Skip to content

Commit 3260573

Browse files
committed
TST: minor fixes
1 parent c926bdd commit 3260573

6 files changed

Lines changed: 121 additions & 20 deletions

File tree

rocketpy/_encoders.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ def set_minimal_flight_attributes(flight, obj):
208208
flight._controllers = getattr(flight.rocket, "_controllers", [])[:]
209209
flight.sensors = flight.rocket.sensors.get_components()
210210
flight.sensors_by_name = flight.rocket.sensors_by_name
211+
# No simulation was run, so there is no recorded sensor data. Set an empty
212+
# mapping so code that reads ``sensor_data`` behaves like a real flight.
213+
flight.sensor_data = {}
211214
# TODO: custom_events are lost when loading from .rpy because they hold
212215
# user-defined callables that are not currently serialized. Add proper
213216
# serialization/deserialization for custom_events and restore them here

rocketpy/control/controller.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,16 @@ def to_dict(self, **kwargs):
422422
"sampling_rate": self.sampling_rate,
423423
"name": self.name,
424424
"controlled_objects_name": getattr(self, "controlled_objects_name", None),
425+
# Hash(es) identifying the controlled object(s), so Rocket
426+
# deserialization can reconnect the controller to the rocket's own
427+
# reconstructed objects (see Rocket.from_dict).
428+
"controlled_objects_hash": self._controlled_objects_hash(),
429+
# Which expensive simulation values the controller reads; needed so a
430+
# re-simulated flight computes them (e.g. state_history). Stored as a
431+
# list because JSON has no set type.
432+
"controller_needs": (
433+
None if self.controller_needs is None else list(self.controller_needs)
434+
),
425435
"context": self.context.copy(), # Preserve context state
426436
"enabled": self.enabled,
427437
"disable_on": disable_on,
@@ -430,6 +440,24 @@ def to_dict(self, **kwargs):
430440
# object reference matching in Rocket deserialization
431441
}
432442

