Download deepspace/sim.py from psiloceyeben/oracle7-deepspace: direct link, hf CLI and curl.
- Browser
- Download file 15 kB
-
https://huggingface.co/psiloceyeben/oracle7-deepspace/resolve/main/deepspace/sim.py
- Command line
-
hf download hf://psiloceyeben/oracle7-deepspace/deepspace/sim.py
-
curl -L -o sim.py https://huggingface.co/psiloceyeben/oracle7-deepspace/resolve/main/deepspace/sim.py
15 kB
| """Spacecraft simulator for oracle7-deepspace. Deterministic (seeded), stdlib only, one tick = 60 s. | |
| Four mission PROFILES modelled on real deep-space craft (Voyager 1, New Horizons, Juno, Europa Clipper). A profile sets | |
| the power source (RTG with a small capacitor buffer and no battery, or solar array + battery), the attitude actuators | |
| (reaction wheels, or thrusters on A/B branches), the thermal environment (colder with distance), the power budget and | |
| the one-way light time. The physics are deliberately simple and documented; the profiles set which subsystems exist | |
| and which faults are possible, not a claim of engineering-level fidelity to the real vehicles. | |
| Faults are injected into the TRUE state; the onboard software only ever sees TELEMETRY. Two faults are TRAPS where | |
| acting would be wrong: a biased sensor (the zone is fine) and a single-tick radiation upset. The same class, built from | |
| telemetry with perturbed parameters, is the onboard DIGITAL TWIN. | |
| """ | |
| from __future__ import annotations | |
| import copy | |
| import math | |
| import random | |
| DT = 60.0 | |
| ZONES = ("battery", "avionics", "tank") | |
| BANDS = {"battery": (5.0, 30.0), "avionics": (0.0, 40.0), "tank": (10.0, 35.0)} # operating limits (deg C) | |
| SURVIVAL = {"battery": (-10.0, 45.0), "avionics": (-20.0, 60.0), "tank": (2.0, 50.0)} # beyond these = damage | |
| THERMOSTAT = {"battery": (12.0, 22.0), "avionics": (8.0, 25.0), "tank": (16.0, 26.0)} # heater on below lo, off above hi | |
| HEATER_ON_EQ = {"battery": 49.0, "avionics": 20.0, "tank": 40.0} # where a stuck-on heater settles | |
| # one-way light time in minutes is approximate and for narrative only (distances change; see the site's ephemeris) | |
| PROFILES = { | |
| "voyager1": {"label": "Voyager 1", "power": "rtg", "actuator": "thrusters", "distance_au": 167.0, "t_env_shift": -30.0, | |
| "source_amp": 7.2, "base_load": 3.9, "science_amp": 1.2, "heater_amp": 0.8, "cap_ah": 2.0, | |
| "faults": ("nominal", "heater_stuck_on", "heater_stuck_off", "sensor_bias", "seu_spike", | |
| "thruster_degradation", "rtg_decline", "star_tracker_dropout")}, | |
| "new_horizons": {"label": "New Horizons", "power": "rtg", "actuator": "thrusters", "distance_au": 61.0, "t_env_shift": -25.0, | |
| "source_amp": 6.6, "base_load": 3.5, "science_amp": 1.1, "heater_amp": 0.8, "cap_ah": 2.0, | |
| "faults": ("nominal", "heater_stuck_on", "heater_stuck_off", "sensor_bias", "seu_spike", | |
| "thruster_degradation", "rtg_decline", "star_tracker_dropout")}, | |
| "juno": {"label": "Juno", "power": "solar", "actuator": "thrusters", "distance_au": 5.2, "t_env_shift": -10.0, | |
| "source_amp": 14.0, "base_load": 6.0, "science_amp": 2.5, "heater_amp": 1.5, "cap_ah": 110.0, | |
| "faults": ("nominal", "heater_stuck_on", "heater_stuck_off", "sensor_bias", "seu_spike", | |
| "thruster_degradation", "solar_degradation", "battery_short", "star_tracker_dropout")}, | |
| "europa_clipper": {"label": "Europa Clipper", "power": "solar", "actuator": "wheels", "distance_au": 3.0, "t_env_shift": -4.0, | |
| "source_amp": 12.0, "base_load": 5.0, "science_amp": 2.0, "heater_amp": 1.5, "cap_ah": 100.0, | |
| "faults": ("nominal", "heater_stuck_on", "heater_stuck_off", "wheel_friction", "sensor_bias", | |
| "star_tracker_dropout", "solar_degradation", "battery_short", "seu_spike")}, | |
| } | |
| FAULTS = ("nominal", "heater_stuck_on", "heater_stuck_off", "wheel_friction", "sensor_bias", "star_tracker_dropout", | |
| "solar_degradation", "battery_short", "seu_spike", "thruster_degradation", "rtg_decline") | |
| # the response that resolves each fault (ground truth for SCORING only; the onboard software never sees it) | |
| CORRECT = {"nominal": {"no_action"}, "heater_stuck_on": {"switch_heater_backup"}, "heater_stuck_off": {"switch_heater_backup"}, | |
| "wheel_friction": {"switch_to_spare_wheel"}, "sensor_bias": {"mark_sensor_invalid"}, | |
| "star_tracker_dropout": {"switch_attitude_sensor"}, "solar_degradation": {"shed_loads"}, | |
| "battery_short": {"safe_mode"}, "seu_spike": {"no_action"}, "thruster_degradation": {"switch_thruster_branch"}, | |
| "rtg_decline": {"shed_loads"}} | |
| HARMFUL = {"sensor_bias": {"switch_heater_backup", "safe_mode", "shed_loads"}, | |
| "seu_spike": {"switch_heater_backup", "safe_mode", "shed_loads", "mark_sensor_invalid", "switch_to_spare_wheel", | |
| "switch_thruster_branch"}, | |
| "nominal": {"switch_heater_backup", "safe_mode", "shed_loads", "switch_to_spare_wheel", "switch_attitude_sensor", | |
| "switch_thruster_branch"}} | |
| def light_time_min(profile): | |
| return PROFILES[profile]["distance_au"] * 8.317 | |
| class Spacecraft: | |
| def __init__(self, seed=0, params=None, profile="europa_clipper"): | |
| self.profile = profile | |
| pr = PROFILES[profile] | |
| self.rng = random.Random(seed) | |
| k_env = {"battery": 0.0022, "avionics": 0.0030, "tank": 0.0018} | |
| t_env = {z: v + pr["t_env_shift"] for z, v in {"battery": -5.0, "avionics": 5.0, "tank": -15.0}.items()} | |
| avionics_self = 0.020 + 0.0030 * max(0.0, -pr["t_env_shift"]) # colder craft keep avionics warm with internal heat | |
| heater_w = {z: max(0.0, k_env[z] * (HEATER_ON_EQ[z] - t_env[z]) - (avionics_self if z == "avionics" else 0.0)) | |
| for z in ZONES} | |
| p = {"cap_ah": pr["cap_ah"], "source_amp": pr["source_amp"], "base_load": pr["base_load"], | |
| "heater_amp": pr["heater_amp"], "science_amp": pr["science_amp"], "wheel_amp": 0.3, "k_env": k_env, | |
| "t_env": t_env, "heater_w": heater_w, "avionics_self_heat": avionics_self, "noise": 0.25} | |
| if params: | |
| p.update(params) | |
| self.p = p | |
| self.power, self.actuator = pr["power"], pr["actuator"] | |
| self.t = 0 | |
| self.soc = 0.85 if self.power == "solar" else 0.9 | |
| self.temp = {"battery": 18.0, "avionics": 18.0, "tank": 21.0} | |
| self.heater_primary_ok = {z: "ok" for z in ZONES} | |
| self.heater_circuit = {z: "primary" for z in ZONES} | |
| self.heater_cmd = {z: False for z in ZONES} | |
| self.sensor_bias = {(z, s): 0.0 for z in ZONES for s in "AB"} | |
| self.sensor_invalid = set() | |
| self.wheel_friction = [1.0, 1.0, 1.0, 1.0] | |
| self.wheels_active = [0, 1, 2] if self.actuator == "wheels" else [] | |
| self.thruster_branch = "A" | |
| self.thruster_eff = {"A": 1.0, "B": 1.0} | |
| self.star_tracker_ok = True | |
| self.attitude_sensor = "star_tracker" | |
| self.source_health = 1.0 | |
| self.short_amp = 0.0 | |
| self.science_on = True | |
| self.safe = False | |
| self.pointing_err = 0.2 | |
| self.seu = None | |
| self.fault = "nominal" | |
| self.damage = [] | |
| self.i_source = self.i_load = 0.0 | |
| self.duty = 0.05 | |
| # ---- fault injection (TRUE state) ---- | |
| def inject(self, fault, zone="battery", wheel=1, sensor="A", magnitude=1.0): | |
| self.fault = fault | |
| if fault == "heater_stuck_on": | |
| self.heater_primary_ok[zone] = "stuck_on" | |
| elif fault == "heater_stuck_off": | |
| self.heater_primary_ok[zone] = "stuck_off" | |
| elif fault == "wheel_friction": | |
| self.wheel_friction[wheel] = 6.0 + magnitude | |
| elif fault == "sensor_bias": | |
| self.sensor_bias[(zone, sensor)] = 18.0 + 10.0 * magnitude | |
| elif fault == "star_tracker_dropout": | |
| self.star_tracker_ok = False | |
| elif fault == "solar_degradation": | |
| self.source_health = 0.78 - 0.08 * magnitude | |
| elif fault == "rtg_decline": | |
| self.source_health = 0.86 - 0.04 * magnitude # a thermocouple string lost: ~0.9-1.3 A on a tight budget | |
| elif fault == "battery_short": | |
| self.short_amp = 5.0 + magnitude | |
| elif fault == "thruster_degradation": | |
| self.thruster_eff["A"] = 0.18 - 0.08 * magnitude # clogged branch-A attitude thrusters | |
| elif fault == "seu_spike": | |
| self.seu = (self.t + 1, zone, sensor) | |
| # ---- commands ---- | |
| def command(self, action, **kw): | |
| if action == "switch_heater_backup": | |
| self.heater_circuit[kw["zone"]] = "backup" | |
| elif action == "mark_sensor_invalid": | |
| self.sensor_invalid.add((kw["zone"], kw["sensor"])) | |
| elif action == "switch_to_spare_wheel": | |
| w = kw["wheel"] | |
| if w in self.wheels_active and 3 not in self.wheels_active: | |
| self.wheels_active = [x if x != w else 3 for x in self.wheels_active] | |
| elif action == "switch_thruster_branch": | |
| self.thruster_branch = "B" if self.thruster_branch == "A" else "A" | |
| elif action == "switch_attitude_sensor": | |
| self.attitude_sensor = "sun_sensor" | |
| elif action == "shed_loads": | |
| self.science_on = False | |
| elif action == "safe_mode": | |
| self.safe = True | |
| self.science_on = False | |
| # ---- physics ---- | |
| def _heater_on(self, z): | |
| if self.heater_circuit[z] == "primary": | |
| st = self.heater_primary_ok[z] | |
| if st == "stuck_on": | |
| return True | |
| if st == "stuck_off": | |
| return False | |
| return self.heater_cmd[z] | |
| def step(self): | |
| p = self.p | |
| self.t += 1 | |
| for z in ZONES: | |
| est = self.zone_estimate(z) | |
| lo, hi = THERMOSTAT[z] | |
| if self.safe: | |
| lo, hi = lo - 4, hi - 4 | |
| if est < lo: | |
| self.heater_cmd[z] = True | |
| elif est > hi: | |
| self.heater_cmd[z] = False | |
| for z in ZONES: | |
| heat = p["heater_w"][z] if self._heater_on(z) else 0.0 | |
| if z == "avionics": | |
| heat += p["avionics_self_heat"] | |
| self.temp[z] += DT * (heat - p["k_env"][z] * (self.temp[z] - p["t_env"][z])) * 0.05 | |
| # attitude | |
| if self.actuator == "wheels": | |
| authority = sum(1.0 / self.wheel_friction[w] for w in self.wheels_active) / 3.0 | |
| self.duty = 0.05 | |
| else: | |
| authority = self.thruster_eff[self.thruster_branch] | |
| self.duty = min(1.0, 0.05 / max(0.05, authority)) # weak thrusters fire longer for the same control | |
| knowledge = 1.0 if (self.attitude_sensor == "star_tracker" and self.star_tracker_ok) else ( | |
| 0.6 if self.attitude_sensor == "sun_sensor" else 0.0) | |
| target = 0.2 if knowledge >= 1.0 else (1.2 if knowledge > 0 else 6.0) | |
| if authority < 0.8: | |
| target += (0.8 - authority) * 25.0 | |
| if self.safe: | |
| target = min(target, 2.0) | |
| self.pointing_err += (target - self.pointing_err) * 0.3 + self.rng.gauss(0, 0.05) | |
| self.pointing_err = max(0.05, self.pointing_err) | |
| # power | |
| if self.power == "solar": | |
| sun = math.cos(math.radians(min(self.pointing_err, 80.0))) | |
| src = p["source_amp"] * self.source_health * sun * (1.05 if self.safe else 1.0) | |
| else: | |
| src = p["source_amp"] * self.source_health * (1.0 - 1e-6 * self.t) # RTG: pointing-independent, slow decay | |
| load = p["base_load"] + sum(p["heater_amp"] for z in ZONES if self._heater_on(z)) \ | |
| + (p["science_amp"] if self.science_on else 0.0) + self.short_amp | |
| if self.actuator == "wheels": | |
| load += p["wheel_amp"] * sum(self.wheel_friction[w] for w in self.wheels_active) | |
| else: | |
| load += 0.6 * self.duty | |
| if self.safe: | |
| load -= 0.5 + 0.6 * self.short_amp + (0.3 if self.power == "rtg" else 0.5) | |
| self.i_source, self.i_load = src, load | |
| self.soc = min(1.0, max(0.0, self.soc + (src - load) * DT / 3600.0 / p["cap_ah"])) | |
| for z in ZONES: | |
| lo, hi = SURVIVAL[z] | |
| if not lo <= self.temp[z] <= hi: | |
| self.damage.append((self.t, "%s_temp" % z)) | |
| if self.soc < 0.15: | |
| self.damage.append((self.t, "bus_undervoltage" if self.power == "rtg" else "battery_depleted")) | |
| def zone_estimate(self, z): | |
| vals = [self._read(z, s) for s in "AB" if (z, s) not in self.sensor_invalid] | |
| return sum(vals) / len(vals) if vals else self.temp[z] | |
| def _read(self, z, s): | |
| v = self.temp[z] + self.sensor_bias[(z, s)] + self.rng.gauss(0, self.p["noise"]) | |
| if self.seu and self.seu[0] == self.t and self.seu[1] == z and self.seu[2] == s: | |
| v += 90.0 | |
| return v | |
| def nominal_source(self): | |
| """what the ONBOARD model expects the power source to deliver now (known design value, no fault knowledge)""" | |
| if self.power == "solar": | |
| return self.p["source_amp"] * math.cos(math.radians(min(self.pointing_err, 80.0))) | |
| return self.p["source_amp"] * (1.0 - 1e-6 * self.t) | |
| def telemetry(self): | |
| tm = {"t": self.t, "profile": self.profile, "power_source": self.power, "actuator": self.actuator, | |
| "soc": round(self.soc, 4), | |
| "bus_v": round(26.0 + 6.0 * self.soc - 0.25 * self.short_amp + self.rng.gauss(0, 0.05), 3), | |
| "i_source": round(self.i_source, 3), "i_source_nominal": round(self.nominal_source(), 3), | |
| "i_load": round(self.i_load, 3), "pointing_err": round(self.pointing_err, 3), | |
| "star_tracker_valid": self.star_tracker_ok, "attitude_sensor": self.attitude_sensor, | |
| "wheels_active": list(self.wheels_active), "thruster_branch": self.thruster_branch, | |
| "thruster_duty": round(self.duty + self.rng.gauss(0, 0.004), 4), | |
| "science_on": self.science_on, "safe_mode": self.safe, | |
| "sensor_invalid": sorted("%s.%s" % zs for zs in self.sensor_invalid)} | |
| tm["i_solar"] = tm["i_source"] # compatibility name used by v0 code | |
| for z in ZONES: | |
| tm["%s_A" % z] = round(self._read(z, "A"), 2) | |
| tm["%s_B" % z] = round(self._read(z, "B"), 2) | |
| tm["%s_heater_cmd" % z] = self.heater_cmd[z] | |
| tm["%s_heater_circuit" % z] = self.heater_circuit[z] | |
| for w in range(4): | |
| tm["wheel%d_current" % w] = round(0.3 * self.wheel_friction[w] * (1 if w in self.wheels_active else 0.1) | |
| + self.rng.gauss(0, 0.01), 3) | |
| return tm | |
| def clone(self, rng_seed=None, perturb=0.0): | |
| """digital twin: a copy with physics parameters perturbed by +-perturb (thermal and source constants)""" | |
| c = copy.deepcopy(self) | |
| c.rng = random.Random(rng_seed if rng_seed is not None else 12345) | |
| if perturb: | |
| r = random.Random(777) | |
| for key in ("k_env", "heater_w"): | |
| c.p[key] = {z: v * (1 + r.uniform(-perturb, perturb)) for z, v in c.p[key].items()} | |
| c.p["source_amp"] *= 1 + r.uniform(-perturb, perturb) | |
| return c | |