Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions grl_snam/scorecard.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,67 @@ def to_dict(self) -> dict:
def to_json(self) -> str:
return json.dumps(self.to_dict())

@classmethod
def from_dict(cls, d: dict) -> NavScorecard:
"""Parse a scorecard dict back into a ``NavScorecard`` — the inverse of :meth:`to_dict`, and
the reader for the C++ ``cvc::nav::scorecard_json`` output (the keys are field-for-field the
same, so a ``dbg_arrival_check3 --episodes`` / ``scorecard_json`` row loads directly). This is
the plumbing a selection/loss stage reads a recorded corpus fitness from; nothing consumes it
yet (Track 4 Phase 1+ wires it into training).

Tolerant of a partial producer: any missing key keeps the dataclass default, so an older or
newer writer (or one that omits zero fields) still loads. An ``rf`` sub-object, if present
(the DBG ``scorecard_json`` embeds one), is ignored here — the base reader is RF-free; the DBG
side reads the RF scorecard separately.
"""
cov = d.get("mean_coverage") or {}
return cls(
checkpoint=str(d.get("checkpoint", "")),
n_episodes=int(d.get("n_episodes", 0)),
n_vehicle_runs=int(d.get("n_vehicle_runs", 0)),
success_rate=float(d.get("success_rate", 0.0)),
arrival_rate=float(d.get("arrival_rate", 0.0)),
mean_time_to_goal_s=float(d.get("mean_time_to_goal_s", 0.0)),
p95_time_to_goal_s=float(d.get("p95_time_to_goal_s", 0.0)),
mean_makespan_s=float(d.get("mean_makespan_s", 0.0)),
mean_path_ratio=float(d.get("mean_path_ratio", 0.0)),
mean_turn_total_rad=float(d.get("mean_turn_total_rad", 0.0)),
mean_fuel=float(d.get("mean_fuel", 0.0)),
mean_penetration_pct=float(d.get("mean_penetration_pct", 0.0)),
veh_contacts_per_run=float(d.get("veh_contacts_per_run", 0.0)),
mean_min_sep_m=float(d.get("mean_min_sep_m", 0.0)),
mean_closest_approach_m=float(d.get("mean_closest_approach_m", 0.0)),
mean_stall_steps=float(d.get("mean_stall_steps", 0.0)),
# missing/null -> the 13-zero default (NOT []), matching the dataclass default so a
# partial producer that drops the all-zero share still loads a length-NUM_MATERIALS list
# (a downstream `share[m]` never IndexErrors).
material_time_share=[
float(x) for x in (d.get("material_time_share") or [0.0] * NUM_MATERIALS)
],
form_arrival_rate=float(d.get("form_arrival_rate", 0.0)),
form_mission_rate=float(d.get("form_mission_rate", 0.0)),
mean_slot_error_m=float(d.get("mean_slot_error_m", 0.0)),
mean_coverage=NavCoverage(
explored_frac=float(cov.get("explored_frac", 0.0)),
visible_frac=float(cov.get("visible_frac", 0.0)),
believed_free_frac=float(cov.get("believed_free_frac", 0.0)),
phantom_frac=float(cov.get("phantom_frac", 0.0)),
),
mean_sense_flips=float(d.get("mean_sense_flips", 0.0)),
mean_alpha=float(d.get("mean_alpha", 0.0)),
mean_beta=float(d.get("mean_beta", 0.0)),
mean_gamma=float(d.get("mean_gamma", 0.0)),
mean_mu=float(d.get("mean_mu", 0.0)),
mean_mrisk=float(d.get("mean_mrisk", 0.0)),
mean_ext_force=float(d.get("mean_ext_force", 0.0)),
)

@classmethod
def from_json(cls, s: str) -> NavScorecard:
"""Parse a scorecard JSON string (``to_json`` / C++ ``scorecard_json``) into a
``NavScorecard``. See :meth:`from_dict`."""
return cls.from_dict(json.loads(s))


def aggregate_nav(episodes: list[EpisodeStats], checkpoint: str = "") -> NavScorecard:
"""Reduce a corpus of episodes into one base scorecard row. Same reduction as the
Expand Down
62 changes: 62 additions & 0 deletions tests/test_scorecard.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@

import math

from grl_snam.material_palette import NUM_MATERIALS
from grl_snam.metrics import NavMetrics, NavStats
from grl_snam.scorecard import (
EpisodeStats,
NavCoverage,
NavScorecard,
VehStats,
aggregate_nav,
compute_coverage,
Expand Down Expand Up @@ -244,3 +246,63 @@ def test_scorecard_json_has_track1_keys():
"believed_free_frac",
"phantom_frac",
}


# --- Track 4 Phase 0: the scorecard reader (inverse of to_dict/to_json; loads C++ scorecard_json) ---


def test_scorecard_reader_round_trips():
# A fully-populated scorecard (all Track-1 field families non-default) survives to_dict->from_dict
# and to_json->from_json unchanged. Since to_dict's keys are field-for-field the C++
# cvc::nav::scorecard_json keys, this is also the guarantee that a recorded C++ scorecard row loads.
e0 = EpisodeStats(
success=True,
makespan_s=20,
penetration_pct=3,
min_sep_m=5,
coverage=NavCoverage(0.6, 0.3, 0.6, 0.2),
per_vehicle=[
VehStats(
arrived=True,
convoy_id=0,
formation_parent=-1,
sense_flips=4,
drive_steps=2,
alpha_mean=1.0,
beta_mean=2.0,
gamma_mean=1.0,
mu_mean=0.5,
mrisk_mean=0.25,
ext_force_mean=1.5,
),
VehStats(
arrived=True,
convoy_id=0,
formation_parent=0,
formation_arrived=True,
slot_error_mean_m=1.5,
closest_approach_m=3.0,
stall_steps=2,
),
],
)
sc = aggregate_nav([e0], "ckpt-RT")
d = sc.to_dict()
assert NavScorecard.from_dict(d).to_dict() == d # dict round-trip is the exact inverse
assert NavScorecard.from_json(sc.to_json()).to_dict() == d # JSON round-trip too


def test_scorecard_reader_tolerates_partial_and_ignores_rf():
# A partial producer (missing keys) loads with dataclass defaults; an embedded "rf" sub-object
# (the DBG scorecard_json shape) is ignored by the base reader.
sc = NavScorecard.from_dict(
{"checkpoint": "x", "success_rate": 0.5, "rf": {"outage_rate": 0.9}}
)
assert sc.checkpoint == "x"
assert math.isclose(sc.success_rate, 0.5)
assert sc.n_episodes == 0 # missing scalar -> default
assert math.isclose(sc.form_arrival_rate, 0.0) # missing Track-1 field -> default
assert math.isclose(sc.mean_coverage.phantom_frac, 0.0) # missing nested -> default
# missing list field -> the length-NUM_MATERIALS zero default, NOT [] (so share[m] never IndexErrors)
assert sc.material_time_share == [0.0] * NUM_MATERIALS
assert not hasattr(sc, "rf") # rf sub-object dropped, not smuggled onto the base row
Loading