Initial Commit
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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 = ×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
|
||||
}
|
||||
Reference in New Issue
Block a user