Skip to content

Reuse forward-solver assembly for calibrator residual#227

Draft
mrp089 wants to merge 11 commits intomasterfrom
claude/calibrator-residual-refactor
Draft

Reuse forward-solver assembly for calibrator residual#227
mrp089 wants to merge 11 commits intomasterfrom
claude/calibrator-residual-refactor

Conversation

@mrp089
Copy link
Copy Markdown
Member

@mrp089 mrp089 commented May 5, 2026

Summary

  • Splits the dual responsibility of per-block update_gradient into (a) writing only the parameter-Jacobian columns and (b) computing the residual via the existing forward-solver assembly. The residual formulas are no longer duplicated between solver and calibrator.
  • Updated blocks: BloodVessel, BloodVesselCRL, BloodVesselJunction now write Jacobian only. Junction's override is dropped (it had no params, only wrote residual). The base-class default is a no-op so blocks without parameters need no override.
  • The Levenberg-Marquardt optimizer owns a SparseSystem and, each iteration, syncs alpha into model.parameter_values, calls model.update_constant once and model.update_solution per observation, and reads r_i = c + E*dy + F*y straight off the assembly — same residual the forward solver computes.

Why

Until now the per-block update_gradient re-implemented two things: the Jacobian columns and the residual algebra. The residual algebra is exactly what the solver assembles via update_constant / update_solution, so the duplication meant any change to a block's E/F/c shape had to land in two places. Splitting concerns lets the calibrator inherit residual changes for free.

Practical wrinkles

  • Calibrator models contain no boundary-condition blocks, so num_vars > num_eqns is possible. The shared SparseSystem is sized with max(num_eqns, num_vars); matrix-vector products run on padded vectors and the residual segment is sliced to num_eqns.
  • calibrate.cpp now registers each parameter slot via Model::add_parameter(0.0) so parameter_values is sized for the assembly path. The LM loop syncs alpha through Model::update_parameter_value. update_time is intentionally skipped — it would overwrite the synced values from the dummy Parameter objects.

Stacked on

This branch is layered on top of #226; the Block API change and the LM refactor here depend on the per-block calibrate field and the block-agnostic helpers introduced there. Marked draft; rebase to master once #226 merges.

Test plan

  • pytest tests/test_calibrator.py — all 5 calibrator tests pass.
  • pytest tests/test_solver.py tests/test_io.py — 49 pass + 2 pre-existing xfail; forward solver unaffected.
  • make codeformat produces no diff.
  • CI green.

Diff stat

13 files changed, +90 / -123 lines.

🤖 Generated with Claude Code

Martin Pfaller and others added 10 commits May 5, 2026 15:58
Add an optional `calibrate` field to `calibration_parameters` that lists
the parameter names to calibrate (e.g., `["R_poiseuille"]`). Parameters
not listed are held constant at their input-file values; the LM
optimizer now takes an active-parameter index list and runs the normal
equations on the reduced sub-Jacobian, leaving inactive entries of
alpha untouched. When the field is absent, all parameters are
calibrated, preserving legacy behavior.

Adds a regression test (`test_steady_flow_calibration_R_only`) that
recovers R=100 from a non-ground-truth initial guess while keeping C,
L, and stenosis_coefficient pinned to their ground-truth values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the synthetic steady-flow R-only test with one based on the
existing VMR calibration fixtures (0104_0001). The test starts from the
calibrated reference (so C, L and stenosis_coefficient are at the ground
truth), zeros every R_poiseuille in vessels and junctions, and asks the
calibrator to recover R only. The reference values are recovered to
machine precision and the held-constant parameters are unchanged.

