File size: 14,973 Bytes
3538060 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 | """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
|