Skip to content

Port MATLAB geometry+process optimization to Python#8

Merged
jcdoll merged 3 commits intomasterfrom
feat/optimization-port
Apr 29, 2026
Merged

Port MATLAB geometry+process optimization to Python#8
jcdoll merged 3 commits intomasterfrom
feat/optimization-port

Conversation

@jcdoll
Copy link
Copy Markdown
Collaborator

@jcdoll jcdoll commented Apr 29, 2026

Summary

Ports the MATLAB optimization framework (optimize_performance /
optimize_performance_from_current plus per-subclass overrides) to a
single declarative Python module. Brings Python optimization to feature
parity with MATLAB across all five cantilever subclasses (Epitaxy,
Diffusion, Implantation, Poly, Piezoelectric).

The MATLAB design used a scaling vector plus five parallel hook methods
per subclass. The Python port replaces them with a single per-subclass
`optimization_state_vars()` returning a tuple of
`StateVar(name, scale, default_min, default_max, log_scale)`. The
optimizer assembles bounds, scaling, log-transform, and the apply/read
functions automatically. Handles the standard geometry+doping pattern,
the multi-layer Poly state vector, and the fully custom Piezoelectric
state vector through one mechanism.

What's new

  • `piezod.optimize_performance` -- multi-start with 1% convergence check
    and `max_iterations` cap, mirroring MATLAB's
    `numOptimizationIterations`.
  • `piezod.optimize_performance_from_current` -- single-shot from current
    state.
  • `OptimizationResult`, `StateVar`, `CantileverMetric`,
    `CantileverMetricConstraint` dataclasses/enums.
  • Named goal factories: `force_resolution_goal`,
    `displacement_resolution_goal`, `force_noise_density_goal`,
    `surface_stress_resolution_goal`, plus `voltage_` / `charge_`
    variants for piezoelectric.
  • `optimization_state_vars()` and `optimization_post_apply()` (where
    needed) on all five cantilever subclasses.
  • 51 new tests, runnable example at
    `python/examples/optimization.py`, rewritten optimization section in
    `docs/tutorial.md`.

Implementation notes

  • SciPy `SLSQP` is used when nonlinear constraints are present,
    `L-BFGS-B` when not. SLSQP often reports `success=False` even on
    usable solutions; the optimizer filters on objective finiteness
    instead.
  • MATLAB's always-on aspect-ratio constraints (`l/w >= 2`, `w/t >= 2`,
    `l_pr/w_pr >= 2`, `l_pr >= 2 um`) are added automatically when the
    cantilever exposes the relevant attributes. Disable via
    `default_aspect_constraints=False`.
  • `CantileverPiezoelectric` mirrors `l_si`/`w_si` onto `l_pe`/`w_pe`,
    and `CantileverPoly` enforces `t_bot == t_top` for symmetric
    two-piezoresistor layouts, both via `optimization_post_apply`.
  • `CantileverImplantation` reuses the existing `_DEFAULT_BOUNDS`
    table-aware bounds so the new optimizer respects whichever lookup
    table the cantilever was constructed with.
  • The existing
    `CantileverImplantation.optimize_doping_for_hooge_noise`
    (implant-process-only Hooge-noise optimizer) is left untouched; it now
    co-exists with the new generic optimizer.

Side fixes to pre-existing defects

Auditing while wiring up the optimizer surfaced several defects in the
cantilever module that the optimizer paths run into. Fixing them was
in-scope here:

  • `Cantilever.force_noise_density` raised on every input shape due to
    `johnson_PSD` / `thermo_PSD` requiring `freq.size`,
    `voltage_noise` calling `math.sqrt` on arrays, and
    `resonant_force_noise_density` returning a 1-element array. Fixed by
    promoting `freq` to `np.atleast_1d`, swapping to `np.sqrt`, and
    squeezing to a scalar.
  • `Cantilever.k_base` did `return k_x[0]` on a Python float. Fixed by
    returning `self.k_x()` directly, which unblocks `approxTempRise`,
    `approxTempRiseAnalytical`, and the `TEMP_TIP_APPROX` /
    `TEMP_MAX_APPROX` optimizer constraints.
  • `plot_noise_spectrum` used `math.sqrt` on PSD arrays. Switched to
    `np.sqrt` plus a squeeze for matplotlib.
  • `print_performance` raised on the "Force noise at 1 kHz" line
    ((1,1) array vs `:g` format) and the "Alpha" line
    (`self.alpha:g` formatted a bound method). Both fixed.

