roverdevkit / webapp /backend /schemas.py
jjreif's picture
Deploy roverdevkit @ 2676a67
b3d14e3
Raw
History Blame Contribute Delete
25.9 kB
"""Pydantic v2 schemas exposed at the HTTP boundary.
Design goal: be a *thin* mirror of :mod:`roverdevkit.schema` so the
frontend can talk to the backend in terms of the same `DesignVector` /
`MissionScenario` objects the Python core uses. Where it makes sense,
we re-export the core models verbatim (frozen + extra-forbid is fine
over JSON); where the API value-add is non-trivial — `PredictRequest`,
`PredictResponse`, `RegistryEntrySummary`, etc. — we define a dedicated
boundary type so a future schema bump on the core does not silently
break the OpenAPI surface.
All response models have ``model_config = ConfigDict(frozen=True)`` so
they are safe to share across requests and so callers cannot mutate
cached registry / scenario payloads.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from roverdevkit.schema import DesignVector, MissionScenario
# ---------------------------------------------------------------------------
# Shared mission-requirement override fields
# ---------------------------------------------------------------------------
#
# Schema v9: scientific payload is a *mission requirement* carried on
# ``MissionScenario`` (``payload_mass_kg`` / ``payload_power_w``), not a
# design-vector trade. Every request that resolves a scenario server-side
# accepts an optional per-call override so the Mission-Inputs panel can
# editing the canonical scenario catalogue. Both are LHS-sampled surrogate
# inputs over ``[0, 30]`` (mirroring the v7_1 δ_ops promotion), so any
# in-bounds override stays on the surrogate path with calibrated PIs.
def _required_obstacle_field() -> Any:
return Field(
default=None,
ge=0.0,
le=0.30,
description=(
"Optional per-query override for "
"``MissionScenario.required_obstacle_height_m`` (minimum "
"traversable obstacle height, m). ``None`` uses the scenario "
"default (0 for smooth-regolith canonical scenarios)."
),
)
def _payload_mass_field() -> Any:
return Field(
default=None,
ge=0.0,
le=30.0,
description=(
"Optional per-query override for "
"``MissionScenario.payload_mass_kg`` (scientific-payload mass, "
"kg, a mission requirement). ``None`` uses the scenario's "
"class-typical default. Schema v9: payload mass is an "
"LHS-sampled surrogate input over [0, 30], so any in-bounds "
"override stays on the surrogate path with calibrated PIs."
),
)
def _mission_duration_field() -> Any:
return Field(
default=None,
ge=0.5,
le=90.0,
description=(
"Optional per-query override for "
"``MissionScenario.mission_duration_earth_days``. Sets the "
"simulation window for solar averaging, energy budgeting, "
"and thermal exposure. ``None`` uses the scenario's calibrated "
"default. ``scenario_mission_duration_earth_days`` is an "
"LHS-sampled surrogate input (family-specific ranges, roughly "
"3–35 d), so in-bounds overrides stay on the surrogate path "
"with calibrated PIs."
),
)
def _payload_power_field() -> Any:
return Field(
default=None,
ge=0.0,
le=30.0,
description=(
"Optional per-query override for "
"``MissionScenario.payload_power_w`` (scientific-payload "
"continuous ops-time power draw, W, a mission requirement). "
"``None`` uses the scenario's class-typical default. Schema "
"v9: LHS-sampled surrogate input over [0, 30]."
),
)
# Re-export the core types unchanged. Pydantic v2 serialises both
# transparently to JSON; importing here keeps the OpenAPI schema names
# consistent with the Python core.
__all__ = [
"DesignVector",
"EvaluateMetric",
"EvaluateRequest",
"EvaluateResponse",
"FeatureRow",
"HealthResponse",
"MissionScenario",
"OptimizeCancelResponse",
"OptimizeCheckpointOut",
"OptimizeConstraintIn",
"OptimizeJobResponse",
"OptimizeObjectiveIn",
"OptimizeParetoPoint",
"OptimizeRequest",
"OptimizeResultResponse",
"PredictMode",
"PredictRequest",
"PredictResponse",
"PredictTarget",
"RegistryEntrySummary",
"RegistryListResponse",
"ScenarioListResponse",
"ScenarioWithSoil",
"ShapExplainRequest",
"ShapFeatureScore",
"ShapLocalResponse",
"SoilParametersOut",
"StallDiagnosticOut",
"SweepAxisIn",
"SweepRequest",
"SweepResponse",
"SweepSensitivityOut",
"ThermalDiagnosticOut",
"VersionResponse",
]
# ---------------------------------------------------------------------------
# Health / version
# ---------------------------------------------------------------------------
class HealthResponse(BaseModel):
"""Liveness + artifact-presence probe."""
model_config = ConfigDict(frozen=True)
status: Literal["ok", "degraded"] = "ok"
surrogate_loaded: bool
surrogate_targets: list[str]
quantile_bundles_path: str
class VersionResponse(BaseModel):
"""Static version metadata for the about box."""
model_config = ConfigDict(frozen=True)
api_version: str
package_version: str
dataset_version: str
quantile_bundles_path: str
# ---------------------------------------------------------------------------
# Scenarios
# ---------------------------------------------------------------------------
class SoilParametersOut(BaseModel):
"""Bekker-Wong soil parameter snapshot, JSON-friendly.
Mirrors :class:`roverdevkit.terramechanics.bekker_wong.SoilParameters`
but as a plain Pydantic model so it serialises cleanly without
dataclass field-ordering quirks.
"""
model_config = ConfigDict(frozen=True)
simulant: str
n: float
k_c: float
k_phi: float
cohesion_kpa: float
friction_angle_deg: float
shear_modulus_k_m: float
class ScenarioWithSoil(BaseModel):
"""Canonical mission scenario plus the nominal soil parameters.
The soil block is included so the frontend can show the user what
Bekker-Wong parameters were used as the surrogate's nominal soil
values without an extra round-trip.
"""
model_config = ConfigDict(frozen=True)
scenario: MissionScenario
soil: SoilParametersOut
class ScenarioListResponse(BaseModel):
"""List of canonical tradespace scenarios with brief metadata."""
model_config = ConfigDict(frozen=True)
scenarios: list[ScenarioWithSoil]
# ---------------------------------------------------------------------------
# Registry (real-rover validation set)
# ---------------------------------------------------------------------------
class RegistryEntrySummary(BaseModel):
"""A real-rover registry entry exposed to the frontend.
Mirrors :class:`roverdevkit.validation.rover_registry.RoverRegistryEntry`
excluding its non-JSON-friendly internals (the ``ThermalArchitecture``
object). The thermal architecture is collapsed to a small dict so
the frontend can show the user how the rover differs from the
tradespace defaults without depending on the dataclass shape.
"""
model_config = ConfigDict(frozen=True)
rover_name: str
is_flown: bool
design: DesignVector
scenario: MissionScenario
gravity_m_per_s2: float
thermal_architecture: dict[str, Any]
panel_efficiency: float
panel_dust_factor: float
panel_tilt_deg: float
panel_azimuth_deg: float
imputation_notes: str
class RegistryListResponse(BaseModel):
"""All real-rover registry entries (flown and design-target tiers)."""
model_config = ConfigDict(frozen=True)
rovers: list[RegistryEntrySummary]
# ---------------------------------------------------------------------------
# Predict
# ---------------------------------------------------------------------------
PrimaryTarget = Literal[
"range_km",
"energy_margin_raw_pct",
"slope_capability_deg",
"total_mass_kg",
]
ArchitectureTarget = Literal[
"obstacle_capability_m",
"obstacle_margin_m",
]
OptimizeTarget = PrimaryTarget | ArchitectureTarget
class FeatureRow(BaseModel):
"""The 27-D feature vector actually fed to the surrogate.
Schema v9 added the two payload mission-requirement inputs
(``scenario_payload_mass_kg`` / ``scenario_payload_power_w``),
taking the surrogate input frame from 25 to 27 columns.
Echoed back so the frontend can show the nominal soil / categorical
values that were used; useful for "did I really pick the soil I
thought I picked?" sanity checks and as the basis for OOD warnings
in later steps.
"""
model_config = ConfigDict(frozen=True)
columns: list[str]
values: list[Any]
class PredictRequest(BaseModel):
"""Input payload for :http:post:`/predict`.
The user always submits a full :class:`DesignVector` (the schema's
own bounds validation will reject anything outside the design
space) plus a canonical scenario name. The scenario's nominal soil
parameters are looked up server-side from the soil catalogue.
"""
model_config = ConfigDict(frozen=True, extra="forbid")
design: DesignVector
scenario_name: str = Field(
description="Canonical scenario key (one of the four returned by /scenarios)."
)
operational_duty_cycle: float | None = Field(
default=None,
ge=0.0,
le=0.6,
description=(
"Optional per-query override for "
"``MissionScenario.operational_duty_cycle``. SCHEMA_VERSION "
"v7_1 (v7_1 schema follow-on): δ_ops is now a per-row LHS "
"feature uniform on [0, 0.6], so any in-bounds override "
"stays on the surrogate path with calibrated PIs. The "
"pre-v7_1 evaluator-only fallback for off-default values "
"has been removed."
),
)
payload_mass_kg: float | None = _payload_mass_field()
payload_power_w: float | None = _payload_power_field()
mission_duration_earth_days: float | None = _mission_duration_field()
required_obstacle_height_m: float | None = _required_obstacle_field()
repair_crossings: bool = Field(
default=True,
description=(
"Row-wise sort the (q05, q50, q95) triple before returning. "
"Cheap, never worsens empirical coverage, and avoids "
"non-monotone reports to the frontend. Set False to inspect "
"raw model output."
),
)
class PredictTarget(BaseModel):
"""Per-target prediction triple."""
model_config = ConfigDict(frozen=True)
target: PrimaryTarget
q05: float
q50: float
q95: float
PredictMode = Literal["surrogate", "evaluator_only"]
"""Kept as a literal for response-schema stability across the v6 ->
v7_1 transition. Live ``/predict`` always returns ``"surrogate"``
since v7_1; the ``"evaluator_only"`` slot is retained for forwards-
compat with future evaluator-fallback paths (e.g. out-of-bounds
inputs the surrogate refuses to predict on)."""
class PredictResponse(BaseModel):
"""Median + 90 % PI for each primary regression target.
See ``reports/intervals_v4/SUMMARY.md`` for empirical coverage
on the test split (target ≈ 90 %, achieved 86–92 % per scenario).
SCHEMA_VERSION v7_1 (v7_1 schema follow-on): ``operational_duty_cycle``
is a true surrogate input feature, so any in-bounds δ_ops stays on
the surrogate path. ``mode`` is therefore always ``"surrogate"`` in
v7_1; the literal still admits ``"evaluator_only"`` for forwards-
compat with future fallback paths.
"""
model_config = ConfigDict(frozen=True)
scenario_name: str
quantiles: tuple[float, float, float] = (0.05, 0.50, 0.95)
predictions: list[PredictTarget]
feature_row: FeatureRow
mode: PredictMode = "surrogate"
"""Always ``"surrogate"`` in v7_1; reserved literal slot for future
evaluator fallbacks. See :class:`PredictRequest` for the override
semantics."""
# ---------------------------------------------------------------------------
# Evaluate (deterministic analytical mission evaluator)
# ---------------------------------------------------------------------------
class EvaluateRequest(BaseModel):
"""Input payload for :http:post:`/evaluate`.
Drives the analytical mission evaluator
(:func:`roverdevkit.mission.evaluator.evaluate`, Bekker-Wong) on a
single ``DesignVector`` and a canonical scenario. Used by the
single-design panel as the source of truth for the median value of
each performance metric; the surrogate's quantile heads supply the
prediction-interval band around it.
"""
model_config = ConfigDict(frozen=True, extra="forbid")
design: DesignVector
scenario_name: str = Field(
description="Canonical scenario key (one of the four returned by /scenarios)."
)
operational_duty_cycle: float | None = Field(
default=None,
ge=0.0,
le=0.6,
description=(
"Optional per-query override for "
"``MissionScenario.operational_duty_cycle``. Schema v7: the "
"evaluator uses this value directly as δ_eff (clamped to "
"[0, 1]). ``None`` uses the scenario's calibrated default."
),
)
payload_mass_kg: float | None = _payload_mass_field()
payload_power_w: float | None = _payload_power_field()
mission_duration_earth_days: float | None = _mission_duration_field()
required_obstacle_height_m: float | None = _required_obstacle_field()
class EvaluateMetric(BaseModel):
"""Per-target deterministic value from the corrected evaluator."""
model_config = ConfigDict(frozen=True)
target: PrimaryTarget
value: float
class ThermalDiagnosticOut(BaseModel):
"""Per-design output of the lumped-parameter thermal model.
The single-design panel surfaces both temperatures so users can
see *why* a thermal-survival flag fired (it's almost always the
cold case for micro-rovers without RHUs). ``rhu_power_w`` is
included because it's the most common knob users would reach for
if they were sizing a real rover; in our design vector it is
fixed at 0 W by convention -- thermal is a diagnostic, not a
design lever, since baseline-surrogate.
"""
model_config = ConfigDict(frozen=True)
survives: bool
"""End-to-end pass / fail (= ``hot_case_ok and cold_case_ok``)."""
peak_sun_temp_c: float
lunar_night_temp_c: float
min_operating_temp_c: float
max_operating_temp_c: float
rhu_power_w: float
hibernation_power_w: float
surface_area_m2: float
hot_case_ok: bool
cold_case_ok: bool
class StallDiagnosticOut(BaseModel):
"""Drivetrain stall status and the torque numbers that drove it.
SCHEMA_VERSION v6 (v6 schema update): replaces ``MotorTorqueDiagnosticOut``.
The pre-v6 diagnostic flagged ``motor_torque_ok`` whenever the
per-step peak torque stayed below an implicit, mass-derived ceiling
inside the mass model. v6 makes the ceiling explicit
(``DesignVector.peak_wheel_torque_nm``) and surfaces the stall gate
directly. ``stalled = True`` means the slip-balance torque demand
exceeded the design's drivetrain capacity *or* the slip solver
couldn't develop the required drawbar pull, equivalent to
``MissionMetrics.stalled`` and the underlying
``run_traverse(...).rover_stalled`` flag.
"""
model_config = ConfigDict(frozen=True)
stalled: bool
"""``True`` iff the rover's drivetrain stalled on the scenario's
worst-case slope. Replaces the v5 ``survives`` field."""
peak_torque_demand_nm: float
"""Per-wheel hub torque the slip-balance solve demanded."""
peak_torque_capacity_nm: float
"""``DesignVector.peak_wheel_torque_nm`` echoed back for context."""
class ArchitectureDiagnosticOut(BaseModel):
"""Architecture-proxy obstacle negotiation diagnostic."""
model_config = ConfigDict(frozen=True)
mobility_architecture: Literal["rigid_4wheel", "rocker_bogie_6wheel"]
obstacle_capability_m: float
required_obstacle_height_m: float
obstacle_margin_m: float
obstacle_requirement_met: bool
architecture_mass_kg: float
class EvaluateResponse(BaseModel):
"""Deterministic evaluator output for the four primary regression targets.
Values match :class:`roverdevkit.schema.MissionMetrics` 1:1 for the
primary subset; the response also surfaces structured constraint
diagnostics (``thermal``, ``stall``) so the frontend can explain *why*
a flag fired without a second round-trip.
"""
model_config = ConfigDict(frozen=True)
scenario_name: str
metrics: list[EvaluateMetric]
thermal: ThermalDiagnosticOut
stall: StallDiagnosticOut
architecture: ArchitectureDiagnosticOut
"""Schema v6: replaces the v5 ``motor_torque`` field."""
effective_duty_cycle: float
"""Schema v7: ``operational_duty_cycle`` (per-scenario default or
per-call override) clamped to ``[0, 1]``. The v6 ``min(δ_des,
δ_ops)`` semantics collapsed when ``designed_duty_cycle`` was
removed from the design vector. Surfaced so the single-design
panel can echo the duty the evaluator actually drove the rover at."""
cruise_speed_mps: float
"""Derived rover cruise speed used by the time loop. Replaces the
v5 ``DesignVector.nominal_speed_mps`` design input. See
:func:`roverdevkit.drivetrain.motor.cruise_speed`."""
elapsed_ms: float
class SweepAxisIn(BaseModel):
"""One axis of a parametric sweep (mirror of ``SweepAxis``).
The variable name is validated server-side against
:data:`roverdevkit.tradespace.sweeps.SWEEPABLE_VARIABLES`; the
``lo`` / ``hi`` range is validated against the ``DesignVector``
schema bounds inside :func:`roverdevkit.tradespace.sweeps.expand_grid`
(Pydantic on the per-cell ``DesignVector`` rebuild surfaces the
out-of-bounds case as a ValidationError -> 422).
"""
model_config = ConfigDict(frozen=True, extra="forbid")
variable: str
lo: float
hi: float
n_points: int = Field(ge=2, le=200)
class SweepRequest(BaseModel):
"""``POST /sweep`` body."""
model_config = ConfigDict(frozen=True, extra="forbid")
target: str
"""One of the primary regression targets (range_km,
energy_margin_raw_pct, slope_capability_deg, total_mass_kg)."""
x_axis: SweepAxisIn
y_axis: SweepAxisIn | None = None
base_design: DesignVector
"""The "rest of the design": every dimension not on an axis is
held at this value across the whole grid."""
scenario_name: str
backend: Literal["auto", "evaluator", "surrogate"] = "auto"
operational_duty_cycle: float | None = Field(
default=None,
ge=0.0,
le=0.6,
description=(
"Optional per-query override for "
"``MissionScenario.operational_duty_cycle``. SCHEMA_VERSION "
"v7_1: δ_ops is a true LHS-sampled surrogate input, so any "
"in-bounds override stays on the surrogate sweep path with "
"calibrated quantiles; the deterministic-evaluator sweep "
"path also honours it (one δ_ops per grid, the grid still "
"runs one-shot)."
),
)
payload_mass_kg: float | None = _payload_mass_field()
payload_power_w: float | None = _payload_power_field()
mission_duration_earth_days: float | None = _mission_duration_field()
class SweepSensitivityOut(BaseModel):
"""Mirror of :class:`roverdevkit.tradespace.sweeps.SweepSensitivity`.
All values share the units of the swept target metric. ``relative_spread``
is dimensionless: the absolute spread divided by the larger of
``|max|``, ``|min|``, ε. Frontend uses it to decide whether the metric
is effectively flat across the grid.
"""
model_config = ConfigDict(frozen=True)
total_spread: float
relative_spread: float
axis_spread_x: float
axis_spread_y: float | None
class SweepResponse(BaseModel):
"""Sweep grid + the values matrix + provenance.
``z_values`` is row-major: 1-D ``(n_x,)`` for a 1-D sweep,
2-D ``(n_y, n_x)`` for 2-D. The 2-D shape matches Plotly's
heatmap convention (rows = y, columns = x) so the frontend can
pass it through unchanged.
"""
model_config = ConfigDict(frozen=True)
target: str
scenario_name: str
x_variable: str
y_variable: str | None
x_values: list[float]
y_values: list[float] | None
z_values: list[float] | list[list[float]]
backend_used: Literal["evaluator", "surrogate"]
backend_requested: Literal["auto", "evaluator", "surrogate"]
n_cells: int
elapsed_ms: float
sensitivity: SweepSensitivityOut
"""Per-axis spread of the swept metric. Drives the inline sensitivity
hint under the chart so users can tell at a glance when a metric is
saturated on the chosen grid or when one axis dominates the other."""
# ---------------------------------------------------------------------------
# Optimize (NSGA-II job orchestration)
# ---------------------------------------------------------------------------
class OptimizeObjectiveIn(BaseModel):
"""One Pareto objective requested by the UI."""
model_config = ConfigDict(frozen=True, extra="forbid")
target: OptimizeTarget
direction: Literal["min", "max"]
class OptimizeConstraintIn(BaseModel):
"""Threshold constraint over an evaluator metric."""
model_config = ConfigDict(frozen=True, extra="forbid")
target: OptimizeTarget
sense: Literal["min", "max"]
value: float
class OptimizeRequest(BaseModel):
"""``POST /optimize`` body."""
model_config = ConfigDict(frozen=True, extra="forbid")
scenario_name: str = Field(
description="Canonical scenario key (one of the four returned by /scenarios)."
)
backend: Literal["surrogate", "evaluator"] = Field(
default="evaluator",
description=(
"Corrected physics evaluator by default; capped server-side at "
"5000 evaluations so a live job finishes inside ~2 min. The "
"surrogate backend is accepted as an opt-in benchmarking option."
),
)
objectives: list[OptimizeObjectiveIn] = Field(
default_factory=lambda: [
OptimizeObjectiveIn(target="range_km", direction="max"),
OptimizeObjectiveIn(target="total_mass_kg", direction="min"),
OptimizeObjectiveIn(target="slope_capability_deg", direction="max"),
],
min_length=1,
max_length=4,
)
constraints: list[OptimizeConstraintIn] = Field(default_factory=list, max_length=8)
population_size: int = Field(default=64, ge=4, le=300)
n_generations: int = Field(default=100, ge=1, le=500)
seed: int = Field(default=0, ge=0)
operational_duty_cycle: float | None = Field(
default=None,
ge=0.0,
le=0.6,
description="Optional per-job override for MissionScenario.operational_duty_cycle.",
)
payload_mass_kg: float | None = _payload_mass_field()
payload_power_w: float | None = _payload_power_field()
mission_duration_earth_days: float | None = _mission_duration_field()
required_obstacle_height_m: float | None = _required_obstacle_field()
class OptimizeJobResponse(BaseModel):
"""Immediate response after queueing an optimization job."""
model_config = ConfigDict(frozen=True)
job_id: str
status: Literal["queued", "running", "completed", "cancelled", "failed"]
stream_url: str
result_url: str
cancel_url: str
class OptimizeCheckpointOut(BaseModel):
"""Per-generation SSE payload."""
model_config = ConfigDict(frozen=True)
gen: int
hypervolume: float
pareto_size: int
best_per_objective: dict[str, float]
class OptimizeParetoPoint(BaseModel):
"""One final Pareto-front point."""
model_config = ConfigDict(frozen=True)
design: DesignVector
metrics: dict[str, float]
class OptimizeResultResponse(BaseModel):
"""Final job state and Pareto front."""
model_config = ConfigDict(frozen=True)
job_id: str
status: Literal["queued", "running", "completed", "cancelled", "failed"]
backend_used: Literal["surrogate", "evaluator"] | None = None
checkpoints: list[OptimizeCheckpointOut] = Field(default_factory=list)
pareto_front: list[OptimizeParetoPoint] = Field(default_factory=list)
error: str | None = None
class OptimizeCancelResponse(BaseModel):
"""Response from ``POST /optimize/{id}/cancel``."""
model_config = ConfigDict(frozen=True)
job_id: str
status: Literal["queued", "running", "completed", "cancelled", "failed"]
class ShapFeatureScore(BaseModel):
"""Per-feature contribution to a single-design prediction."""
model_config = ConfigDict(frozen=True)
feature: str
value: float
class ShapExplainRequest(BaseModel):
"""Explain the current design for one target."""
model_config = ConfigDict(frozen=True, extra="forbid")
design: DesignVector
scenario_name: str
target: PrimaryTarget
operational_duty_cycle: float | None = Field(default=None, ge=0.0, le=0.6)
payload_mass_kg: float | None = _payload_mass_field()
payload_power_w: float | None = _payload_power_field()
mission_duration_earth_days: float | None = _mission_duration_field()
class ShapLocalResponse(BaseModel):
"""Per-design feature contributions for one target prediction."""
model_config = ConfigDict(frozen=True)
target: PrimaryTarget
prediction: float
base_value: float
contributions: list[ShapFeatureScore]