Also fixes the clang-format violations CI flagged on the previous commit
and removes the steady-flow JSON case (the dirgraph test auto-discovers
JSONs in tests/cases/ and would have required a generated reference dot
file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each vessel and BloodVesselJunction can now carry its own optional
``calibrate`` list naming the parameters to optimize. Resolution order
for each block: block-level ``calibrate`` > ``calibration_parameters.calibrate``
> calibrate everything (legacy). An explicit empty list at any level
means "calibrate nothing for that scope". The active-parameter id list
passed to LM is built per block from the resolved set, so the
optimizer still operates on a global reduced subspace.

Adds two regression tests on the smallest VMR case:
- ``test_calibration_vmr_R_only_per_block`` — sets the new field on
  every vessel and junction with no global default.
- ``test_calibration_vmr_block_overrides_global`` — sets a wrong
  global default (``["C"]``) and overrides each block to recover R;
  verifies the override actually wins.

The previous global-only test is renamed to
``test_calibration_vmr_R_only_global``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes hardcoded knowledge of BloodVessel/BloodVesselJunction parameter
names ("R_poiseuille", "C", "L", "stenosis_coefficient") from
calibrate.cpp. Each block now exposes its parameter names via the
existing ``Block::input_params`` field, and ``Block::input_params_list``
distinguishes scalar layout (one slot per name) from list layout
(``input_params.size() * stride`` slots, name-major). Three small
helpers (``num_param_slots``, ``register_active``, ``init_alpha_for_block``,
``write_alpha_for_block``) walk the parameters generically; alpha
init, active-id selection, and JSON output writing all use the block's
own metadata. New blocks that implement ``update_gradient`` and
populate ``input_params`` will be calibrated without further changes
to calibrate.cpp.

The legacy ``calibrate_stenosis_coefficient: false`` flag is preserved
as an additional filter that excludes ``stenosis_coefficient`` from
the active set; behavior is observably identical (the slot is always
allocated but not optimized).

All seven calibrator tests pass; the existing forward-solver tests
are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the in-test config builders in tests/test_calibrator.py with
three checked-in fixtures derived from the existing 0104_0001 VMR data:

  tests/cases/vmr/input/0104_0001_calibrate_R_only_global.json
  tests/cases/vmr/input/0104_0001_calibrate_R_only_per_block.json
  tests/cases/vmr/input/0104_0001_calibrate_R_only_block_overrides.json

Each starts from the calibrated reference (so C, L and
stenosis_coefficient are at the ground truth) with every R_poiseuille
zeroed; the test parametrizes the same recovery assertion across all
three. The Python helpers that built these configs at runtime are gone.

Adds docs/pages/calibrator.md describing the new ``calibrate`` field,
its global vs per-block resolution order, and pointing readers at the
new fixtures as worked examples. Linked from docs/pages/main.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the global ``calibration_parameters.calibrate`` option. The
``calibrate`` field is now exclusively a per-block setting on vessels
and multi-outlet junctions. If no block sets it, the legacy
"calibrate every parameter" behavior still applies.

Consolidates the three R-only fixtures into a single
``tests/cases/vmr/input/0104_0001_calibrate_R_only.json`` (the former
per-block variant); the global and block-overrides fixtures, which
exercised paths that no longer exist, are deleted. The parametrized
``test_calibration_R_only`` collapses to one assertion.

Updates ``docs/pages/calibrator.md`` to document only the per-block
form, drops the resolution-precedence section, and points readers at
the single new fixture as a worked example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the legacy "no calibrate field => calibrate everything"
fallback. Each block must now declare a ``calibrate`` list naming the
parameters to optimize; a missing or empty list means every parameter
of that block is held at its input value.

Drops the ``calibrate_stenosis_coefficient`` flag from
``calibration_parameters``: with explicit per-block selection it is
redundant. Removed from the reader in calibrate.cpp and from the
fixtures that still carried it.

Updates every checked-in calibrator fixture to the new schema:

* ``tests/cases/steadyFlow_calibration.json``
* ``tests/cases/vmr/input/0080_0001_calibrate_from_0d.json``
* ``tests/cases/vmr/input/0104_0001_calibrate_from_0d.json``
* ``tests/cases/vmr/input/0140_2001_calibrate_from_0d.json``

Each gets ``calibrate: ["R_poiseuille", "C", "L", "stenosis_coefficient"]``
on every vessel and ``calibrate: ["R_poiseuille", "L", "stenosis_coefficient"]``
on every multi-outlet junction so the existing test expectations
(``test_steady_flow_calibration``, ``test_calibration_vmr``) still hold.

Updates ``docs/pages/calibrator.md`` to document the mandatory form
and shows the explicit lists needed to opt every parameter into
calibration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the BloodVessel-specific ``set_capacitance_to_zero`` switch
from ``calibration_parameters`` along with the name-based output
post-processing (``std::max(C, 0)``, ``std::max(L, 0)``). With explicit
per-block ``calibrate`` selection, these legacy clamps are unnecessary
and the calibrator no longer special-cases parameter names by string.

Folds the new R-only test case into the existing
``test_calibration_vmr`` parametrize and switches the comparison to
the proper ``reference/`` files (which were always intended). Fixes
the latent bug where ``np.isclose`` was called without ``assert`` and
the comparison referenced ``input/`` instead of ``reference/`` -
``calibrator(input)`` matches every reference to ~1e-13, so the
existing three VMR cases now actually verify their output. The
R-only case is just one more entry with the same comparison code.

Removes the local ``docs/pages/calibrator.md`` and link from
``main.md``: the canonical user guide is in the SimVascular
documentation site repo and will be updated there.

Drops ``set_capacitance_to_zero`` from every checked-in fixture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Python formatter that re-emitted the calibrator fixtures was
ignoring the parent dict key's prefix when deciding whether an array
fits inline. That left one over-80 line in
``0080_0001_calibrate_from_0d.json``
(``"outlet_vessels": [4, 37, 49, 59, 70, 73, 91, 94, 100, 137, 156, 168, 186, 198],``,
86 chars) which prettier flagged in CI. Re-emitted all calibrator
fixtures with the fixed formatter; round-trip on the master VMR files
matches byte-for-byte and no line in any fixture is over 80 chars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Until now the per-block ``update_gradient`` re-implemented two things:
the parameter Jacobian columns and the residual itself, with the latter
duplicating the (E, F, c) algebra that the forward solver already
owns. This commit splits the two concerns:

* ``Block::update_gradient`` is contracted to populate only the
  parameter Jacobian columns; the residual argument is removed from
  the signature and the implementations of BloodVessel, BloodVesselCRL
  and BloodVesselJunction lose their residual-formula branch.
  Junction's override (which only wrote the residual and had no
  parameters) is dropped entirely; the new base-class default is a
  no-op so blocks without parameters need no override.

