psiloceyeben's picture
Upload folder using huggingface_hub
3538060 verified
Raw History Blame Contribute Delete
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