443+
def _controlled_objects_hash(self):
444+
"""Return the identity hash of the controlled object(s), matching the
445+
shape of ``controlled_objects`` (a single hash for a single object, a
446+
list of hashes for a list). These hashes match the ones the encoder
447+
stores in each object's signature, letting Rocket deserialization find
448+
the reconstructed objects. Returns ``None`` for anything unhashable."""
449+
450+
def safe_hash(obj):
451+
try:
452+
return hash(obj)
453+
except TypeError:
454+
return None
455+
456+
controlled_objects = self.controlled_objects
457+
if isinstance(controlled_objects, (list, tuple)):
458+
return [safe_hash(obj) for obj in controlled_objects]
459+
return safe_hash(controlled_objects)
460+
433461
@classmethod
434462
def from_dict(cls, data, controlled_objects=None):
435463
"""Reconstruct controller from dictionary.
@@ -455,6 +483,7 @@ def from_dict(cls, data, controlled_objects=None):
455483
enabled = data.get("enabled", True)
456484
disable_on = data.get("disable_on")
457485
enable_on = data.get("enable_on")
486+
controller_needs = data.get("controller_needs")
458487

459488
try:
460489
controller_function = from_hex_decode(controller_function)
@@ -476,7 +505,7 @@ def from_dict(cls, data, controlled_objects=None):
476505
if controlled_objects is None:
477506
controlled_objects = []
478507

479-
return cls(
508+
controller = cls(
480509
controller_function=controller_function,
481510
controlled_objects=controlled_objects,
482511
sampling_rate=sampling_rate,
@@ -486,4 +515,28 @@ def from_dict(cls, data, controlled_objects=None):
486515
enabled=enabled,
487516
disable_on=disable_on,
488517
enable_on=enable_on,
518+
controller_needs=controller_needs,
519+
)
520+
# Stash the serialized controlled-object hash(es) so Rocket.from_dict
521+
# can reconnect the controller to the rocket's reconstructed objects.
522+
controller._serialized_controlled_objects_hash = data.get(
523+
"controlled_objects_hash"
489524
)
525+
return controller
526+
527+
def rebind_controlled_objects(self, controlled_objects):
528+
"""Point the controller at reconstructed controlled object(s) and
529+
refresh the callback name bindings.
530+
531+
Used when a rocket is loaded from a file: the controller is rebuilt
532+
without its controlled objects (they are separate objects in the saved
533+
data), so this reconnects it to the rocket's own objects, ensuring the
534+
controller mutates them rather than orphaned copies.
535+
536+
Parameters
537+
----------
538+
controlled_objects : object or list of object
539+
The reconstructed object(s) the controller should control.
540+
"""
541+
self.controlled_objects = controlled_objects
542+
self._controlled_objects_bindings = self.__verify_controlled_objects_name()

rocketpy/plots/flight_plots.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1500,12 +1500,21 @@ def sensor_data(self, *, filename=None):
15001500
print("No sensors were registered in this flight.")
15011501
return
15021502

1503+
# A flight loaded from a file without being re-simulated has no recorded
1504+
# measurements, so fall back to an empty mapping instead of raising.
1505+
sensor_data = getattr(self.flight, "sensor_data", None) or {}
1506+
15031507
seen = []
15041508
for sensor in self.flight.sensors:
15051509
if sensor in seen: # a multiply-added sensor is listed more than once
15061510
continue
15071511
seen.append(sensor)
1508-
measured_data = self.flight.sensor_data[sensor]
1512+
measured_data = sensor_data.get(sensor)
1513+
if not measured_data:
1514+
print(
1515+
f"\n\n{sensor.name}: no data available (flight was not simulated)."
1516+
)
1517+
continue
15091518
print(f"\n\n{sensor.name} Sensor Data\n")
15101519
if filename is None:
15111520
sensor.plots.all(data=measured_data)

rocketpy/prints/flight_prints.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,12 +266,20 @@ def sensors(self):
266266
print("No sensors were registered.")
267267
return
268268

269+
# A flight loaded from a file without being re-simulated has no recorded
270+
# measurements, so fall back to an empty mapping instead of raising.
271+
sensor_data = getattr(self.flight, "sensor_data", None) or {}
272+
269273
for sensor in self.flight.sensors:
270274
sensor_name = sensor.name if sensor.name else "Unnamed Sensor"
271-
measured_data = self.flight.sensor_data[sensor]
272275
print(f"Sensor: {sensor_name}")
273276
print(f"\tType: {sensor.__class__.__name__}")
274277
print(f"\tSampling Rate: {sensor.sampling_rate:.3f} Hz")
278+
measured_data = sensor_data.get(sensor)
279+
if not measured_data:
280+
print("\tNo measurement data available (flight was not simulated).")
281+
print()
282+
continue
275283
print(f"\tMeasurements Recorded: {len(measured_data)}")
276284
sensor.prints.data_summary(data=measured_data)
277285
print()

rocketpy/rocket/rocket.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,7 +1035,9 @@ def _evaluate_is_incidence_linear(self):
10351035
self, 0.0, probe_alpha, probe_mach, "yaw"
10361036
)
10371037
else:
1038-
point_zero = neutral_point_and_slope(self, 0.0, 0.0, probe_mach, "pitch")
1038+
point_zero = neutral_point_and_slope(
1039+
self, 0.0, 0.0, probe_mach, "pitch"
1040+
)
10391041
point_five = neutral_point_and_slope(
10401042
self, probe_alpha, 0.0, probe_mach, "pitch"
10411043
)
@@ -2639,7 +2641,14 @@ def controller_wrapper(**kwargs):
26392641
state = kwargs.get("state")
26402642
state_history = kwargs.get("state_history")
26412643
observed_variables = controller_context.get("observed_variables", [])
2642-
interactive_objects = kwargs.get("interactive_objects", air_brakes)
2644+
# Prefer the controller's live ``controlled_objects`` (set by the
2645+
# callback) over the captured ``air_brakes``: after a flight is
2646+
# loaded from a file the two differ, and only the former is the air
2647+
# brakes the rocket actually uses for drag. For a normal flight they
2648+
# are the same object, so this is unchanged behavior.
2649+
interactive_objects = kwargs.get(
2650+
"interactive_objects", kwargs.get("controlled_objects", air_brakes)
2651+
)
26432652
sensors = kwargs.get("sensors")
26442653
environment = kwargs.get("environment")
26452654

@@ -3048,22 +3057,34 @@ def from_dict(cls, data):
30483057
rocket.air_brakes.append(air_brake)
30493058

30503059
for controller in data["_controllers"]:
3051-
interactive_objects_hash = getattr(controller, "_interactive_objects_hash")
3052-
if interactive_objects_hash is not None:
3053-
is_iterable = isinstance(interactive_objects_hash, Iterable)
3054-
if not is_iterable:
3055-
interactive_objects_hash = [interactive_objects_hash]
3056-
for hash_ in interactive_objects_hash:
3060+
# Reconnect the controller to the rocket's own reconstructed objects
3061+
# by matching the hash(es) it stored for its controlled objects
3062+
# against the reconstructed objects (see _Controller.to_dict).
3063+
controlled_objects_hash = getattr(
3064+
controller, "_serialized_controlled_objects_hash", None
3065+
)
3066+
if controlled_objects_hash is not None:
3067+
is_iterable = isinstance(controlled_objects_hash, Iterable)
3068+
hashes = (
3069+
controlled_objects_hash
3070+
if is_iterable
3071+
else [controlled_objects_hash]
3072+
)
3073+
found = []
3074+
for hash_ in hashes:
3075+
if hash_ is None: # unhashable controlled object; cannot match
3076+
continue
30573077
if (hashed_obj := find_obj_from_hash(data, hash_)) is not None:
3058-
if not is_iterable:
3059-
controller.interactive_objects = hashed_obj
3060-
else:
3061-
controller.interactive_objects.append(hashed_obj)
3078+
found.append(hashed_obj)
30623079
else:
30633080
warnings.warn(
3064-
"Could not find controller interactive objects."
3081+
"Could not find controller controlled objects. "
30653082
"Deserialization will proceed, results may not be accurate."
30663083
)
3084+
if found:
3085+
controller.rebind_controlled_objects(
3086+
found if is_iterable else found[0]
3087+
)
30673088
rocket._add_controllers(controller)
30683089

30693090
return rocket

tests/unit/test_rail_buttons_bending_moments.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,19 @@ def test_railbuttons_no_aero_contribution():
147147
"""Test RailButtons provide zero aerodynamic contributions."""
148148
rb = RailButtons(buttons_distance=0.5)
149149

150-
rb.evaluate_center_of_pressure()
150+
# Center of pressure sits at the surface origin.
151151
assert rb.cp == (0, 0, 0)
152152

153-
rb.evaluate_lift_coefficient()
154-
assert rb.clalpha(1.0) == 0 # Zero lift derivative
155-
assert rb.cl(0.1, 1.0) == 0 # Zero lift coefficient
153+
# Every force and moment coefficient is identically zero.
154+
full_state = (0.1, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0) # alpha, beta, mach, re, rates
155+
for name in ("cN", "cY", "cA", "cm", "cn", "cl"):
156+
coefficient = getattr(rb, name)
157+
assert coefficient.is_zero
158+
assert coefficient(*full_state) == 0
159+
160+
# Zero stability derivatives, so rail buttons never shift the static margin.
161+
assert rb.cN_alpha(*full_state) == 0
162+
assert rb.cm_alpha(*full_state) == 0
156163

157164

158165
def test_rail_button_bending_moments_prints(flight_calisto_robust, capsys):

0 commit comments

Comments
 (0)