* The Levenberg-Marquardt optimizer now owns a SparseSystem and, at
  each iteration, syncs alpha into the model's parameter storage,
  calls ``Model::update_constant`` once and ``Model::update_solution``
  per observation, and reads the residual chunk straight off the
  assembly (``r_i = c + E*dy + F*y``). This is the same residual the
  forward solver computes (up to the sign that cancels in the LM
  normal equations).

Two practical wrinkles addressed:

* Calibrator models contain no boundary-condition blocks, so
  ``num_vars > num_eqns``. The system is sized with the larger of the
  two; matrix-vector products run on padded vectors and the residual
  segment is sliced to the equation count.
* ``calibrate.cpp`` now registers each parameter slot through
  ``Model::add_parameter`` so ``parameter_values`` is sized for the
  assembly path. The LM loop syncs alpha through
  ``Model::update_parameter_value``; ``update_time`` is intentionally
  skipped because it would overwrite the synced values from the
  Parameter objects.

All seven calibrator tests still pass, every solver/io test still
passes, and ``make codeformat`` produces no diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented May 5, 2026

Codecov Report

❌ Patch coverage is 87.94326% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.30%. Comparing base (c7b0ca0) to head (244734f).

Files with missing lines Patch % Lines
src/optimize/calibrate.cpp 94.38% 5 Missing ⚠️
src/model/Block.cpp 0.00% 2 Missing ⚠️
src/model/ChamberElastanceInductor.h 0.00% 1 Missing ⚠️
src/model/ChamberElastanceInductorExponential.h 0.00% 1 Missing ⚠️
src/model/ChamberSphere.h 0.00% 1 Missing ⚠️
src/model/ClosedLoopHeartPulmonary.h 0.00% 1 Missing ⚠️
src/model/FlowReferenceBC.h 0.00% 1 Missing ⚠️
src/model/LinearElastanceChamber.h 0.00% 1 Missing ⚠️
src/model/OpenLoopCoronaryBC.h 0.00% 1 Missing ⚠️
src/model/PressureReferenceBC.h 0.00% 1 Missing ⚠️
... and 2 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #227      +/-   ##
==========================================
+ Coverage   78.63%   79.30%   +0.66%     
==========================================
  Files          70       70              
  Lines        2790     2803      +13     
  Branches      318      325       +7     
==========================================
+ Hits         2194     2223      +29     
+ Misses        596      580      -16     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The calibrator residual is computed at observation samples that carry
no time stamp, so any block with non-trivial ``update_time`` cannot be
calibrated correctly. ``Block`` gains a virtual
``has_time_dependent_assembly()`` (default false); chambers and the
time-dependent boundary conditions override it to return true.
``calibrate.cpp`` walks the model after ``finalize`` and throws before
reading observations or constructing the optimizer if any such block
is present.

Restores ``Block::update_gradient``'s default to throw "not
implemented" so a parameterized block that forgets to override is
caught instead of silently producing a zero Jacobian column. The
Levenberg-Marquardt loop skips blocks with no parameters
(``global_param_ids.empty()``) so parameterless blocks like
NORMAL_JUNCTION don't trip the throw and don't need their own no-op
override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant