"""Run the de-tuned peak-solar prediction and write the paper artifacts (ยง5.3). Replaces the *circular* flown-rover peak-solar band check (which used per-rover ``panel_efficiency`` / ``panel_dust_factor`` chosen to match each rover's published number) with an *honest* forward prediction (:mod:`roverdevkit.validation.power_prediction`): a single, fixed, literature-justified panel parameter set is applied uniformly to every rover, and the only rover-specific inputs are published solar-array area and scenario latitude. The prediction is allowed to be wrong, and the residual is reported. Headline result the artifacts capture: - Pragyan (fresh array, single lunar day): the de-tuned beginning-of-life clean-array prediction lands inside the published band with single-digit percent error and stays in-band across the full literature cell-efficiency range -- a genuine, zero-tuning predictive hit. - Yutu-2 (dozens of lunar days): the BOL prediction over-predicts the published operational peak by ~2x; the implied net derate we back out (published / BOL) is consistent with multi-year dust + end-of-life degradation. Reported as a recovered output, not a tuned input. Outputs (under ``--out-dir``, default ``reports/power_prediction``): - ``summary.csv`` -- one row per flown rover with geometry, the de-tuned predictions, the sensitivity band, the published band, in-band flag, percent error, and the implied total derate. - ``power_prediction_report.md`` -- human-readable rollup. Usage ----- :: python scripts/run_power_prediction.py """ from __future__ import annotations import argparse import sys from pathlib import Path import pandas as pd from roverdevkit.validation.power_prediction import ( CELL_EFFICIENCY_BOL, CELL_EFFICIENCY_RANGE, CLEAN_DUST_FACTOR, ELECTRICAL_DERATE, HIGH_TEMP_DERATE, PACKING_FACTOR, SYSTEM_EFFICIENCY, DetunedPowerPrediction, format_report, predict_all_flown, ) def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: p = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) p.add_argument("--out-dir", type=Path, default=Path("reports/power_prediction")) return p.parse_args(argv) def _to_frame(predictions: tuple[DetunedPowerPrediction, ...]) -> pd.DataFrame: rows: list[dict[str, object]] = [] for p in predictions: rows.append( { "rover_name": p.rover_name, "latitude_deg": p.latitude_deg, "panel_area_m2": p.panel_area_m2, "peak_elevation_deg": round(p.peak_elevation_deg, 2), "mission_duration_days": p.mission_duration_days, "predicted_bol_w": round(p.predicted_bol_w, 1), "predicted_clean_w": round(p.predicted_clean_w, 1), "sensitivity_low_w": round(p.sensitivity_low_w, 1), "sensitivity_high_w": round(p.sensitivity_high_w, 1), "published_w": p.published_w, "band_low_w": p.band_low_w, "band_high_w": p.band_high_w, "in_band": p.in_band, "pct_error_vs_published": round(p.pct_error_vs_published, 1), "implied_total_derate": round(p.implied_total_derate, 3), } ) return pd.DataFrame(rows) def _markdown(df: pd.DataFrame, predictions: tuple[DetunedPowerPrediction, ...]) -> str: lines = [ "# De-tuned peak-solar prediction (flown rovers)", "", "**What this is.** A genuine forward prediction of peak noon solar power", "for the flown rovers using a *single, fixed, literature-justified* panel", "parameter set applied uniformly -- no per-rover calibration. The only", "rover-specific inputs are published solar-array area and scenario", "latitude. This replaces the earlier circular band check, which tuned", "`panel_efficiency` / `panel_dust_factor` per rover to match each rover's", "own published number.", "", "**Fixed literature parameter set (every rover).**", "", "| factor | value | source |", "|---|---|---|", f"| cell efficiency (BOL AM0) | {CELL_EFFICIENCY_BOL:.2f} | " "triple-junction GaAs/Ge (Spectrolab XTJ / AzurSpace 3G30) |", f"| packing factor | {PACKING_FACTOR:.2f} | Patel Ch. 4 |", f"| electrical derate (MPPT+harness+diode) | {ELECTRICAL_DERATE:.2f} | SMAD Ch. 11 |", f"| high-temperature derate (lunar noon) | {HIGH_TEMP_DERATE:.2f} | " "GaAs power coefficient |", f"| **net system efficiency** | **{SYSTEM_EFFICIENCY:.3f}** | product of the above |", f"| clean-array dust factor | {CLEAN_DUST_FACTOR:.2f} | fresh / lunar-day-1 array |", "", "The sensitivity band sweeps cell efficiency over " f"{CELL_EFFICIENCY_RANGE[0]:.2f}-{CELL_EFFICIENCY_RANGE[1]:.2f} so no single", "efficiency choice is load-bearing.", "", "## Per-rover predictions", "", "| rover | area (m^2) | noon elev (deg) | pred BOL (W) | pred clean (W) | " "sensitivity (W) | published (W) | band (W) | in-band | err % | implied derate |", "|---|---|---|---|---|---|---|---|---|---|---|", ] for p in predictions: lines.append( f"| {p.rover_name} | {p.panel_area_m2:.2f} | {p.peak_elevation_deg:.1f} | " f"{p.predicted_bol_w:.1f} | {p.predicted_clean_w:.1f} | " f"{p.sensitivity_low_w:.0f}-{p.sensitivity_high_w:.0f} | " f"{p.published_w:.0f} | {p.band_low_w:.0f}-{p.band_high_w:.0f} | " f"{'yes' if p.in_band else '**no**'} | {p.pct_error_vs_published:+.1f} | " f"{p.implied_total_derate:.2f} |" ) lines += [ "", "## Interpretation", "", "- **Fresh arrays predict cleanly.** A rover that flew a single lunar day", " reports a near-beginning-of-life peak; the de-tuned BOL + clean-array", " prediction lands inside its published band with single-digit percent", " error and stays in-band across the full literature cell-efficiency", " range. That is a real predictive hit with zero per-rover tuning.", "- **Aged arrays expose their degradation rather than hide it.** A rover", " that operated for dozens of lunar days reports a heavily dust- and", " end-of-life-degraded operational peak. The BOL prediction over-predicts", " it, and the implied net derate (published / BOL) that we back out is", " independently consistent with multi-year lunar dust accumulation plus", " EOL cell degradation. We report that derate as a recovered output, not", " a tuned input.", "", "Net: with literature BOL clean-array parameters and no per-rover", "calibration, the power sub-model predicts the fresh-array rover within", "its published band; the only residual is a physically attributable aging", "derate on the multi-year rover. Regenerate via", "`scripts/run_power_prediction.py`.", "", "```", format_report(predictions), "```", "", ] return "\n".join(lines) def main(argv: list[str] | None = None) -> int: args = _parse_args(argv) predictions = predict_all_flown() df = _to_frame(predictions) args.out_dir.mkdir(parents=True, exist_ok=True) csv_path = args.out_dir / "summary.csv" df.to_csv(csv_path, index=False) md_path = args.out_dir / "power_prediction_report.md" md_path.write_text(_markdown(df, predictions)) print(f"Wrote 2 artifact(s) to {args.out_dir}:") print(f" csv: {csv_path}") print(f" report: {md_path}") print() print(format_report(predictions)) return 0 if __name__ == "__main__": sys.exit(main())