roverdevkit / scripts /run_mass_validation.py
jjreif's picture
Deploy roverdevkit @ 2676a67
b3d14e3
Raw
History Blame Contribute Delete
7.36 kB
"""Run the bottom-up mass-model validation and write the paper artifacts (§5.2).
Cross-checks the bottom-up parametric mass model against **published
full-up total masses** of real rovers
(:func:`roverdevkit.mass.validation.validate_against_published_rovers`,
data in ``data/mass_validation_set.csv``). This is a genuine two-sided
accuracy check: the model's specific-mass coefficients are cited from
external space-hardware sources (SMAD, AIAA S-120A, vendor catalogues) and
are **never regressed on these rovers**, so
the comparison is out-of-sample. Together with the single-wheel
terramechanics validation (sec. 5.1) it is one of the two component-level
empirical validations the paper rests on.
Outputs (under ``--out-dir``, default ``reports/mass_validation``):
- ``summary.csv`` — one row per rover: published vs predicted total,
absolute / percent error, in-class flag, and the full subsystem mass
breakdown.
- ``mass_validation_report.md`` — human-readable rollup with the
per-rover table and the in-class aggregate statistics.
Usage
-----
::
python scripts/run_mass_validation.py
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import pandas as pd
from roverdevkit.mass.validation import (
ValidationSummary,
validate_against_published_rovers,
)
# Paper-side acceptance target for the primary statistic (median |err|
# on in-class rovers). Matches tests/test_mass.py and the module docstring.
_IN_CLASS_TARGET_PCT: float = 30.0
_BREAKDOWN_FIELDS: tuple[str, ...] = (
"chassis_kg",
"wheels_kg",
"motors_and_drives_kg",
"solar_panels_kg",
"battery_kg",
"avionics_kg",
"harness_kg",
"thermal_kg",
"margin_kg",
"payload_kg",
)
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/mass_validation"))
return p.parse_args(argv)
def _summary_to_frame(summary: ValidationSummary) -> pd.DataFrame:
rows: list[dict[str, object]] = []
for r in summary.per_rover:
row: dict[str, object] = {
"rover_name": r.rover_name,
"in_class": r.in_class,
"mass_published_kg": r.mass_published_kg,
"mass_predicted_kg": r.mass_predicted_kg,
"absolute_error_kg": r.absolute_error_kg,
"percent_error": r.percent_error,
}
for field in _BREAKDOWN_FIELDS:
row[field] = float(getattr(r.breakdown, field))
rows.append(row)
return pd.DataFrame(rows)
def _markdown(df: pd.DataFrame, summary: ValidationSummary) -> str:
target_ok = summary.median_abs_percent_error_in_class <= _IN_CLASS_TARGET_PCT
lines: list[str] = [
"# Mass-model validation against published rover masses (\u00a75.2)",
"",
"Bottom-up parametric mass model vs **published full-up total mass**",
"for real rovers (`data/mass_validation_set.csv`). The specific-mass",
"budget structure and housekeeping fractions follow SMAD / AIAA S-120A;",
"solar, battery, and avionics MERs use SMAD bands; mobility terms use",
"vendor catalogues and engineering defaults (see Table in §3.3).",
"Defaults are **never regressed on these rovers**,",
"so this is an out-of-sample, two-sided accuracy check \u2014 the mass",
"counterpart to the single-wheel terramechanics validation (\u00a75.1).",
"",
"The primary statistic is the **median absolute percent error on",
"in-class (5\u201350 kg) rovers**; the mobility defaults are intended for",
"that regime. Out-of-regime rovers",
"(ultra-micro < 5 kg, and > 50 kg) are reported but excluded from the",
"primary statistic and flagged `in_class = False`.",
"",
"## Per-rover results",
"",
]
cols = [
("rover_name", "rover"),
("in_class", "in_class"),
("mass_published_kg", "published (kg)"),
("mass_predicted_kg", "predicted (kg)"),
("absolute_error_kg", "err (kg)"),
("percent_error", "err %"),
]
lines.append("| " + " | ".join(label for _, label in cols) + " |")
lines.append("| " + " | ".join("---" for _ in cols) + " |")
for _, row in df.iterrows():
cells: list[str] = []
for key, _label in cols:
v = row[key]
if key == "in_class":
cells.append("yes" if bool(v) else "no")
elif key == "percent_error":
cells.append(f"{float(v):+.1f}")
elif key in ("mass_published_kg", "mass_predicted_kg", "absolute_error_kg"):
cells.append(f"{float(v):.2f}")
else:
cells.append(str(v))
lines.append("| " + " | ".join(cells) + " |")
lines.append("")
worst = summary.worst_in_class
lines.extend(
[
"## Aggregate (in-class, 5\u201350 kg)",
"",
f"- Rovers in class: `{summary.n_in_class}` of `{summary.n_total}`",
f"- **Median |error|: `{summary.median_abs_percent_error_in_class:.1f}\u202f%`** "
f"(target \u2264 {_IN_CLASS_TARGET_PCT:.0f}\u202f% \u2014 "
f"{'PASS' if target_ok else 'FAIL'})",
f"- Mean |error|: `{summary.mean_abs_percent_error_in_class:.1f}\u202f%`",
f"- Worst in-class: `{worst.rover_name}` ({worst.percent_error:+.1f}\u202f%)",
"",
"## Interpretation",
"",
"- This is a **two-sided** accuracy validation (signed % error on a",
" directly-published quantity), unlike the one-sided flown-rover",
" power/thermal/range consistency checks in \u00a75.3. With the",
" coefficients fixed from the literature, the model predicts",
" in-class total mass to within a median ~10\u201315\u202f% \u2014 well inside",
" the conceptual-design margin a designer would carry.",
"- The ultra-micro out-of-regime case (CADRE-unit ~2 kg, "
"+~100\u202f%) is reported, not hidden: below ~5 kg the model's",
" fixed-overhead terms (motor base mass, avionics, harness,",
" thermal, margin) dominate and the specific-mass MERs over-",
" predict. This bounds the model's lower-mass envelope and",
" matches the surrogate-envelope caveat in \u00a75.4.",
]
)
return "\n".join(lines) + "\n"
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv)
summary = validate_against_published_rovers()
df = _summary_to_frame(summary)
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 / "mass_validation_report.md"
md_path.write_text(_markdown(df, summary))
print(f"Wrote 2 artifact(s) to {args.out_dir}:")
print(f" csv: {csv_path}")
print(f" report: {md_path}")
print(
f" in-class median |err| = "
f"{summary.median_abs_percent_error_in_class:.1f}% "
f"(n={summary.n_in_class}/{summary.n_total})"
)
return 0
if __name__ == "__main__":
sys.exit(main())