Files
Astronome/backend/src/phd2/mod.rs
T
2026-04-10 00:09:42 +02:00

307 lines
11 KiB
Rust

use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::config::PLATE_SCALE_ARCSEC;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Phd2Analysis {
pub session_date: String, // Extracted from log
pub duration_min: u32,
pub total_frames: u32,
pub rms_ra_arcsec: f64,
pub rms_dec_arcsec: f64,
pub rms_total_arcsec: f64,
pub peak_error_arcsec: f64,
pub star_lost_count: u32,
pub mean_snr: f64,
pub drift_ra_arcsec_per_min: f64,
pub drift_dec_arcsec_per_min: f64,
// Equipment details extracted from header
#[serde(skip_serializing_if = "Option::is_none")]
pub equipment_profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub camera_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exposure_ms: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mount_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pixel_scale_arcsec: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hfd_px: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guide_star_snr_at_start: Option<f64>,
}
pub fn parse_phd2_log(content: &str) -> anyhow::Result<Phd2Analysis> {
// Extract session date from log header
// Look for patterns like "Log enabled at 2026-03-17 19:33:09" or "Guiding Begins at 2026-03-17 20:04:53"
let session_date = extract_session_date(content)
.unwrap_or_else(|| chrono::Utc::now().naive_utc().date().to_string());
// Extract header information before data section
let equipment_profile = extract_header_value(content, "Equipment Profile = ");
let camera_name = extract_camera_name(content);
let exposure_ms = extract_header_value(content, "Exposure = ")
.and_then(|s| s.trim_end_matches(" ms").parse::<u32>().ok());
let mount_name = extract_header_value(content, "Mount = ")
.map(|s| s.split(',').next().unwrap_or(&s).to_string());
let pixel_scale_arcsec = extract_header_value(content, "Pixel scale = ")
.and_then(|s| s.trim_end_matches(" arc-sec/px").parse::<f64>().ok());
let hfd_px = extract_header_value(content, "HFD = ")
.and_then(|s| s.trim_end_matches(" px").parse::<f64>().ok());
// PHD2 logs have a header block followed by CSV data
// Find the line starting with "Frame,Time,..."
let header_line = content
.lines()
.enumerate()
.find(|(_, line)| line.starts_with("Frame,Time,"))
.map(|(i, _)| i)
.context("PHD2 log: could not find data header line")?;
let data_lines: Vec<&str> = content
.lines()
.skip(header_line)
.collect();
if data_lines.is_empty() {
anyhow::bail!("PHD2 log: no data lines found");
}
// Parse header to find column indices
let headers: Vec<&str> = data_lines[0].split(',').collect();
let col = |name: &str| -> anyhow::Result<usize> {
headers.iter().position(|h| h.trim() == name)
.with_context(|| format!("PHD2 log: missing column '{}'", name))
};
let col_frame = col("Frame")?;
let col_time = col("Time")?;
let col_ra_raw = headers.iter().position(|h| h.trim() == "RARawDistance")
.or_else(|| headers.iter().position(|h| h.trim() == "RAGuideDistance"))
.context("PHD2 log: missing RA distance column")?;
let col_dec_raw = headers.iter().position(|h| h.trim() == "DECRawDistance")
.or_else(|| headers.iter().position(|h| h.trim() == "DECGuideDistance"))
.context("PHD2 log: missing Dec distance column")?;
let col_snr = headers.iter().position(|h| h.trim() == "SNR");
let col_err = headers.iter().position(|h| h.trim() == "ErrorCode");
let mut ra_vals: Vec<f64> = Vec::new();
let mut dec_vals: Vec<f64> = Vec::new();
let mut snr_vals: Vec<f64> = Vec::new();
let mut star_lost = 0u32;
let mut first_time: Option<f64> = None;
let mut last_time: Option<f64> = None;
let mut guide_star_snr_at_start: Option<f64> = None;
for line in data_lines.iter().skip(1) {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let fields: Vec<&str> = line.split(',').collect();
// Check error code
if let Some(ec) = col_err {
if let Some(err_str) = fields.get(ec) {
if let Ok(err_code) = err_str.trim().parse::<i32>() {
if err_code != 0 {
star_lost += 1;
continue;
}
}
}
}
let _frame: u32 = fields.get(col_frame)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
let time: f64 = fields.get(col_time)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0.0);
if first_time.is_none() {
first_time = Some(time);
// Capture SNR from first frame for reference
if let Some(sc) = col_snr {
if let Some(snr) = fields.get(sc).and_then(|s| s.trim().parse::<f64>().ok()) {
guide_star_snr_at_start = Some(snr);
}
}
}
last_time = Some(time);
let ra: f64 = fields.get(col_ra_raw)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0.0);
let dec: f64 = fields.get(col_dec_raw)
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0.0);
// Convert pixels to arcsec if values look like pixels (> 10.0)
let ra_arcsec = if ra.abs() < 30.0 && ra.abs() > 0.001 {
ra * PLATE_SCALE_ARCSEC
} else {
ra
};
let dec_arcsec = if dec.abs() < 30.0 && dec.abs() > 0.001 {
dec * PLATE_SCALE_ARCSEC
} else {
dec
};
ra_vals.push(ra_arcsec);
dec_vals.push(dec_arcsec);
if let Some(sc) = col_snr {
if let Some(snr) = fields.get(sc).and_then(|s| s.trim().parse::<f64>().ok()) {
snr_vals.push(snr);
}
}
}
let n = ra_vals.len() as f64;
if n == 0.0 {
anyhow::bail!("PHD2 log: no valid data frames found");
}
let rms_ra = (ra_vals.iter().map(|v| v * v).sum::<f64>() / n).sqrt();
let rms_dec = (dec_vals.iter().map(|v| v * v).sum::<f64>() / n).sqrt();
let rms_total = (rms_ra * rms_ra + rms_dec * rms_dec).sqrt();
let peak_ra = ra_vals.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
let peak_dec = dec_vals.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
let peak_error = peak_ra.max(peak_dec);
let mean_snr = if snr_vals.is_empty() {
0.0
} else {
snr_vals.iter().sum::<f64>() / snr_vals.len() as f64
};
let duration_sec = match (first_time, last_time) {
(Some(f), Some(l)) => (l - f).max(0.0),
_ => 0.0,
};
let duration_min = (duration_sec / 60.0) as u32;
// Simple linear drift: last half minus first half average
let drift_ra = if n > 4.0 {
let half = (n as usize) / 2;
let first_half_mean = ra_vals[..half].iter().sum::<f64>() / half as f64;
let second_half_mean = ra_vals[half..].iter().sum::<f64>() / (n as usize - half) as f64;
if duration_min > 0 {
(second_half_mean - first_half_mean) / (duration_min as f64 / 2.0)
} else {
0.0
}
} else {
0.0
};
let drift_dec = if n > 4.0 {
let half = (n as usize) / 2;
let first_half_mean = dec_vals[..half].iter().sum::<f64>() / half as f64;
let second_half_mean = dec_vals[half..].iter().sum::<f64>() / (n as usize - half) as f64;
if duration_min > 0 {
(second_half_mean - first_half_mean) / (duration_min as f64 / 2.0)
} else {
0.0
}
} else {
0.0
};
Ok(Phd2Analysis {
session_date,
duration_min,
total_frames: n as u32,
rms_ra_arcsec: rms_ra,
rms_dec_arcsec: rms_dec,
rms_total_arcsec: rms_total,
peak_error_arcsec: peak_error,
star_lost_count: star_lost,
mean_snr,
drift_ra_arcsec_per_min: drift_ra,
drift_dec_arcsec_per_min: drift_dec,
equipment_profile,
camera_name,
exposure_ms,
mount_name,
pixel_scale_arcsec,
hfd_px,
guide_star_snr_at_start,
})
}
fn extract_header_value(content: &str, key: &str) -> Option<String> {
content
.lines()
.find(|line| line.contains(key))
.and_then(|line| {
let parts: Vec<&str> = line.split(key).collect();
if parts.len() > 1 {
let value = parts[1].trim();
// Handle comma-separated values by taking up to first comma
let end_pos = value.find(',').unwrap_or(value.len());
Some(value[..end_pos].trim().to_string())
} else {
None
}
})
}
fn extract_camera_name(content: &str) -> Option<String> {
// Look for "Camera = XXX, ..." line
content
.lines()
.find(|line| line.trim().starts_with("Camera = "))
.and_then(|line| {
extract_header_value(&format!("{}\n", line), "Camera = ")
})
}
fn extract_session_date(content: &str) -> Option<String> {
// Look for patterns like:
// "Log enabled at 2026-03-17 19:33:09"
// "Guiding Begins at 2026-03-17 20:04:53"
for line in content.lines() {
// Try "Log enabled at" pattern first
if let Some(idx) = line.find("Log enabled at ") {
let date_time = &line[idx + 15..];
return extract_date_from_timestamp(date_time);
}
// Try "Guiding Begins at" pattern
if let Some(idx) = line.find("Guiding Begins at ") {
let date_time = &line[idx + 18..];
return extract_date_from_timestamp(date_time);
}
// Try "Calibration Begins at" pattern as fallback
if let Some(idx) = line.find("Calibration Begins at ") {
let date_time = &line[idx + 21..];
return extract_date_from_timestamp(date_time);
}
}
None
}
fn extract_date_from_timestamp(timestamp: &str) -> Option<String> {
// Extract date part from timestamp like "2026-03-17 19:33:09"
// Just take the first 10 characters which should be YYYY-MM-DD
if timestamp.len() >= 10 {
let date_part = &timestamp[..10];
// Validate it's in YYYY-MM-DD format
if date_part.chars().nth(4) == Some('-')
&& date_part.chars().nth(7) == Some('-')
&& date_part[..4].chars().all(|c| c.is_numeric())
&& date_part[5..7].chars().all(|c| c.is_numeric())
&& date_part[8..10].chars().all(|c| c.is_numeric())
{
return Some(date_part.to_string());
}
}
None
}