Known follow-up (not addressed here)

  • `Cantilever.calculateTempProfile` (and therefore
    `calculateMaxAndTipTemp`) has unported MATLAB-style 1-indexed loops
    and sequence-into-scalar slot assignments. Restoring the FD-based
    exact temperature solver requires a careful rewrite that would
    significantly expand this PR. `TEMP_TIP_EXACT` /
    `TEMP_MAX_EXACT` are removed from the `CantileverMetric` enum
    pending that work; the `APPROX` lumped-circuit variants remain and
    now work. The "F-D Temp Rises" line in `print_performance` is
    similarly dropped for now.

Test plan

  • `uvx ruff check .` -- clean
  • `uvx ruff format . --check` -- clean
  • `uv run pytest` -- 392 passed (341 existing + 51 new)
  • `uvx ty check src` -- clean
  • `python/examples/optimization.py` runs end-to-end and improves
    Harley-1999-style force resolution by several hundred times under
    power/frequency/stiffness constraints
  • `Cantilever.print_performance()` runs end-to-end on a default
    epitaxial cantilever (regression-tested)

jcdoll added 3 commits April 28, 2026 23:15
Port the MATLAB optimization framework (cantilever.m optimize_performance
and the per-subclass optimize_performance overrides on cantileverPoly /
cantileverPiezoelectric) to a single declarative Python module.

The MATLAB design used a scaling vector plus five parallel hook methods
per subclass (doping_optimization_scaling, doping_cantilever_from_state,
doping_current_state, doping_optimization_bounds,
doping_initial_conditions_random). The Python port replaces them with a
single per-subclass declarative method, optimization_state_vars(),
that returns a tuple of StateVar(name, scale, default_min, default_max,
log_scale). The optimizer then assembles bounds, scaling, log-transform,
and the apply/read functions automatically. This handles the standard
geometry+doping pattern (Epitaxy, Diffusion, Implantation), the
multi-layer Poly state vector, and the fully custom Piezoelectric state
vector through one mechanism.

Public API in piezod.optimization (re-exported from piezod):

  optimize_performance, optimize_performance_from_current,
  OptimizationResult, StateVar,
  CantileverMetric, CantileverMetricConstraint,
  force_resolution_goal, displacement_resolution_goal,
  surface_stress_resolution_goal,
  voltage_/charge_force_resolution_goal,
  voltage_/charge_displacement_resolution_goal.

Implementation notes:

- SciPy SLSQP is used when nonlinear constraints are present, L-BFGS-B
  when not. SLSQP often reports success=False on constrained problems
  even when it found a useful local minimum (the well-known "inequality
  constraints incompatible" behaviour); the optimizer filters on whether
  the objective is finite rather than result.success so good solutions
  are not discarded.
- MATLAB's always-on aspect-ratio constraints (l/w >= 2, w/t >= 2,
  l_pr/w_pr >= 2, l_pr >= 2 um) are added automatically when the
  cantilever exposes the relevant attributes. Disable with
  default_aspect_constraints=False.
- The shallow-copy + setattr apply path needs a hook for derived
  attributes. CantileverPiezoelectric mirrors l_si/w_si onto l_pe/w_pe;
  CantileverPoly enforces t_bot == t_top for symmetric two-piezoresistor
  layouts. Both are handled by overriding optimization_post_apply.
- CantileverImplantation reuses the existing _DEFAULT_BOUNDS table-aware
  bounds so the new optimizer respects whichever lookup table the
  cantilever was constructed with.
- The existing CantileverImplantation.optimize_doping_for_hooge_noise
  (implant-process-only optimizer with Hooge-noise default objective)
  is left untouched; it now coexists with the new generic optimizer
  rather than being refactored on top of it.

Out of scope and deliberately not included:

- force_noise_density_goal: the underlying force_noise_density /
  voltage_noise methods have a pre-existing bug (math.sqrt of an array)
  that affects callers from this PR. Skipping the goal until that is
  fixed in a separate PR.

Tests: 43 new tests in tests/test_optimization.py covering state
machinery, per-subclass state-var declarations, every named goal,
constraint validation, end-to-end optimization on all five subclasses,
multi-start reproducibility with seeded RNG, parameter and metric
constraint enforcement, default aspect-ratio constraints, custom
objective callables, and immutability of the input cantilever.

Example: python/examples/optimization.py mirrors the MATLAB tutorial's
Harley-1999-style optimization flow and improves force resolution from
~115 pN to ~0.24 pN under power, frequency, and stiffness constraints.

Tutorial: the optimization section of docs/tutorial.md is rewritten
with the actual API.
…_goal

Three layered defects made Cantilever.force_noise_density raise on every
input shape, blocking the MATLAB-equivalent goalForceNoiseDensity from
being ported in this PR:

1. johnson_PSD and thermo_PSD ended with `... * np.ones((1, freq.size))`,
   broadcasting a constant across every frequency bin. A Python float has
   no `.size`, so passing a scalar raised AttributeError before the
   broadcast even ran.

2. voltage_noise summed the four PSD components and applied math.sqrt.
   math.sqrt only accepts a scalar (or 0-d array), so any 1-D input
   raised TypeError("only 0-dimensional arrays can be converted to Python
   scalars").

3. resonant_force_noise_density passed a Python scalar straight into
   force_noise_density and returned a 1-element array instead of a
   scalar.

Together: force_noise_density(scalar) failed at #1, force_noise_density
(array) failed at #2, and resonant_force_noise_density failed at both #1
and #3. f_min_cumulative (which calls voltage_noise on a logspace array)
and the "Force noise at 1 kHz" line in print_performance were also
silently broken; neither has test coverage.

Fix:

- johnson_PSD and thermo_PSD now promote freq via
  `np.atleast_1d(np.asarray(freq, dtype=float))` so scalar input is
  accepted. The (1, N) output shape is unchanged for any caller that
  was relying on it.
- voltage_noise switches math.sqrt -> np.sqrt, which handles scalar,
  0-d, 1-d, and N-d inputs uniformly.
- resonant_force_noise_density wraps the damped frequency in
  np.atleast_1d, then squeezes the (1,1) result back to a Python float.

The fix also unblocks the existing f_min_cumulative and
print_performance code paths, which were already broken; these are
strictly additive, not regressions.

With the noise chain fixed, the previously-omitted
force_noise_density_goal is restored. It evaluates
force_noise_density at the damped resonance and applies the MATLAB
1e12 (pN/sqrt(Hz)) scaling. Two new tests cover the goal: one asserts
finite scalar output with the right unit relationship to
resonant_force_noise_density (factor of 1e-3, fN -> pN), and one drives
end-to-end optimization with this goal under a power-dissipation
constraint to confirm it improves.
… temp metrics

While auditing the new optimization paths I confirmed three more
pre-existing defects that affected this PR's surface:

1. k_base() did `return k_x[0]` on a Python float. The "approx"
   branch of k_x() returns a scalar; the "exact" branch returns a
   numpy scalar. Indexing either with [0] raises. The legacy bracket
   was a MATLAB-port leftover. Fix: return self.k_x() directly.
   Unblocks approxTempRise, approxTempRiseAnalytical, and through
   them the TEMP_TIP_APPROX / TEMP_MAX_APPROX optimizer constraints.

2. plot_noise_spectrum used math.sqrt on the four PSD arrays (same
   pattern as the voltage_noise fix). Switched to np.sqrt and
   squeezed the (1, N) outputs of johnson_PSD/thermo_PSD to (N,) so
   matplotlib gets matched 1-D arrays.

3. print_performance() raised in two places: the "Force noise at
   1 kHz" line dropped a (1,1) array into a `:g` format spec, and
   the "Alpha" line formatted a bound method instead of calling it.
   Both now squeeze/call to a scalar before formatting.

The FD-based exact temperature solver
(calculateMaxAndTipTemp / calculateTempProfile) has additional
unported MATLAB-isms (1-indexed loops, sequence-into-scalar slot
assignments) and would need a substantial rewrite. Out of scope here.
Consequently this commit also drops TEMP_TIP_EXACT and TEMP_MAX_EXACT
from the public CantileverMetric enum -- they would dispatch to
calculateMaxAndTipTemp and never produce a finite value. The
TEMP_TIP_APPROX and TEMP_MAX_APPROX variants remain and now actually
work; the docstring on CantileverMetric notes this. The "F-D Temp
Rises" line in print_performance is also dropped pending an FD-solver
port; the lumped-circuit "Approx. Temp Rises" line still prints.

Six new tests in TestNoiseChainAndPrintPerformance cover voltage_noise
on scalar+array input, resonant_force_noise_density returning a
Python float, k_base returning a finite scalar, approxTempRise
returning finite numbers, print_performance running end-to-end with
output spot-checks, and optimize_performance_from_current accepting a
TEMP_MAX_APPROX constraint and respecting it on the optimized
cantilever.
@jcdoll jcdoll merged commit 2a4425e 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