Repository navigation
Add trim() and linearize_system() for system-level operating points - #237
miguelpi314 wants to merge 6 commits into
Conversation
… 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>
|
Re: the Codecov patch-coverage warning — traced the cause: Both new test files run in well under a second, so they aren't "slow" in the sense the marker implies — they landed in |
|
Thanks for this, and sorry for the slow response — this touches the part of the Let me start with what I want to take right away, then explain where I want to Merging nowThree of the fixes in here are independent of the linearization design and I'd
If you open that as its own PR I'll merge it. Where I want to take
|

Summary
already existed (
Block.linearize()/Simulation.linearize()), but nothingassembled those Jacobians into a single global
(A,B,C,D)for aninterconnected diagram.
Simulation.linearize_system(inputs, outputs, as_block=True):assembles that global linear state-space model around the current
operating point, as a
StateSpaceblock or raw(A,B,C,D)plus labeledstate_labels/input_labels/output_labels.Simulation.trim(targets, free=(), ...)-- a genuinely newcapability, not an extension of anything that existed before. It's
needed as a precondition for
linearize_system()to be useful inpractice: you need a real operating point to linearize around, and the
existing
steadystate()can only drive states to equilibrium giveninputs 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.
states=/inputs=/outputs=kwargscontrol.StateSpaceexpects -- verified directly againstpython-control:construction needs no adapter code, poles/DC gain/step response all match.
Changes
blocks/_block.py: fix a latentTypeErrorinBlock.linearize()forstateless blocks whose
op_algis aDynamicOperator(e.g.DynamicalFunction) -- the no-engine branch called.linearize(u)regardless of operator type.
utils/linearization.py(new): per-block Jacobian adapter(
linearize_block) uniform across the different block patterns in thelibrary,
_break_connections, andassemble_linear_system(DAG-ordersymbolic substitution assembling the global state-space model).
simulation.py:trim()andlinearize_system(), reusing the existinggeneric
solve_rootoptimizer for the former.utils/register.py: fixed two scalar/array-shape bugs found whilebuilding 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).
tests/pathsim/blocks/test_table.py: two classes in this file were bothnamed
TestLUT1D; Python silently let the second shadow the first, sopytest 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.Valueclass that doesn't existanywhere in the current codebase (marked
@unittest.skipwith anexplicit reason rather than guessing at reimplementing it).
examples/example_trim.py,examples/example_linearize_system.py:usage examples -- an aircraft longitudinal short-period trim (built from
small connected blocks:
LUT1Daero tables +Amplifier/Adderdynamics +
Integratorstates, deliberately not a single equation) anda PID + nonlinear-plant closed loop showing the small-signal validity
limit of the linearized model.
tests/evals/test_linearize_system_python_control.py+pyproject.toml:compatibility test against real
python-control, following the existingFMPy/scikit-rftest-extra precedent.Not in this PR (open questions for review)
wrapping a
SimulationasNonlinearIOSystem) -- other ideas raisedalongside this one in Integration with python-control #195 -- is intentionally not attempted here.
trim()isnew functionality pathsim doesn't have today, so I think it's worth
shipping on its own merits. But once a
Simulationcan be wrapped as aNonlinearIOSystem, python-control's ownfind_operating_point()offerssomething similar (and richer -- per-state free/steady selection, fixed
indices). Should
trim()be treated as a permanent, standalone pathsimcapability, or as a provisional one likely to be superseded once that
bridge exists? Happy to go either way.
test_sensitivityintest_table.pyreferences a removedValueADclass -- flagged but not resolved here.
Test plan
pytest -m "slow or not slow"-- 1158 passed, 14 skipped,1 pre-existing unrelated failure (
test_cosim_fmu_system.py, missingFMU
.sobinary -- confirmed present onmastertoo)🤖 Generated with Claude Code