Add trim() and linearize_system() for system-level operating points by miguelpi314 · Pull Request #237 · pathsim/pathsim · GitHub
Skip to content

Add trim() and linearize_system() for system-level operating points - #237

Closed
miguelpi314 wants to merge 6 commits into
pathsim:masterfrom
miguelpi314:feat/trim-and-linearize-system
Closed

miguelpi314 wants to merge 6 commits into
pathsim:masterfrom
miguelpi314:feat/trim-and-linearize-system

Conversation

@miguelpi314

Copy link
Copy Markdown

Summary

  • Closes the gap identified in Integration with python-control #195: per-block linearization
    already existed (Block.linearize()/Simulation.linearize()), but nothing
    assembled those Jacobians into a single global (A,B,C,D) for an
    interconnected diagram.
  • Adds Simulation.linearize_system(inputs, outputs, as_block=True):
    assembles that global linear state-space model around the current
    operating point, as a StateSpace block or raw (A,B,C,D) plus labeled
    state_labels/input_labels/output_labels.
  • Also adds Simulation.trim(targets, free=(), ...) -- a genuinely new
    capability, not an extension of anything that existed before. It's
    needed as a precondition for linearize_system() to be useful in
    practice: you need a real operating point to linearize around, and the
    existing steadystate() can only drive states to equilibrium given
    inputs that are already fixed -- it has no way to solve backwards for an
    input that produces a desired output. This is exactly what the aircraft
    example needs (find the elevator deflection for straight-and-level
    flight) and what motivated writing it in the first place.
  • Those labels are exactly the states=/inputs=/outputs= kwargs
    control.StateSpace expects -- verified directly against python-control:
    construction needs no adapter code, poles/DC gain/step response all match.

Changes

  1. blocks/_block.py: fix a latent TypeError in Block.linearize() for
    stateless blocks whose op_alg is a DynamicOperator (e.g.
    DynamicalFunction) -- the no-engine branch called .linearize(u)
    regardless of operator type.
  2. utils/linearization.py (new): per-block Jacobian adapter
    (linearize_block) uniform across the different block patterns in the
    library, _break_connections, and assemble_linear_system (DAG-order
    symbolic substitution assembling the global state-space model).
  3. simulation.py: trim() and linearize_system(), reusing the existing
    generic solve_root optimizer for the former.
  4. utils/register.py: fixed two scalar/array-shape bugs found while
    building the trim example (a 0-d array from a scipy interpolator, and a
    1-element array assigned to a scalar index -- both broken under current
    numpy).
  5. tests/pathsim/blocks/test_table.py: two classes in this file were both
    named TestLUT1D; Python silently let the second shadow the first, so
    pytest never collected 8 of its tests. Renamed to unshadow them, which
    surfaced a wrong expected value in one test (fixed) and a stale test
    importing a pathsim.optim.value.Value class that doesn't exist
    anywhere in the current codebase (marked @unittest.skip with an
    explicit reason rather than guessing at reimplementing it).
  6. examples/example_trim.py, examples/example_linearize_system.py:
    usage examples -- an aircraft longitudinal short-period trim (built from
    small connected blocks: LUT1D aero tables + Amplifier/Adder
    dynamics + Integrator states, deliberately not a single equation) and
    a PID + nonlinear-plant closed loop showing the small-signal validity
    limit of the linearized model.
  7. tests/evals/test_linearize_system_python_control.py + pyproject.toml:
    compatibility test against real python-control, following the existing
    FMPy/scikit-rf test-extra precedent.

Not in this PR (open questions for review)

  • The actual python-control bridge (importing/exporting block diagrams, or
    wrapping a Simulation as NonlinearIOSystem) -- other ideas raised
    alongside this one in Integration with python-control #195 -- is intentionally not attempted here.
  • Open question, raised here rather than decided unilaterally: trim() is
    new functionality pathsim doesn't have today, so I think it's worth
    shipping on its own merits. But once a Simulation can be wrapped as a
    NonlinearIOSystem, python-control's own find_operating_point() offers
    something similar (and richer -- per-state free/steady selection, fixed
    indices). Should trim() be treated as a permanent, standalone pathsim
    capability, or as a provisional one likely to be superseded once that
    bridge exists? Happy to go either way.
  • test_sensitivity in test_table.py references a removed Value AD
    class -- flagged but not resolved here.

Test plan

  • Full suite: pytest -m "slow or not slow" -- 1158 passed, 14 skipped,
    1 pre-existing unrelated failure (test_cosim_fmu_system.py, missing
    FMU .so binary -- confirmed present on master too)
  • Ran both new examples manually and visually checked the plots

🤖 Generated with Claude Code

Miguel and others added 6 commits July 5, 2026 17:42
… op_alg

Block.linearize()'s no-engine branch called op_alg.linearize(u) with a
single argument regardless of operator type, which raised TypeError for any
stateless block whose op_alg is a DynamicOperator (e.g. DynamicalFunction),
since DynamicOperator.linearize requires (x, u, t). Dispatch on the
operator type instead. No test previously exercised this combination.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closes the gap identified in pathsim#195: individual blocks already
support in-place linearization, but nothing assembled those local Jacobians
into a single global model for an interconnected diagram, and steadystate()
can only drive dx/dt=0 given inputs that are already fixed -- it can't solve
backwards for the input that produces a desired output.

- utils/linearization.py: linearize_block() (a per-block Jacobian adapter,
  uniform across the different block patterns in the library -- full
  op_dyn+op_alg, op_dyn-only with an implicit output, no op_dyn at all as in
  Integrator, and stateless op_alg-only blocks), _break_connections() and
  assemble_linear_system() (DAG-order symbolic substitution that eliminates
  algebraic blocks and produces a global (A, B, C, D)).
- Simulation.trim(targets, free=(), ...): fixes a mix of output values
  and/or block states and solves for the free unknowns (unpinned dynamic
  states plus named Constant.value parameters) via the existing generic
  solve_root optimizer.
- Simulation.linearize_system(inputs, outputs, as_block=True): assembles
  the global closed-loop model around the current operating point, as a
  StateSpace block or raw matrices. Returns a LinearizationResult with
  state/input/output labels alongside (A, B, C, D) -- these are exactly the
  states=/inputs=/outputs= kwargs python-control's StateSpace expects, so a
  future export needs no adapter code.

Verified against a PID + nonlinear-plant closed loop: the assembled model
reproduces the same small-signal trajectory as the existing in-place
linearize() + run() to ~1e-15.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found while building an aircraft trim example using LUT1D as a CL(alpha)/
Cm(alpha) lookup table. LUT1D's underlying scipy interp1d call, for a
single scalar output, returns a 0-d numpy array; Register.update_from_array
did len(arr) on anything isinstance(arr, np.ndarray), which raises
TypeError for 0-d arrays. Special-case 0-d arrays as scalars.

Root cause of why this went uncaught: tests/pathsim/blocks/test_table.py
had two classes both named TestLUT1D. Python silently let the second
shadow the first at module import, so pytest never collected the first
class's tests (test_embedding_siso, test_update_siso, etc.) -- including
the coverage that would have caught this. Renamed the first class to
TestLUT1DEmbedding to restore it.

Unshadowing surfaced two more latent issues, fixed here:
- Register.__setitem__ didn't tolerate a 1-element numpy array assigned to
  a scalar int index (numpy 2.x: "setting an array element with a
  sequence") -- extended the existing 0-d scalar-coercion branch.
- test_linearization_siso asserted the wrong expected value: linear
  interpolation between grid points (1, 1) and (2, 4) at x=1.5 correctly
  gives 2.5, not the exact quadratic 1.5**2=2.25 the test asserted.

test_sensitivity in the same file imports pathsim.optim.value.Value, which
does not exist anywhere in the current src/ tree -- looks like a stale test
for removed/never-merged AD functionality. Marked @unittest.skip with an
explicit reason instead of guessing at reimplementing it; worth reporting
separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- example_trim.py: longitudinal trim of an aircraft short-period model
  (angle of attack + pitch rate), deliberately built from small connected
  blocks (two LUT1D aerodynamic tables, Amplifier/Adder force and moment
  combination, two Integrators) rather than one closed-form equation, since
  that's how real aircraft models get built. trim() solves for the angle of
  attack and elevator deflection that give straight-and-level flight, then
  a perturbed transient run shows the short-period mode settle back to the
  trim point.
- example_linearize_system.py: PID + nonlinear-plant closed loop. Trims,
  assembles the closed-loop state-space model, prints its poles, and
  compares a small vs. a large reference step against the true nonlinear
  response -- the small step matches closely, the large step visibly
  diverges, showing the small-signal validity limit of the linearized
  model.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirms the LinearizationResult produced by linearize_system() is
directly usable by control.StateSpace, with no adapter/glue code -- the
state_labels/input_labels/output_labels are exactly its states=/inputs=/
outputs= kwargs. Oriented at the python-control integration goal in
pathsim#195, without building the actual import/export bridge
(ideas A/C in that discussion) in this branch.

Adds "control" to the test extra in pyproject.toml, following the existing
precedent of FMPy/scikit-rf being test-only optional dependencies for
external-ecosystem interop tests.

Verified: zero-glue construction with label round-trip, poles match
np.linalg.eigvals(A) exactly, DC gain is correct, and a step response
matches pathsim's own StateSpace simulation of the identical model in the
time domain (not just the assembled matrices).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ings

Several docstrings/comments justified a new function's existence by naming
and contrasting a sibling (trim() vs. steadystate(), linearize_system() vs.
linearize()/delinearize(), linearize_block() vs. Block.linearize(),
Simulation._checkpoint_key, Simulation._dag()). These comparisons rot
silently if the referenced name ever changes, and the informational
content either duplicated what the Parameters section already said or
described an invariant that reads fine without naming the sibling it
happens to mirror. Rewrote each to describe what the function itself does.

Also dropped "see discussion pathsim#195" from two test file
headers -- that context belongs in the PR description, not the code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 5, 2026

Copy link
Copy Markdown

@miguelpi314

Copy link
Copy Markdown
Author

Re: the Codecov patch-coverage warning — traced the cause: pyproject.toml's default addopts = "-m 'not slow'" combined with the conftest.py hook that auto-marks everything under tests/evals/ as slow means CI's pytest --cov=... (no -m override in tests_codecov.yml) never runs tests/evals/test_trim_and_linearize_system.py or tests/evals/test_linearize_system_python_control.py — hence utils/linearization.py shows ~9% instead of the ~88-100% these tests actually give it when run explicitly. This isn't new to this PR: all 97 tests under tests/evals/ (including the pre-existing test_steadystate_transient_system.py) are excluded from CI's default run the same way.

Both new test files run in well under a second, so they aren't "slow" in the sense the marker implies — they landed in evals/ just to match test_steadystate_transient_system.py's precedent. Happy to move them into tests/pathsim/ so they run by default in CI (fixing the coverage number for real and giving actual regression protection on this new code), but wanted to check first since it touches the existing evals/ vs regular test-tree convention rather than just this PR. How would you like to handle it?

@milanofthe

Copy link
Copy Markdown
Member

Thanks for this, and sorry for the slow response — this touches the part of the
core I'm most protective about, so I wanted to go through it properly rather
than give you a quick reply.

Let me start with what I want to take right away, then explain where I want to
take the rest, because the second part is a roadmap decision on my side rather
than anything wrong with your work.

Merging now

Three of the fixes in here are independent of the linearization design and I'd
like them in as a separate, small PR:

  1. blocks/_block.py — the DynamicOperator branch in Block.linearize().
    Real TypeError, and the regression test in test_function.py is exactly
    how I'd have written it.
  2. utils/register.py — update_from_array on a 0-d array. Confirmed: this
    raises TypeError: len() of unsized object on master. (The second half, the
    1-element array in __setitem__, is a DeprecationWarning under NumPy 2.3
    rather than an error — still worth hardening, just not the same severity as
    the PR description suggests.)
  3. tests/pathsim/blocks/test_table.py — the duplicate TestLUT1D class
    shadowing 8 tests is a genuine catch, pathsim.optim.value.Value really is
    gone from the codebase, and 2.5 is the correct expected value: LUT1D
    interpolates linearly through interp1d, so x=1.5 sits between (1,1) and
    (2,4).

If you open that as its own PR I'll merge it.

Where I want to take linearize_system()

Here's the thing I need to be upfront about: pathsim and fastsim are converging
on a shared architecture, and linearization is one of the places where fastsim
has already made the call. Its Operator (src/blocks/operator.rs) derives the
Jacobian from the SSA graph as a pure query — the doc comment there says it
explicitly: "the Jacobian is recomputed per Newton step rather than
linearized-and-reused". And Simulation.linearize() is deliberately
NotImplementedError on that side.

So the target shape for pathsim is: Jacobian is a query, never a mode. That's a
decision that sits above your PR, and it's why I don't want to merge this path
even though the assembly itself is correct — I checked it against analytic
results for gain cascades, closed loops, pure feedthrough and a 2x2 MIMO
StateSpace, and it reproduces the matrices exactly.

Two things in the current implementation are what convinced me the query layer
has to come first, rather than being a later refactor:

The simulation stays linearized. linearize_block() calls block.linearize(t)
for its side effect in order to read op_dyn.Jx/.Ju, and nothing reverts it. So
after sim.linearize_system(...), a subsequent sim.run() silently integrates
the linear surrogate:

nonlinear run,               y(end) = 1.99955
run AFTER linearize_system,  y(end) = 2.47252

The tests here don't catch it because they happen to call Sim.linearize()
before and Sim.delinearize() after.

Worth knowing: the pure API you need already exists one layer down —
Operator.jac(x) and DynamicOperator.jac_x(x,u,t) / .jac_u(x,u,t) in
optim/operator.py, which blocks/dae.py:253 already uses in the hot solve
path. I rewrote linearize_block() against those and got bit-identical matrices
with no mode switch at all.

Blanket numerical fallback. When a block has no operator, the code
finite-differences it. 22 of 36 block modules have no operator, and that set
includes Comparator, Relay, Switch, RNG and Counter — for those a
numerical "Jacobian" is meaningless rather than approximate. Linearizability
should be an explicit per-block property, and blocks that don't have a valid
linear model should raise rather than return a number. That's also how fastsim
separates traceable operators from genuinely opaque ones.

Two smaller things I found while testing, in case they're useful wherever this
lands:

  • _state_labels() and _port_ref_labels() count independently, so the label
    sets collide across categories. On a two-integrator chain I get
    state_labels = ['Integrator_0', 'Integrator_1'] and
    output_labels = ['Integrator_0'] where the tapped block is actually the
    second integrator. Since the labels are meant as python-control identifiers,
    that one matters.
  • assemble_linear_system resolves break points with inputs._map(port) instead
    of PortReference._get_input_indices(), which is what resizes the register.
    Marking an input port that isn't wired yet (a disturbance input, say) raises
    IndexError: index 1 is out of bounds for axis 0 with size 1.

The plan

Three levels, to_statespace() throughout:

  • Operator — jac() / jac_x() / jac_u(), pure. Already there.
  • Block — Block.to_statespace(t), defaulting to the operator queries, with
    exact overrides where an operator is absent (Integrator is A=0, B=I, C=I, D=0, no differencing needed) and a hard failure where no valid linear model
    exists. Block.linearize(t) stays as the mode switch but starts returning the
    matrices it computes anyway.
  • Simulation — Simulation.to_statespace(inputs, outputs, t) assembling via
    the interconnection form rather than DAG substitution, which handles surviving
    algebraic loops instead of rejecting them.

Returning a StateSpace block at every level also removes the need for the
as_block flag and the label attributes attached from outside, and
Subsystem.to_statespace() falls out for free.

I'm writing this up properly in #195. If you'd like to take the block level —
level 2 above — it's the most self-contained piece and I'd be glad to have it.
No obligation either way; the fixes at the top are worth merging on their own.

Your open questions

evals/ vs tests/: you're right about the cause, and yes — move them into
tests/pathsim/. They run in under two seconds, they aren't slow in the sense
the marker means, and evals/ being excluded from the coverage run isn't a
convention worth preserving for them.

trim(): I'd like to keep the capability but not as a separate solver stack.
steadystate() works by switching all blocks to the SteadyState solver and
reusing _solve() — no new numerics. Trim is that same solve with the unknown
vector extended by the free parameters and the residual extended by the output
targets, so it belongs there rather than beside it. That also settles the
python-control question: it stays pathsim's own, because it's an extension of
machinery pathsim already has.

@miguelpi314

Copy link
Copy Markdown
Author

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants