Source code for argos.core.photometry.quality
"""Live, auditable quality assessment for a comparison-star ensemble.
The acquisition preview is deliberately not a substitute for calibrated Siril
photometry. It can nevertheless catch a bad reference before it poisons a
night: every comparison already has a leave-one-out differential curve, so its
own stability can be assessed against the other comparisons.
"""
from __future__ import annotations
import json
import math
import os
import statistics
from datetime import datetime, timezone
from pathlib import Path
from argos.core.catalog.targets import ROLE_COMPARISON, TargetSet
from argos.core.photometry.uncertainty import estimate_lightcurve_scatter
[docs]
def comparison_quality_report(
target_set: TargetSet,
curves: dict,
*,
min_points: int = 10,
max_scatter_mag: float = 0.10,
max_formal_error_mag: float = 0.10,
) -> dict:
"""Assess every comparison's leave-one-out curve without changing it.
``stable`` means only that the *live preview* has enough internally
consistent samples. It is not a calibrated-photometry certification;
that belongs to the registered and calibrated Siril sequence.
"""
entries: list[dict] = []
for star in target_set.by_role(ROLE_COMPARISON):
key = star.auid or star.display_name
curve = curves.get(key)
points = [] if curve is None else curve.points
valid = [
point
for point in points
if not point.saturated
and math.isfinite(point.jd_utc)
and math.isfinite(point.mag)
and math.isfinite(point.formal_mag_err or point.mag_err)
]
scatter = estimate_lightcurve_scatter((point.jd_utc, point.mag) for point in valid)
formal_median = (
statistics.median(point.formal_mag_err or point.mag_err for point in valid)
if valid
else None
)
if len(valid) < min_points or scatter is None or formal_median is None:
status = "insufficient_data"
elif formal_median > max_formal_error_mag:
status = "noisy"
elif scatter > max_scatter_mag:
status = "unstable"
else:
status = "stable"
entries.append(
{
"name": star.display_name,
"auid": star.auid,
"n_valid": len(valid),
"scatter_mag": None if scatter is None else round(float(scatter), 5),
"median_formal_error_mag": (
None if formal_median is None else round(float(formal_median), 5)
),
"status": status,
}
)
stable = sum(entry["status"] == "stable" for entry in entries)
assessed = sum(entry["status"] != "insufficient_data" for entry in entries)
if not entries or assessed < len(entries):
status = "insufficient_data"
elif stable < 3:
status = "not_ready"
else:
status = "live_preview_consistent"
return {
"schema": 1,
"generated_utc": datetime.now(timezone.utc).isoformat(),
"selection_status": status,
"scientific_note": (
"Leave-one-out live-preview assessment only. Confirm the ensemble on "
"registered, calibrated frames in Siril / star_var_script."
),
"criteria": {
"min_points": int(min_points),
"max_scatter_mag": float(max_scatter_mag),
"max_formal_error_mag": float(max_formal_error_mag),
},
"comparison_stars": entries,
}
[docs]
def save_comparison_quality_report(path, report: dict) -> None:
"""Atomically persist the live assessment beside the observing session."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(report, indent=2), encoding="utf-8")
os.replace(tmp, path)