Skip to content

Phase.load_case clips restored states against bounds in the declared units while the values are in the recorded timeseries units #1239

Description

@hahnpv

Description

In Phase.load_case (phase.py:3040), the values are in the recorded timeseries units but options['lower']/['upper'] are in the state's declared units:

prev_state_val = prev_vars[prev_state_path]['val']      # prev_state_units
prev_state_val = prev_state_val.clip(options['lower'], options['upper'])  # declared units
self.set_state_val(state_name, vals=..., units=prev_state_units)

A bounded state recorded in non-native units is clipped dimensionally wrong, and the restored trajectory is silently flattened. State declared rad with lower/upper=±0.15 and add_timeseries_output(..., units='deg'), flown 0..0.05 rad — well inside the bounds — restores as 0..0.002618 rad, i.e. the constant 0.15 deg. Repro snippet below.

Unbounded states are fine. Controls take the same clip at phase.py:3069, but are repaired afterwards by Problem.load_case re-applying recorded nodal values. The deprecated module-level dymos.load_case has no clip and restores correctly.

Fix: convert the bounds into prev_state_units before clipping (guarding None for one-sided bounds); same at phase.py:3069.

Found on dymos 1.14.0 / OM 3.40.0; identical output on 1.15.1 / OM 3.45.0
(phase.py:3041) and current master 1.15.2-dev @ 5bfbe39 / OM 3.45.0
(phase.py:3043). Python 3.12, clean venvs.

Example

"""dymos Phase.load_case: bounded state recorded in non-native units is clipped
against the bounds as if the recorded values were in the state's declared units."""

import dymos as dm
import openmdao.api as om

DB = "repro.db"

class ConstantRateODE(om.ExplicitComponent):
def initialize(self):
self.options.declare("num_nodes", types=int)

  def setup(self):
      nn = self.options["num_nodes"]
      self.add_input("theta", shape=(nn,), units="rad")
      self.add_output("theta_dot", shape=(nn,), units="rad/s")
      self.declare_partials("theta_dot", "theta", dependent=False)

  def compute(self, inputs, outputs):
      outputs["theta_dot"][:] = 1.0e-3

def build():
p = om.Problem()
traj = p.model.add_subsystem("traj", dm.Trajectory())
phase = traj.add_phase(
"phase0",
dm.Phase(ode_class=ConstantRateODE, transcription=dm.Radau(num_segments=4, order=3)),
)
phase.set_time_options(fix_initial=True, fix_duration=True, units="s")
phase.add_state(
"theta", rate_source="theta_dot", units="rad", fix_initial=True, lower=-0.15, upper=0.15
) # bounds in rad
phase.add_timeseries_output("theta", units="deg") # recorded in deg
p.setup()
phase.set_time_val(initial=0.0, duration=50.0)
phase.set_state_val("theta", [0.0, 0.05])
return p

Fly once (no driver) and record the solution.

p1 = build()
dm.run_problem(p1, run_driver=False, simulate=False, solution_record_file=DB)
flown = p1.get_val("traj.phase0.timeseries.theta", units="rad").ravel()

Restart a fresh copy of the same problem from that case.

case = om.CaseReader(p1.get_outputs_dir() / DB).get_case("final")
p2 = build()
p2.final_setup()
p2.load_case(case)
restored = p2.get_val("traj.phase0.states:theta", units="rad").ravel()

print(f"flown theta : {flown.min():+.6f} .. {flown.max():+.6f} rad (bounds +-0.15 rad)")
print(f"restored theta : {restored.min():+.6f} .. {restored.max():+.6f} rad")
print("0.15 deg in rad:", f"{0.15 * 3.14159265358979 / 180:+.6f}")

Dymos Version

1.15.2-dev

Relevant environment information

No response

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions