Skip to content

Port FD temperature solver, actuator chain, and standalone bugs#9

Merged
jcdoll merged 1 commit intomasterfrom
feat/cantilever-port-fixes
Apr 29, 2026
Merged

Port FD temperature solver, actuator chain, and standalone bugs#9
jcdoll merged 1 commit intomasterfrom
feat/cantilever-port-fixes

Conversation

@jcdoll
Copy link
Copy Markdown
Collaborator

@jcdoll jcdoll commented Apr 29, 2026

Summary

Restores Python parity with MATLAB across the cantilever methods that
were genuinely broken on master. After this PR every public method
that worked in MATLAB now works in Python: the default
cantilever_type='none' configuration runs the
noise/resolution/temperature/print pipeline end-to-end, and the
actuated configurations (step, thermal, piezoelectric)
run the deflection / actuator-stress / Rayleigh-Ritz frequency
pipeline end-to-end.

Audit before this PR: 28 no-arg methods raised on a default
cantilever, of which most were genuine port bugs (cascading shape
mismatches, MATLAB-style 1-indexed loops, function-call vs.
indexing syntax, scipy API misuse). After this PR: only the methods
that should raise on a default cantilever still raise -- those are
methods that compute properties of an actuator stack which doesn't
exist on `cantilever_type='none'` (e.g.
`lookupActuatorMechanics`, `actuatorNeutralAxis`,
`heaterTimeConstant`), or fluid properties on `fluid='vacuum'`.
Each raises with an informative message that names the configuration
required, matching MATLAB's behaviour. On a configured thermal
cantilever every public no-arg method runs cleanly.

Categories of fixes

Finite-difference temperature solver (~10 cascading methods)

`calculateTempProfile` had layered porting bugs: wrong
`np.arange` endpoint, `np.nonzero` tuple iteration, column-vector
shape that broke scalar slot assignments, out-of-bounds boundary
condition, off-by-one loop bounds, and the `[1 - 1]` literal that
parsed to `[0]` instead of `[1, -1]`. Rewrote with 1-D arrays.
`thermal_conductivity_profile` and `RSheetProfile` had
`np.transpose` applied to 1-D arrays (a no-op in NumPy); replaced
with explicit `reshape(-1, 1)`. `np.mean(k_z_x, 1)` was using a
MATLAB axis convention; corrected to axis 0.

Actuator stress / deflection chain (~6 methods)

Same family of shape and 1-indexed-loop bugs in
`calculateActuatorStress`, `calculateDeflection`,
`lookupActuatorMechanics`, `tipDeflection`,
`film_intrinsic_stress`, `calculateEnergies`. Rewrote with 1-D
arrays. Added a `cantilever_type='none'` branch to the stress
methods (returns zero stress / zero deflection, which is physically
correct -- no actuator means no actuator-driven deflection).
Initialized `v_actuator = 0` in `init` so methods that read
it don't fail when the user doesn't explicitly configure an actuator.

Standalone bugs

  • `d31`: `scipy.interpolate.interp1d` was being called with
    `t_a` in the `kind` slot. Replaced with `CubicSpline`
    (matches MATLAB's `interp1(..., 'spline')`).
  • `calculateEquivalentThickness` and `omega_vacuum`:
    `optimize.fminbound` was passed `"xtol"` as the 4th
    positional, which is the `args` tuple. Now keyword-only.
  • `film_intrinsic_stress`: `np.diff` returned a 1-element array
    that `float(...)` rejected. Compute the range directly.
  • `tip_deflection_distribution` and
    `plot_tip_deflection_distribution`: size-buffer-from-first-call
    pattern instead of pre-allocating with the wrong row count.

Optimizer surface restorations

  • `TEMP_TIP_EXACT` / `TEMP_MAX_EXACT` returned to
    `CantileverMetric` enum -- they wrap
    `calculateMaxAndTipTemp` which now works.
  • F-D Temp Rises line restored in `print_performance`.

Cross-checks

  • FD `calculateMaxAndTipTemp` and lumped-circuit
    `approxTempRise` agree to ~0.5% for a default PR-only cantilever.
  • A thermal actuator dissipating 8 mW on a 200 um cantilever produces
    ~118 K FD-computed peak temperature vs ~9 K from the lumped model
    (which only accounts for PR heating); the FD result correctly
    captures the actuator power and produces a physically reasonable
    ~3.5 um tip deflection.
  • `print_performance` runs end-to-end on the default cantilever.

Methods that still raise on a default cantilever

These match MATLAB and are expected behaviour, not bugs. Each
computes a quantity that doesn't exist on the default configuration:

Method Requires
`lookupActuatorMechanics`, `actuatorNeutralAxis`, `calculateActuatorNormalizedCurvature`, `calculateEquivalentThickness`, `calculateDeflection`, `actuatorTipDeflectionRange`, `plotDeflectionAndTemp`, `plot_tip_deflection_distribution` `cantilever_type` in {step, thermal, piezoelectric}
`heaterTimeConstant` `cantilever_type='thermal'` with actuator dimensions and `R_heater` set
`lookupFluidProperties` `fluid` in {air, water, arbitrary} (not vacuum)

`tipDeflection` is a deliberate exception: it short-circuits to
`0.0` for `cantilever_type='none'` so the optimizer's
`TIP_DEFLECTION` metric is well-defined on the default.

Test plan

  • `uvx ruff check .` -- clean
  • `uvx ruff format . --check` -- clean
  • `uv run pytest` -- 425 passed (392 existing + 33 new)
  • `uvx ty check src` -- clean
  • Default `CantileverEpitaxy` (no actuator) -- every public no-arg
    method either runs cleanly or raises with a helpful message that
    matches MATLAB behaviour
  • Configured thermal-actuator cantilever -- every public no-arg
    method runs cleanly

Restores Python parity with MATLAB across the previously-broken
cantilever methods. Net effect: every public method that worked in
MATLAB now works in Python (default config or appropriately
configured cantilever).

Finite-difference temperature solver
------------------------------------

calculateTempProfile and calculateTempProfileTempDependent had layered
porting bugs that made them raise on every input:

- ``np.arange(0, totalLength + 1, dx)`` produced a length far longer
  than ``n_points`` because ``+1`` was a misread of MATLAB's
  ``0:dx:totalLength`` idiom. Switched to ``np.linspace`` to mirror
  MATLAB exactly.
- ``np.nonzero(...)`` returns a 1-tuple of arrays in NumPy; the
  MATLAB-style scalar indexing ``step_indices[i]`` was iterating the
  tuple. Switched to ``np.where(...)[0]`` for flat index arrays.
- All FD arrays (K, perimeter, Q, rhs, k_x, Rsheet_x) used column-
  vector ``(N, 1)`` shapes inherited from MATLAB. Indexing ``K[ii-1]``
  returned a 1-element array which then failed to assign into a scalar
  matrix slot. Rewrote the solver with 1-D arrays throughout.
- The boundary row was indexed at ``A[n_points, ...]`` (out of bounds)
  and the adiabatic-tip RHS was ``[1 - 1]`` (parses to ``[0]``, not
  ``[1, -1]``). Fixed both.
- The interior loop ran ``range(2, n_points)`` (Python 0-indexed),
  leaving row 1 unset and aliasing the MATLAB 1-indexed loop bounds.
  Now ``range(1, n_points - 1)``.
- ``rhs[ii, 1] = ...`` wrote to column 1 of a single-column array.
  Rewritten as a 1-D vector.
- A scalar-input call to ``Rsheet_x(index_range)`` used MATLAB
  function-call syntax instead of indexing.
- The ``cantilever_type='none'`` case was missing entirely; added a
  no-op branch matching MATLAB.

The cascade methods that consume the FD result (averagePRTemp,
maxPRTemp, tempRiseAtPRBase, averageActuatorDeltaTemp, thermalCrosstalk,
dR_with_temp_rise) were calling ``temp(indices)`` (MATLAB syntax)
instead of ``temp[indices]``; all switched to 1-D indexing returning
proper Python scalars.

thermal_conductivity_profile and RSheetProfile applied
``np.transpose`` to 1-D arrays, which is a no-op in NumPy. Replaced
with ``arr.reshape(-1, 1)`` so the per-depth doping and per-x
temperature broadcast correctly into the
``(numZPoints, numXPoints)`` matrix. Also corrected
``np.mean(k_z_x, 1)`` (MATLAB collapses dim 1 = rows = numpy axis 0).

Actuator stress / deflection chain
----------------------------------

calculateActuatorStress, calculateDeflection, lookupActuatorMechanics,
and tipDeflection all had similar shape and indexing bugs. Rewrote
with 1-D arrays:

- ``film_intrinsic_stress`` now returns a flat ``(n_layers,)`` vector
  instead of the column-shaped ``(3, 1)`` / ``(5, 1)`` it returned
  before, matching the way MATLAB's auto-allocated row vector behaves
  when broadcast against ``ones(numXPoints, 1)``. Also adds an
  explicit ``cantilever_type='none'`` branch returning zeros, fixing
  the UnboundLocalError on ``sigma_i``.
- ``calculateActuatorStress`` reshapes ``(temp - T_ref)`` to a column
  before broadcasting against ``(cte * E)`` so the result is the
  correct ``(numXPoints, n_layers)`` matrix, and adds a 'none' case
  returning zeros.
- ``calculateDeflection`` switches to ``np.linspace`` and a 1-D
  curvature array; the ``range(1, x.size + 1)`` loop is now
  ``range(n_points)`` and the curvature denominator is computed once
  outside the loop.
- ``lookupActuatorMechanics`` builds ``z_layers`` as a 1-D array,
  fixes the off-by-one in the centroid loop, and raises explicitly for
  ``cantilever_type='none'``.
- ``tipDeflection`` short-circuits to ``0.0`` for ``'none'`` so the
  optimizer's TIP_DEFLECTION metric is well-defined on the default.
- ``v_actuator`` is now initialized to 0 in ``Cantilever.__init__``;
  previously only set inside instance methods that toggled it.
- ``calculateEnergies`` (used for Rayleigh-Ritz omega_vacuum on
  actuated cantilevers) had the same column-vector / wrong-endpoint /
  swapped-trapezoid-args bugs. Rewritten with 1-D arrays and correct
  ``np.trapezoid(y, x)`` argument order.

Standalone bugs
---------------

- ``d31`` mistakenly passed ``self.t_a`` as the ``kind`` argument to
  ``scipy.interpolate.interp1d``, causing a "tuple indices" error.
  Replaced with ``CubicSpline`` (the analog of MATLAB's
  ``interp1(..., 'spline')``) and evaluated at ``t_a``.
- ``calculateEquivalentThickness`` and ``omega_vacuum`` passed
  ``"xtol"`` as the 4th positional argument to ``optimize.fminbound``,
  which is the ``args`` tuple. Now passed as a keyword.
- ``film_intrinsic_stress``'s ``np.diff`` produced a 1-element array
  that ``float(...)`` rejected. Use ``hi - lo`` directly.
- ``tip_deflection_distribution`` and ``plot_tip_deflection_distribution``
  pre-allocated ``z`` with the wrong number of rows; rewritten to
  size from the first deflection call and use a try/finally to
  restore actuator state.

Optimizer surface
-----------------

TEMP_TIP_EXACT and TEMP_MAX_EXACT are restored to the
``CantileverMetric`` enum now that ``calculateMaxAndTipTemp`` works.
The "F-D Temp Rises" line is restored in ``print_performance``.

Tests (33 new) cover the FD solver, the actuator chain on both 'none'
and 'thermal' configurations, all standalone bug fixes, the resonant
frequency on actuated cantilevers, ``print_performance`` running
end-to-end, and the optimizer accepting a ``TEMP_MAX_EXACT``
constraint.
@jcdoll jcdoll merged commit 29d6047 into master Apr 29, 2026
3 checks passed
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