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, #[serde(skip_serializing_if = "Option::is_none")] pub camera_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub exposure_ms: Option, #[serde(skip_serializing_if = "Option::is_none")] pub mount_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub pixel_scale_arcsec: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hfd_px: Option, #[serde(skip_serializing_if = "Option::is_none")] pub guide_star_snr_at_start: Option, } pub fn parse_phd2_log(content: &str) -> anyhow::Result { // 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::().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::().ok()); let hfd_px = extract_header_value(content, "HFD = ") .and_then(|s| s.trim_end_matches(" px").parse::().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 { 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 = Vec::new(); let mut dec_vals: Vec = Vec::new(); let mut snr_vals: Vec = Vec::new(); let mut star_lost = 0u32; let mut first_time: Option = None; let mut last_time: Option = None; let mut guide_star_snr_at_start: Option = 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::() { 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::().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::().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::() / n).sqrt(); let rms_dec = (dec_vals.iter().map(|v| v * v).sum::() / 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::() / 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::() / half as f64; let second_half_mean = ra_vals[half..].iter().sum::() / (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::() / half as f64; let second_half_mean = dec_vals[half..].iter().sum::() / (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 { 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 { // 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 { // 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 { // 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 = ×tamp[..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 }