Spaces:
Sleeping
Sleeping
File size: 7,364 Bytes
b3d14e3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | """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())
|