"""TRUTH physics that the onboard twin does not model. The flight code (deepspace/) is unchanged: its twin is still the base Spacecraft class, so every term below is STRUCTURAL mismatch, not a perturbed constant. thermal inter-zone conduction, and a radiative (T^4) share of the heat loss instead of the twin's linear law; heater powers differ from design by x0.8-1.25 per zone power coulombic charge efficiency 95%, an unmodelled load that random-walks within +-4% of the craft's source (sized on dev seeds 0-3: +-0.6 A exceeded an RTG craft's whole margin and browned out healthy craft), array current noise sensors quantised to 0.25 C, heavy-tailed noise (2% of readings get an extra N(0, 3 C) outlier) attitude pointing jitter 3x the design value dropout 2% of telemetry frames never reach the flight software (handled by the runner) """ from __future__ import annotations import math import random import sys import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from deepspace.sim import DT, ZONES, Spacecraft # noqa: E402 VARIANTS = {"base": set(), "thermal": {"thermal"}, "power": {"power"}, "sensors": {"sensors"}, "attitude": {"attitude"}, "all": {"thermal", "power", "sensors", "attitude", "dropout"}} class TruthCraft(Spacecraft): def __init__(self, seed=0, profile="europa_clipper", terms=()): super().__init__(seed=seed, profile=profile) self.terms = set(terms) self.trng = random.Random(seed * 31 + 7) self.load_drift = 0.0 if "thermal" in self.terms: self.p["heater_w"] = {z: w * self.trng.uniform(0.8, 1.25) for z, w in self.p["heater_w"].items()} def step(self): soc0 = self.soc super().step() if "thermal" in self.terms: # conduction between zones, and a radiative share of the loss (30% of the linear law at 17 C, T^4 shaped) mean = sum(self.temp.values()) / 3.0 for z in ZONES: self.temp[z] += DT * 0.0006 * (mean - self.temp[z]) * 0.05 tk, ek, ref = self.temp[z] + 273.15, self.p["t_env"][z] + 273.15, 290.0 rad = 0.3 * self.p["k_env"][z] * (tk ** 4 - ek ** 4) / (4 * ref ** 3) self.temp[z] -= DT * rad * 0.05 if "power" in self.terms: gain = self.soc - soc0 if gain > 0: self.soc -= 0.05 * gain # coulombic loss on charge bound = 0.04 * self.p["source_amp"] self.load_drift = max(-bound, min(bound, self.load_drift + self.trng.gauss(0, bound / 12))) noise = self.trng.gauss(0, 0.08) self.i_load += self.load_drift self.i_source += noise self.soc = min(1.0, max(0.0, self.soc - (self.load_drift - noise) * DT / 3600.0 / self.p["cap_ah"])) if "attitude" in self.terms: self.pointing_err = max(0.05, self.pointing_err + self.trng.gauss(0, 0.1)) def _read(self, z, s): v = super()._read(z, s) if "sensors" in self.terms: if self.trng.random() < 0.02: v += self.trng.gauss(0, 3.0) v = round(v * 4) / 4.0 return v def run_truth(params, onboard, terms, ticks=320, physical=None, magnitude_override=None): """the runtime.run loop against TruthCraft; frames can drop out before reaching the flight software""" sc = TruthCraft(seed=params["seed"] * 7 + 1, profile=params["profile"], terms=terms) drop = random.Random(params["seed"] * 101 + 3) actions, receipts = [], [] min_soc, sci_off, over_band = 1.0, 0, 0 from deepspace.sim import BANDS for t in range(ticks): if t == params["onset"]: sc.inject(params["fault"], zone=params["zone"], wheel=params["wheel"], sensor=params["sensor"], magnitude=params["magnitude"]) if magnitude_override: magnitude_override(sc, params) sc.step() min_soc = min(min_soc, sc.soc) sci_off += 0 if sc.science_on else 1 over_band += any(not BANDS[z][0] <= sc.temp[z] <= BANDS[z][1] for z in ZONES) tm = sc.telemetry() if "dropout" in terms and drop.random() < 0.02: continue r = onboard(tm) if r: receipts.append(r) if r["action"] in physical: sc.command(r["action"], **r["arg"]) actions.append((sc.t, r["action"], r["arg"])) return {"actions": actions, "receipts": receipts, "damage": sc.damage, "safe": sc.safe, "final_soc": sc.soc, "min_soc": min_soc, "science_off_ticks": sci_off, "out_of_band_ticks": over_band} # out-of-range fault intensities: WEAK (near or below the detection thresholds) and STRONG (beyond the design range) def _set(kind): def f(sc, p): fault = p["fault"] if fault == "sensor_bias": sc.sensor_bias[(p["zone"], p["sensor"])] = 9.0 if kind == "weak" else 60.0 elif fault == "wheel_friction": sc.wheel_friction[p["wheel"]] = 3.8 if kind == "weak" else 20.0 elif fault == "solar_degradation": sc.source_health = 0.82 if kind == "weak" else 0.45 elif fault == "rtg_decline": sc.source_health = 0.91 if kind == "weak" else 0.70 elif fault == "battery_short": sc.short_amp = 3.0 if kind == "weak" else 12.0 elif fault == "thruster_degradation": sc.thruster_eff["A"] = 0.30 if kind == "weak" else 0.03 return f INTENSITY = {"weak": _set("weak"), "strong": _set("strong")} SCALABLE = ("sensor_bias", "wheel_friction", "solar_degradation", "rtg_decline", "battery_short", "thruster_degradation")