Skip to content

feat(fit): the Fit wizard — build a model, fit the scan, commit component maps - #86

Merged
CSSFrancis merged 33 commits into
feat/0.3.0from
feat/0.3.0-fit-wizard
Jul 28, 2026
Merged

feat(fit): the Fit wizard — build a model, fit the scan, commit component maps#86
CSSFrancis merged 33 commits into
feat/0.3.0from
feat/0.3.0-fit-wizard

Conversation

@CSSFrancis

Copy link
Copy Markdown
Owner

Closes #55, #56, #58. Part of tracker #48.

The first UI on top of spyde.fitting. Stacked on #84 (needs ModelSpec + the engine).

Shape

Two tabs, matching the Orientation caret: Model builds the model component by component, Run fits it and commits the maps. Layout is horizontal by preference — a component's parameters sit side by side, so the caret grows wider rather than taller.

Components are added from a + Component popup rather than an always-open palette.

#56 — the picker shows shapes, not names

The backend samples every offerable component at defaults over the current signal axis and sends a normalised polyline, drawn as an inline sparkline. "Gaussian" and "Lorentzian" aren't distinguishable by name to someone who hasn't met them.

Seeding matters here: a default Gaussian sits at 0 with σ=1, which is off-screen on a 200–800 eV axis — every peak shape would otherwise preview as an identical flat line.

#58 — component maps

Commit ships one integrated-area map per component through commit_result_tree, which already gives the strain-style toggle (click one, ⌘-click to tile). No new display code.

Two real bugs only running the app could find

Both handler suites pass either way — this is the case CLAUDE.md is about.

  1. A click on a caret control was silently lost. Capture-phase listeners showed mousedown -> the button's span, mouseup -> mdi-area: the mousedown bubbled to the window, re-rendered the subwindow subtree, and replaced the control between down and up — so React never saw a click. The add simply didn't happen, with no error anywhere. Fixed by keeping mousedown inside the caret.

    dispatchEvent worked throughout, which is precisely what makes this look like a test problem rather than a UI one. It isn't: a user clicking that button gets nothing.

  2. emit was imported as from ipc import emit, which binds the original at import time. The test fixture patches ipc.emit, so every message assertion would have silently observed nothing. Imported as a module now.

Verification

  • 26 backend handler tests, including the StrictMode open/close/open contract (two live controllers means two of everything downstream).
  • fit_wizard.spec.ts drives the real app end to end — toolbar button, palette sparklines, model building, a parameter edit echoed back from the backend, the fit, teardown/reopen, and Commit opening a window with a chip per component. Screenshots at every stage, and I looked at them.

Worth your call

Closing the caret disposes the fit. The wizard owns the result, so close/reopen discards it and Commit is no longer offered. Consistent with the other wizards, but a fit is more expensive than most wizard state — happy to persist it on the tree instead if you'd prefer.

Still open in Wave 1's UI: #57 (drag components directly on the plot — the anyplotlib interactive-fitting port).

Five waves, each independently shippable behind its own optional extra.
Tracked in #48 with a sub-issue per task.

Decisions recorded up front, because each one shapes the code:

- spyde/models/ is already the neural disk detector, so the HyperSpy model
  port lives in spyde/fitting/. Unrelated meanings of "model".
- exspy/kikuchipy/atomap ship as optional extras gated by a new
  requires_package key, mirroring requires_vectors.
- The fitting engine is a batched GPU Levenberg-Marquardt over the whole nav
  grid (the vector_orientation_gpu.py playbook), with SAMFire's
  neighbour-seeding kept as the seed source rather than the scheduler.

Measured baseline that motivates it: multifit on 1024 spectra x 1024 channels
runs 10.83 s = 95 spectra/s single-threaded, which extrapolates to 11.6 min
for a 256x256 SI. Parity with multifit is the acceptance test, not
convergence.

Also found while measuring: numexpr is not installed, so every hyperspy
Expression component silently falls back to numpy.
Waves 1-4 need something loadable per modality from day one, before the real
datasets land (#80). These are also the better thing to test against: the
ground truth is known exactly, so a fit or an indexing run is scored against
the numbers the data was built from rather than a golden file that quietly
encodes yesterday's bug.

  eels_si       power law + C/N/O K edges at real onsets, intensities
                following known concentration maps
  eds_si        bremsstrahlung + Fe/Ni/Cu K families at real energies, with
                Fe-Kb/Ni-Ka overlapping ON PURPOSE so family-aware fitting is
                actually exercised rather than assumed
  ebsd_patterns real gnomonic Kikuchi-band geometry for a cubic crystal over a
                two-grain orientation field; the exact Euler angles are stamped
                on the signal, so dictionary indexing can be checked against
                truth instead of against a fixture of its own output

Every spatial map is distinguishable from its transpose and both mirrors, so a
flipped axis fails a test instead of looking fine — the si_grains/movie
precedent.

Ground truth is read with spyde.data.ground_truth(), not straight off the
metadata: hyperspy turns nested metadata dicts into DictionaryTreeBrowser
nodes, which have no .values().

None of this needs exspy or kikuchipy. set_signal_type is attempted and its
"not understood" warning demoted to debug, so a user without the extras gets
usable data and no scary warning.

Reachable as load_test_data_eels / _eds / _ebsd.
Closes the framework half of #49.

requires_package: hides a toolbar action until its optional extra is
importable, mirroring requires_vectors — so a user without spyde[eels] never
sees a button that would raise ImportError on click. Resolved with find_spec,
so it never imports the package and never pays its import cost; cached, since
an extra cannot appear mid-session.

Applied in BOTH filter paths. get_toolbar_actions_for_plot and
_action_matches_plot duplicate the gate list, and adding a key to one renders a
button that never dispatches. A test asserts both contain it.

Extras: eels (exspy), ebsd (kikuchipy), atoms (atomap), all. Deliberately not
core deps — each pulls a large tree and the frozen app must stay installable
for someone who only does 4D-STEM. A CI job installs spyde[all] so the gated
code actually executes; the default job proves the app still works with none of
it.

NOT adding numexpr, and pyproject now says why. Hyperspy warns "Numexpr is not
installed ... which is slower to calculate model" on every model build, but at
spectrum sizes that advice is backwards. Measured on a 1024-channel multifit:

    absent          110 spectra/s
    numexpr,  1 thr  77
    numexpr,  4 thr  75
    numexpr, 48 thr  70

numexpr's per-call setup and thread dispatch dominate arrays of ~1k elements.
The warning will keep inviting someone to add it, hence the comment.
Ports hyperspy's model concept and makes it fast. #51, #52, #53.

Lives in spyde/fitting/, NOT spyde/models/ — that name is already the neural
disk detector. Unrelated meanings of "model".

  spec.py        ModelSpec: serialisable, round-trips against hyperspy's
                 BaseModel.as_dictionary(), so a model built here opens in
                 plain hyperspy. Packed parameter order IS the API — column j
                 always means the same parameter.
  components.py  batched autograd-differentiable ports of hyperspy's
                 components; no Python loop over pixels anywhere
  engine.py      batched Levenberg-Marquardt over the whole nav grid

Why it works: n (free parameters) is tiny even when P and C are large, so the
normal equations are (P, n, n) — a batch of small solves torch.linalg does
natively. The one large object is the (P, C, n) Jacobian, so P is chunked.
Jacobian by jacfwd not jacrev: forward-mode costs one pass per INPUT (n~10),
reverse one per OUTPUT (C~2000).

One implementation, not two. The "CPU fallback" is this code with
device="cpu"; a separate scipy path would be a second thing to keep in
agreement with hyperspy, and hyperspy already IS the reference — the
acceptance test asserts parameter parity with multifit directly, because "it
converged" proves nothing when you are replacing a reference implementation.

Measured, 1024 spectra x 1024 channels, PowerLaw + 3 Erf (11 free params):

    hyperspy multifit   214.80 s     4.8 spectra/s
    batched cpu          27.02 s    37.9 spectra/s    7.9x
    batched cuda          3.97 s   257.8 spectra/s   54.1x

Two numerical traps found by measurement, both documented at the code:

- Column scaling is load-bearing, not a refinement. A PowerLaw fits A ~ 1e6
  alongside r ~ 3, so cond(JtJ) ~ 1e12 and float64 Cholesky loses nearly every
  digit in the small-eigenvalue direction. Unscaled: 247 iterations for a TWO
  parameter fit, stopping 1.5-3.5% short of multifit. Scaled: 46 iterations to
  chisq ~1e-34.
- Convergence must be a GRADIENT test. Testing step size alone reads "stuck
  against a wall with large lambda" as converged; requiring an ACCEPTED step
  instead never fires at all, because at the optimum nothing improves.

Convergence on hard many-parameter models is still seed-limited (hyperspy hits
maxfev on the same model) — that is what seeded propagation (#54) is for.
Answers "is PyTorch really the right way to fit?" — the honest answer was
"yes as a vehicle, no as I was using it". Profiling the solver:

    residual evaluation    3.4 ms
    jacfwd Jacobian       51.6 ms      <- 94% of the solver

Forward-mode AD costs one forward pass per parameter, and these components have
closed-form derivatives that mostly reuse the value already computed
(df/dA IS the shape for every linear amplitude). So components now carry
analytic gradients and the engine builds the Jacobian directly; autodiff stays
as the fallback for any component without one.

    batched cpu    37.9 -> 105.9 spectra/s   (2.8x)
    batched cuda  257.8 -> 361.6 spectra/s   (1.4x)

The GPU gains less because it is now dispatch-bound, not compute-bound — the
remaining lever there is kernel fusion, not more math.

Also: the chunk heuristic was starving the GPU. Capping the Jacobian at 2**22
elements chopped a 1024-spectrum batch into pieces of 372 and cost a third of
the throughput (178 vs 267 spectra/s). Raised to 2**26 so typical scans run
unchunked.

Correctness is unchanged and still defined by hyperspy: every analytic gradient
is asserted equal to autodiff's, which is the ideal oracle since it is derived
mechanically from the value function and cannot share a mistake with a
hand-written formula.

seeding.py implements #54 — coarse strided fit, propagate to nearest neighbour,
one batched refine. SAMFire's insight (a neighbour's result is the best start)
without SAMFire's per-pixel scheduler. Only CONVERGED coarse fits propagate: a
failed one has wandered somewhere unphysical and seeding from it spreads the
failure rather than the answer.

Two benchmark corrections, both of which had been quietly measuring the wrong
thing:

- With exspy installed, create_model() returns an EELSModel that ALREADY has a
  background, so the reference model had TWO PowerLaws while the batched side
  had one. Both sides must fit the same model or the comparison means nothing.
- The EELS benchmark model cannot represent a hydrogenic edge, so neither
  engine converges and it was timing the iteration cap. Added a well-posed EDS
  case (gaussian peaks, which the library expresses exactly) as the default;
  EELS stays as the deliberately-hard timing-only case, documented as such.

On the well-posed case: 57.6% converged, 361 spectra/s, 11.6x multifit.
Gaussian2D through the SAME engine, not a second code path. An image is
flattened to H*W sample points exactly as a spectrum is C channels; the only
difference is that the "axis" carries (x, y) PAIRS instead of scalars, handled
by one branch in TorchComponent._split. Packing, the Jacobian, bounds, fixed
parameters and the LM solve are all the 1-D machinery unchanged — and the tests
assert that rather than assume it (bounds and fixed-parameter tests are
repeated in 2-D precisely because a different shaping path is where they would
break).

image_coordinates() builds the coordinate grid, row-major so it lines up with
a .ravel()'d image.

The test image is deliberately NON-SQUARE (24x28): a transposed coordinate grid
would run happily on a square one and put every peak in the wrong place.

As in 1-D, A is the VOLUME under the surface rather than the peak height —
transcribed from hyperspy's expression, with parity, batching and
analytic-vs-autodiff gradient tests to keep it honest.

This also unblocks Wave 4: refining atom positions IS a batched 2-D gaussian
fit (#77).
Both shaped their output with `expand_as` / `expand`, which forces the
parameter block's P down to the axis's leading 1 and raises for P > 1:

    RuntimeError: The expanded size of the tensor (1) must match the
    existing size (4) at non-singleton dimension 0

Plain broadcasting (`p + 0.0 * x`) gives (P, C) for both a shared axis and a
per-position one.

Found by fitting a real EDS composition model, whose background is a
Polynomial — not by the component tests, because every one of them used a
SINGLE spectrum, where expand_as happens to work. That is the actual defect
here: a batched component library whose tests never batch.

Closed with TestEveryComponentBatches, which runs every component (and
Polynomial) with P=4 and asserts the value shape, the gradient shape, and that
row 1 reflects ITS OWN parameters — a broadcasting slip that returns row 0 for
everything would still have the right shape.
Wave 2's headline: type which elements are present, get a populated model.

exspy owns the spectroscopy — edge and line energies, GOS edge shapes, family
relationships — because reimplementing it would mean diverging from the numbers
the community publishes. This module owns the plumbing:

- one call for EELS and EDS alike returning a ModelSpec, so the result feeds the
  batched engine, the wizard and the renderer through one type;
- pruning to the measured range. A component with no data under it is NOT
  harmless: its amplitude is unconstrained, so the optimiser trades it against
  everything else and degrades the parameters that ARE measurable. exspy will
  happily add a Cu-L line at 0.93 keV to a spectrum whose useful range starts
  at 2 keV;
- an honest report of whether the batched engine can fit the result.

That last point is asymmetric today and worth knowing:

  EDS   -> Polynomial + Gaussian  -> fully supported, gets the GPU path NOW
  EELS  -> EELSCLEdge (GOS table) -> no batched port yet (#63), falls back to
                                     hyperspy, which is correct but slower

Reported in the returned info rather than left as a surprise.

Two exspy behaviours that do not do what their names suggest:

- `add_lines` APPENDS, so restricting needs `set_lines`.
- Even then, the EDS model expands each ELEMENT into its whole family
  (selecting Fe_Ka still builds Fe_Kb, Fe_La, Fe_Ln, ...) regardless of
  metadata.Sample.xray_lines — so honouring only_lines means filtering the
  built spec by name.

The source signal is never mutated; a user may be exploring several
compositions.
Two bugs of the same shape, found by round-tripping a composition model.
to_model() rebuilt every component BARE, which breaks for anything that takes
constructor arguments:

- EELSCLEdge raises outright (element_subshell is required), so no EELS model
  could be turned back into hyperspy at all.
- Polynomial is worse because it does NOT raise. Rebuilt bare it comes back at
  its DEFAULT order, so exspy's order-6 EDS background silently lost a3..a6 —
  to_model skipped the parameters that no longer existed and returned a
  different model that still fits and still looks plausible.

ComponentSpec now carries init_args, captured by from_model via a small
_INIT_ARGS table and used when rebuilding. An unrebuildable component gets an
error naming the table to add to, rather than a bare TypeError from inside
hyperspy.

The original round-trip tests missed both because they only ever went
model -> spec, never spec -> model with a component of this kind.

Fixing that surfaced a third: EELSCLEdge.fine_structure_coeff is a VECTOR
parameter with 8 elements, and ParameterSpec stored a single float, so
hyperspy rejected the assignment on length. Vector parameters now round-trip
faithfully (value becomes a list) but are excluded from the PACKED vector via
ComponentSpec.scalar_parameters — the solver puts one scalar per column, and no
component with such a parameter is fittable by the batched engine anyway
(components.supports already says no). components.py uses the same scalar view
so the two cannot drift apart.
…oo (#63)

An EELS core-loss edge is not a formula — EELSCLEdge looks its shape up in a
GOS table, which is why it had no batched port and why an EELS model fell back
to hyperspy's one-pixel-at-a-time fitting while an EDS model (gaussians) got
the GPU.

The way out is to notice what is actually being fitted. Across a spectrum image
the edge SHAPE is the same everywhere — atomic physics, fixed by the element and
the microscope. What varies pixel to pixel is HOW MUCH of the element there is
and exactly WHERE its edge starts. So the edge is sampled once on the signal
axis and replaced by a tabulated component fitting intensity (linear) and
onset_shift, batched like everything else.

The approximation, stated rather than buried: fine structure and effective
angle are FROZEN at the values the table was sampled with. They are what make
an edge a lookup in the first place, and refitting them would mean re-sampling
the table every iteration — the per-item Python work the batched engine exists
to avoid. tabulate_model() reports what it froze. Right trade for
quantification; wrong one for fine-structure analysis, which is why it is an
explicit call and not automatic.

Two deliberate choices in the component:

- Outside the table the value HOLDS at the end sample rather than
  extrapolating. Extrapolating a GOS tail would invent signal where the
  measurement has none.
- The onset-shift derivative is a central difference over one channel, not the
  exact piecewise-linear slope. The exact one is a step function that jumps at
  every segment boundary, so LM chatters as the shift crosses a channel. It
  differs from autodiff by ~1.5% of the gradient's own scale, only within a
  channel of a kink, and the test states that as the property rather than
  asserting an equality that is not true.

Tested end to end: the table reproduces hyperspy's edge to 1e-9, a fit recovers
both a known intensity and a known 12 eV onset shift, and on the synthetic SI
the fitted O_K intensity tracks the concentration map the data was built from.
Two steps, kept separate because they fail differently.

element_intensity_maps() is the BRIDGE: fitted parameters -> one intensity map
per element, reading the component names the model was built with (Fe_Ka, O_K)
and summing a family's lines. Pure array work, no physics, no optional extra.
One bridge serves EDS and EELS because the naming shape is the same and the
intensity parameter is looked up per component kind (Gaussian's A,
TabulatedShape's intensity) — otherwise the EELS path silently produces no maps.

quantify() is the PHYSICS: relative, Cliff-Lorimer, or EELS cross-sections.

The split matters because the bridge is where a naming or ordering mistake
sends iron's intensity into copper's map, and that is SILENT — every downstream
number stays plausible. A wrong k-factor is at least a number a microscopist
can sanity-check. They deserve separate tests, and the bridge gets more of them.

Three things that would otherwise be quiet:

- Negative fitted intensities are clamped at SOURCE. A fit can drive a line
  slightly negative on noise, and a negative "amount of an element" is not just
  unphysical for that element — it corrupts the normalisation for every other
  one.
- Missing k-factors default to 1.0 and are REPORTED, because defaulting
  silently makes an uncalibrated number look calibrated.
- The result records that composition is RELATIVE to the fitted elements.
  Cliff-Lorimer cannot know about an element you did not fit, so leaving one
  out does not cause a small error — it redistributes that element's share
  across everything else.

End to end on the synthetic EDS SI: composition -> model -> batched fit ->
quantify recovers the generator's concentration maps at r > 0.9 per element.
"Quantification works" means it recovers what we put in, not that the numbers
sum to 1.
…55, #56, #58)

The first UI on top of spyde.fitting. Two tabs, matching the Orientation
caret's shape: Model builds the model component by component, Run fits it and
commits the maps. Layout is horizontal by preference — a component's parameters
sit side by side so the caret grows wider rather than taller.

#56, the picker: components are added from a "+ Component" POPUP that shows
each one's SHAPE, not just its name. The backend samples every offerable
component at defaults over the CURRENT signal axis and sends a normalised
polyline the renderer draws as a sparkline. Seeding matters — a default
Gaussian sits at 0 with sigma 1, which is off-screen on a 200-800 eV axis, so
every peak shape would otherwise preview as an identical flat line.

#58, the maps: Commit ships one integrated-area map per component through
commit_result_tree, which already gives the strain-style toggle (click one,
cmd-click to tile). No new display code.

The preview is ONE spectrum, not the grid: a model edit has to feel instant,
and fitting 65k positions per keystroke would not. fit_run is the only thing
that touches the whole scan.

The caret rebuilds itself from `fit_state` and never edits a local copy — the
backend owns the model, and a caret that mutated its own would drift out of
step the moment an edit was rejected, then show a model that is not the one
being fitted.

TWO REAL BUGS that only running the app could find; both handler-test suites
pass either way:

1. A click on a caret control was SILENTLY LOST. Capture-phase listeners showed
   `mousedown -> the button's span, mouseup -> mdi-area`: the mousedown bubbled
   to the window, re-rendered the subwindow subtree, and replaced the control
   between down and up, so React never saw a click. The add simply did not
   happen, with no error anywhere. Fixed by keeping mousedown inside the caret.
   (dispatchEvent worked throughout, which is exactly what makes this look like
   a test problem rather than a UI one.)
2. `emit` was imported as `from ipc import emit`, which binds the original at
   import time — the test fixture patches `ipc.emit`, so every message
   assertion would have silently observed nothing. Imported as a module now.

Backend: 26 handler tests (including the StrictMode open/close/open contract).
UI: fit_wizard.spec.ts drives the real app end to end — toolbar button, palette
sparklines, model building, a parameter edit echoed back from the backend, the
fit, teardown/reopen, and Commit opening a window with a chip per component.
Screenshots at every stage.
#57 — every positioned component gets on-plot handles: a POINT handle driving
its centre and height, and a RANGE band driving its width. Dragging writes
straight back into the model and redraws, and the numeric fields refresh from
the same state, so both directions agree. Ported from anyplotlib's
interactive-fitting example.

The conversions are the substance. For most components the fitted amplitude is
an AREA, not a height, so storing a dragged y straight into `A` would jump the
curve by sigma*sqrt(2*pi) the instant it was touched. A width drag likewise
holds the peak HEIGHT fixed while changing sigma — otherwise the curve moves
away under the cursor, which reads as it fighting the drag.

PERSISTENCE: the model and the fit now live on the TREE (`tree.fit_spec`,
`tree.fit_result`), not on the caret controller. They are results, and the
ownership map in actions/README.md §3 puts results on the tree. A model costs
real effort to build and a fit costs minutes; closing the caret to get it out of
the way must not discard either. tree.close() still disposes both.

THREE MORE BUGS THE RUNNING APP FOUND, all invisible to the handler tests:

1. The live preview NEVER DREW. anyplotlib's add_line takes `label`, not
   `name`, and the TypeError was swallowed by this method's own except. The
   previous spec passed end to end while the plot showed nothing — I only
   caught it by looking at the screenshot.
2. Preview lines ACCUMULATED: remove_line takes an id or a Line1D handle, not
   a label, so every redraw stacked another line and the legend filled with
   `fit_preview` entries. The handle is kept now.
3. A new component arrived with amplitude 1 against counts of 1e5 — the curve
   was a flat line on the axis, the handles sat at y=0, and the fit started
   five orders of magnitude from its answer. All three read as "the model does
   nothing". scale_to_data() now sets the linear amplitude from the spectrum
   itself, generically: evaluate at unit amplitude, scale the peak to a
   fraction of the data's range.

39 backend tests; the e2e now also asserts the model survives close/reopen, and
its screenshot is where the preview curve and the handles are actually checked
— nothing else in the suite can see them.
@CSSFrancis

Copy link
Copy Markdown
Owner Author

#57 drag handles + a persistent model

#57 — every positioned component now gets on-plot handles: a point handle driving its centre and height, and a range band driving its width. Dragging writes straight back into the model and redraws; the numeric fields refresh from the same state, so both directions agree. Ported from anyplotlib's interactive-fitting example.

The conversions are the substance. For most components the fitted amplitude is an area, not a height — storing a dragged y straight into A would jump the curve by σ√2π the instant it was touched. A width drag likewise holds the peak height fixed while changing σ; otherwise the curve moves away under the cursor, which reads as it fighting the drag.

Persistence — as you asked. The model and the fit now live on the tree (tree.fit_spec, tree.fit_result), not the caret controller. They're results, and the ownership map in actions/README.md §3 puts results on the tree. tree.close() still disposes both. The e2e asserts a model survives close/reopen with its edited values intact.

Three more bugs the running app found

All invisible to the handler tests, and the first is the one worth dwelling on:

  1. The live preview never drew. add_line takes label, not name, and the TypeError was swallowed by the method's own except. The previous spec passed end to end while the plot showed nothing — I only caught it by looking at the screenshot. That's exactly the failure mode CLAUDE.md warns about, and I'd shipped it in the last commit.
  2. Preview lines accumulated. remove_line takes an id or a Line1D handle, not a label, so every redraw stacked another line and the legend filled with fit_preview entries. Visible in the screenshot as three identical legend rows.
  3. New components arrived at amplitude 1 against counts of 1e5 — flat curve on the axis, handles at y=0, and the fit starting five orders of magnitude from its answer. scale_to_data() now sets the linear amplitude from the spectrum itself, generically: evaluate at unit amplitude, scale the peak to a fraction of the data's range.

Verified

39 backend tests, and the e2e screenshot now shows what it should: one fit_preview entry, the model curve at data scale (PowerLaw decay + a Gaussian at 532 eV sitting on the real spectrum), and both drag handles live on the plot.

Wave 1's UI is complete: #55, #56, #57, #58.

…background

Three things, two of them from using it.

"FIT SPECTRUM" (requested): fits only the spectrum on screen and writes the
result back into the model, so the next nudge starts from a fitted position.
Building a model is a loop — place a component, look, adjust — and fitting the
whole scan to check one guess is the wrong unit of work: it costs seconds to
minutes and answers a question about one pixel. Same engine, same spec, one row
of data. It deliberately does NOT set a scan result: one spectrum is not a map,
and offering Commit after it would export a component map built from a single
pixel repeated everywhere. The other button is now "Run scan", so the two read
as what they are.

HANDLES MOVE IN PLACE instead of being rebuilt (the "gaussians don't move"
report). fit_set_param used to call sync_widgets(), which tears down and
recreates every widget — on every keystroke, several times a second. That makes
the handles flicker and can pull one out from under the cursor mid-reach.
update_widgets() now moves them via the widget's own set(), and rebuilding is
reserved for a changed component LIST. A drag skips the handle being dragged,
because writing a position back to the widget under the user's finger fights
the drag.

I could not reproduce a parameter edit failing to move the CURVE — in the app,
typing a new centre moves both curve and handles — so the churn above is my
best explanation for what was seen. If it persists, which parameter and how
many components would narrow it.

#65: fit_from_composition — type the elements, get a populated model, then drag
the lines. EELS models are tabulated on the way in (#63), or the model is
correct but falls back to hyperspy's per-pixel fitting, which is the difference
between seconds and minutes on a real scan.

#64: background_action — drag a window over a background-only region; the model
is fitted to THAT SPAN ONLY and extrapolated across the whole axis, which is
the entire point of a background model (it says what the background would have
been under the peaks, where it cannot be measured). Fitted per navigation
position, because a spectrum image has a different background per pixel and
subtracting one pixel's fit everywhere would imprint a thickness map on the
result. Seeded from the data INSIDE the window: a pre-edge window sits far
below the peak, so scaling to the global max starts it an order of magnitude
high.

46 backend tests; the e2e passes end to end.
…erly

The drag did nothing to the model, and the reason is embarrassing: anyplotlib
hands the dragged widget back on `event.source`, so the position is
`event.source.x`. I read `event.x`, which is None — so every drag silently
updated nothing while the handle still moved on screen, because the widget
draws itself. That is exactly why it looked like "the curves don't move".

Rewritten to follow the interactive-fitting example rather than approximate it:

- Lines are updated with Line1D.set_data IN PLACE. Removing and re-adding a
  line per drag frame is heavy and does not repaint mid-drag.
- ONE LINE PER COMPONENT plus a dashed sum, instead of a single summed curve.
  With several overlapping peaks, a sum tells you the total is wrong but not
  which component to grab.
- The `_syncing` guard, because moving the handles in response to a drag
  re-enters the handler and the widget and model chase each other.
- The range widget uses style="fwhm" at y=height/2, and the point widget drops
  its crosshair — the example's geometry, which reads as a peak handle and a
  width band rather than two unrelated markers.

Verified with a REAL pointer drag in the app, not a synthesised event: grabbing
the handle and dragging left moves centre 500 -> 296 eV, and the curve, both
handles and the caret's fields all follow. A screenshot confirms it.

"Fit spectrum" moved to the Model tab (requested). That is where the loop is —
add, look, nudge, fit this one, look again. Run scan stays on its own tab as
the separate expensive thing you do once the model is right.

The drag test now pins the `event.source` contract directly, so this specific
mistake cannot come back silently.
…d spectrum

Reported as "Fit spectrum doesn't work at all", and the way it failed is worse
than not working: it said CONVERGED and drew a model at about half the height
of the data.

current_spectrum() reconstructed the on-screen spectrum from signal.data plus a
navigator index. The index was not where it expected, so it fell through to the
mean over navigation — and a fit against the mean converges perfectly well. The
status was true; the answer was about a spectrum nobody was looking at.

plot.current_data is the authority: it IS the array the plot is displaying,
already resolved through whatever navigator, region-integration or derived-view
path produced it. Read that. The navigation mean survives only as a stand-in
for the PREVIEW before the first frame lands, and it logs when it is used.

Why nothing caught this: the handler tests build their own signal and never
have a navigator, so the fallback WAS the displayed spectrum for them. It
needed the real app, a real navigator position, and an assertion about the
fitted VALUES rather than about a status string.

fit_two_gaussians.spec.ts is that test, on the dataset this is meant for
(tutorial_spectroscopy = hyperspy's two_gaussians). It asserts the model's peak
height, computed from the fitted parameters — a HyperSpy Gaussian's A is an
AREA, so the height is A/(sigma*sqrt(2*pi)) summed over components. Fitting the
navigation mean gave ~460 against data peaking at ~1050; the fix gives ~1050.
The threshold separates those two outcomes without pinning an exact fitted
value, and a second assertion stops a degenerate fit that zeroes one component
from passing on the other's back.

Four backend tests pin the same contract directly, including that a painted
spectrum always beats the mean and that a stale/odd-shaped current_data (a dask
Future mid-transition) is ignored rather than fitted.
… icon

PER-POSITION MEMORY. Each navigator position now remembers its own fitted
parameters, on the tree beside the model. Scrubbing back to a pixel shows what
was found THERE rather than whatever the last pixel left behind. The store is
sparse — a dict keyed by position, not a (P, n) array — because it fills in as
the user explores, and a full allocation would claim every position had been
fitted. It is cleared whenever the component list changes: the vectors are
POSITIONAL, so after an add or remove a stored sigma would arrive as an
amplitude and nothing would look obviously wrong.

ADAPTIVE FITTING (an "Adaptive" toggle). Moving the navigator recalls a stored
fit if there is one, and otherwise — with adaptive on — fits the new spectrum
seeded from the model as it stands, which is a neighbouring position's answer
and therefore a good starting point (the same reason seeded propagation works
for a whole scan, #54). Recall wins over refit: same computation, already done.
Driven from pointer_UP on the navigator, not pointer_move, or it would queue
fits faster than they complete.

This also fixes the thing that made the earlier "Fit spectrum" bug possible:
current_indices lives on the navigation SELECTOR, not on the Plot. Reading it
off the Plot always returned None. There is now a real accessor and a test that
exercises it against a REAL session rather than only a fake.

SMOOTHER DRAGS. pointer_move and pointer_up are wired to separate handlers
because they should do different amounts of work. A move now redraws the curve
and nothing else; moving the partner handle and re-sending the whole model both
cross the IPC boundary, and doing either at pointer rate is what made the curve
lag the cursor. Both happen once, on release.

Remove Background gets its OWN icon — it was borrowing Fit's, so the two
buttons were indistinguishable. Drawn for 20 px rather than for the artboard:
only about three strokes survive at button size, so it spends them on a
straight baseline and a down-arrow, a different silhouette from Fit's peak. The
first attempt was a dashed background curve — handsome at 150 px, mush at 20,
which was the whole complaint. Checked by rendering at 20/28/150 px on the
toolbar's own background before committing.

66 backend tests, both e2e specs green.
THE DRAG REGRESSION WAS MINE. Splitting the preview into one line per component
went from ONE line update per pointer frame to N+1, and Line1D.set_data is not
cheap bookkeeping: it recomputes the axis range and pushes the WHOLE plot state
to the renderer. Two gaussians therefore meant three full state pushes per
frame where there had been one. That is why it got worse after the last change,
and the "smoothness" work in that commit did not touch it.

refresh_lines now writes every line's data and pushes ONCE, so it is one push
per frame regardless of component count — fewer than before the per-component
rewrite, not merely back to parity. It reaches into anyplotlib's line entries
because there is no public batched update; the public per-line path stays as the
fallback, so an upstream change costs speed rather than correctness.

FIT COVERAGE, so the store is visible: the caret shows "N/M fitted" with a
filled marker when the CURRENT position has an answer. Without it there is no
way to tell a position you skipped from one you fitted, and "why didn't adaptive
fill this in" has no answer on screen.

WHAT I COULD NOT MEASURE, stated plainly. I tried to quantify drag smoothness
through Playwright and produced a number that was mostly harness: a bare
page.mouse.move costs ~16 ms here, so 30 synthetic moves take ~600 ms with NO
caret open at all, against ~850 ms with one. My earlier "~28 ms per move" was
therefore ~70% Playwright. On that bad number I added a redraw coalescer; it
showed no improvement, and I have removed it rather than ship an unverified
optimisation that also drops frames. The batched push stays because it is a
strict reduction in work that is true independent of any timing.

Measured for real, on the side: a single-spectrum fit is 33 ms (14 LM
iterations). float32 is SLOWER than float64 here (203 vs 109 ms) — worth
recording so nobody tries it again. The 109 ms in that comparison was dominated
by BUILDING the spec, which goes through sympy in hyperspy's Expression
components; reusing a spec is 33 ms.
Two things the caret got wrong, both reported from the app.

**Specs were rebuilt constantly.** Constructing a hyperspy component costs
20-110 ms — Expression components lambdify their formula through sympy on
every instantiation, with no caching upstream — and the picker, "add
component" and the background wizard each built their own. The palette
therefore cost 6.2 s on first open and ~130 ms on every open after, which is
what "the fitting is slow" actually was: the caret opening, not the fit.

One prototype per kind now, one ComponentSpec read off it, deep-copied per
use (`new_component_spec`), and the sampled palette cached per axis. With
imports already paid, as the app pays them: first open 681 -> 3 ms on a new
dataset, 0.04 ms reopening the same one. The remaining first-open cost is
sampled off the main thread, so the caret appears immediately and the
sparklines fill in.

**Handles came off the component.** Backgrounds had none at all — a PowerLaw
or an Offset was editable only through the caret's number boxes. They now
carry ANCHOR points, and dragging one solves the component through it in
closed form, so the curve passes exactly under the cursor (the other anchors
are held, so it does not pivot out from under them). And the partner handle
is moved every pointer frame instead of only on release: a widget `set` is a
targeted push of a few fields, unlike `fit_state`, which stays on release.

Two real bugs surfaced while verifying the anchors in the app, neither
visible to the handler tests:

* A PowerLaw's `origin` was seeded to the axis midpoint, because the rule
  that centres a gaussian also matched the name. The background was then
  identically zero over the left half of the spectrum, with its anchors
  outside its own domain.
* Scaling a background by its PEAK matches a power law's singular left edge,
  leaving ~3e-5 across the spectrum; by its median LEVEL it puts 3.7e10 at
  that same edge. Neither is a scaling problem — a background needs its shape
  solved from the data, which `seed_background` now does through the same
  closed form, sampling a low percentile over a wide window so it lands on
  the low envelope rather than on whatever peak sits under an anchor.

Verified in the app: `fit_handles.spec.ts` reads each handle out of the panel
state and requires it to sit on its component's own line, drives a live
pointer_move and asserts the width band tracked it, and drags a background
anchor. Adds a `_spyde_test_panel_json` hook so a spec can read the lines as
well as the widgets — a screenshot cannot tell "on the curve" from "a few
pixels off it".
`exspy`, `kikuchipy` and `atomap` (plus `ase`, which atomap pulls in) are
declared as optional extras in pyproject but were never resolved into the
lockfile, so `uv sync --extra ebsd` had to re-resolve them every time.
…ehind

Three things wrong in `fit_run`'s completion, all of the same shape as the
handle drift: the result landed in the model but nothing else was told.

* The store was never filled, so the caret said "0/1024 fitted" immediately
  after fitting all 1024, and navigating afterwards refit positions it had
  just solved. `record_run` now records every CONVERGED position — a failed
  fit is not an answer to remember, and leaving it out lets a later adaptive
  pass retry from a better neighbouring seed.
* The handles stayed put while the curves jumped to the fitted values.
* The post-run preview read `current_indices` off the PLOT, where it does not
  live, so `idx` was always None and it silently showed position 0's
  parameters instead of the displayed one. Same mistake that made "Fit
  spectrum" fit the navigation mean.

`position_of` is the exact inverse of the `ravel_multi_index(reversed(idx))`
the run path uses; it is tested on a NON-square scan, because a transposed key
is invisible on a square one.

`record_run` is a method rather than inline in `_done` so it is reachable
without the worker marshal, which does not turn under pytest — the existing
run test sidesteps the same way.

Verified in the app: the spec now runs the scan and asserts the coverage
readout moved off zero and that the handle positions changed (347/1024 on the
tutorial spectroscopy dataset, handles on the fitted curves).
…t separate

Every component is seeded mid-axis, so a second gaussian arrived with the same
centre AND the same sigma as the first: two IDENTICAL Jacobian columns
(measured corr(centre1, centre2) = 1.000000, cond(J) = 1e16). A perfectly
degenerate pair can never separate — the solver moves both the same way
forever — so it grows one and shrinks the other. On two genuinely separated
peaks it was worse than cosmetic: the pair ran away together to sigma = 170 on
a 100-wide axis, chisq 6.3e7, with neither peak found.

Two changes, and the measurement says both are needed:

* `spread_repeats` places repeats of a kind symmetrically about the seed,
  spaced by ONE of their own widths. Symmetric so a genuinely co-centred pair
  still starts co-centred; one width because wider is worse, not better (at
  two widths the co-centred case fails outright, chisq 6.3e5 against 1.3e-21).
* `clamp_to_axis` bounds a peak's position to the axis and its width to the
  axis span. A component centred off the data or wider than all of it is not
  modelling anything the user can see, and those are exactly the runaway
  directions. Backgrounds are exempt: a PowerLaw's `origin` is a reference
  point deliberately placed outside the axis.

Neither alone is enough. Bounds without the spread only pin the degenerate
pair at the cap (chisq 6.3e7, unchanged). The spread without bounds still
diverges on a noisy near-overlapping pair (chisq 1.1e8 against 127).

Measured over six synthetic arrangements plus two positions of hyperspy's real
`two_gaussians`: three catastrophic failures fixed, and the two real positions
reproduce their previous fits EXACTLY (chisq 5.245e5 and 1.795e5, unchanged) —
which is the point of spacing by one width rather than relocating.

No seed is universally right on a non-convex problem; a wider spread trades one
failure for another. That is what the drag handles are for.
"Once you try to fit a spectrum then you can't move either component."

`spyde:figure_event` carries `{figId, event}` and NO window_id — SpyDEContext's
re-broadcast never had one. The Fit caret's navigator listener guarded with
`detail.window_id === windowId`, which compared `undefined` and so never fired.
Every pointer_up on the caret's OWN plot therefore sent `fit_navigated`,
including the ones from its own drag handles.

Before a fit that was invisible: `fit_navigated` finds nothing stored and just
redraws. After one, the position IS stored, so it RECALLED the fitted values
and overwrote the drag the instant it landed — the handle snapped straight
back and the component could not be moved at all. Which also made the fit
un-improvable, since nudging a component and refitting is the entire workflow.

Filtered by figId instead, against this window's own figures.

Also reorders `_on_widget_drag`: handles first, then lines. `refresh_lines`
ends in a FULL panel push that serialises every widget's geometry, while
`update_widgets` issues TARGETED per-widget pushes that travel on the event
channel. Lines-first meant the frame's full push carried the OLD partner
position and the new one went out targeted-only. Handles-first is one push
per frame instead of one plus one per moved handle, and it is what lets a test
see the partner track at all.

Verified in the app by fit_drag_after_fit.spec.ts, which drags a component
before a fit (the control), fits, drags again, and requires the model to both
move AND stay moved.
Two gaussians are exchangeable — nothing in the model says which is the broad
one — so fitted position by position they land in whichever slot each fit
happens to pick. Measured on hyperspy's two_gaussians: the first component was
the broad peak at only 43% of positions, its sigma ranging 2.2 to 25.7 across
the scan.

Which is what "it's literally suppressing one component to 0" was. Scrubbing
the navigator made the SAME named component jump between the broad peak and
the narrow one, so its amplitude dropped by an order of magnitude at every
swap (10259 -> 1509 between two positions, verified in the app). And a
committed component map would have been a checkerboard of two different peaks,
not a map of anything.

The fit itself was never the problem: 97% of positions already reach the noise
floor, median chisq 0.991x what the TRUE generating model achieves on the same
noise. Only the labelling was wrong.

`relabel_scan` decides the ordering ONCE for the whole scan and applies it
everywhere: find the parameter that genuinely separates the components (here
sigma, 2.5 against 25.5) and sort by it. Wired into both the cold batched path
and `fit_seeded` — the latter also relabels its COARSE grid before propagating,
or each coarse cell hands out its own arrangement and undoes the seeding.

Two things I tried first and rejected on the measurement:

* Serpentine neighbour-matching — walk the grid in boustrophedon order and keep
  the arrangement closest to the position you just came from. It drifts:
  matching is chained, so one bad match (a position where the components are
  genuinely similar) flips the convention for everything after it. 425 of 1024
  positions were permuted correctly and agreement went DOWN, 43% to 28%.
* Scoring separability by how far apart the sorted slots' means are. Sorting
  ALWAYS separates: two components drawn from the same distribution still give
  a "smaller" slot and a "larger" one. The score now measures DISJOINTNESS
  (5th percentile of the upper slot minus the 95th of the lower), and refuses
  to reorder when they overlap — an invented identity is worse than an
  inconsistent one, because the map then looks meaningful when it is not.

Relabelling changes no residual: median chisq is identical before and after
(216748), and a test asserts the summed model curve is unchanged to 1e-12.

Verified in the app by fit_navigate.spec.ts: fit the scan, move the navigator
between two positions with different answers, and require both that the model
follows the navigator AND that each named component is still the same peak.
…efits the poor

Renames "Run scan" to "Fit all Spectra" and makes it deliver what the name
promises.

**It plots the result.** One map per component PLUS chi squared, opened when
the fit finishes rather than only on Commit — the answer used to be reachable
only by scrubbing the navigator a pixel at a time. chi squared belongs beside
the components: a component map read without it can be a picture of the model
falling over rather than of the sample. The automatic window is a preview and
is REPLACED on each fit; "Keep these maps" (was "Commit component maps") makes
one that survives.

**Every position is stored, not only the converged ones.** A position left out
of the store showed whatever the last one left in the model, which reads as the
fit being stale — so moving the navigator now always shows THAT position's fit.
A poor fit is still that position's answer; it is flagged instead of withheld.

**The poor positions get refit, from their best neighbour.** A failure is
almost always a pixel whose neighbours succeeded, and a spectrum image is
smooth, so next door is nearly the answer here. Runs automatically at the end
of a fit and again from a "Refit poor (N)" button. Measured on hyperspy's
two_gaussians: positions worse than 1.5x the noise floor go 27 -> 1, total
chisq down 9.7%, at 0.1 s a pass against 2.8 s for the fit itself. Two passes
reach the fixed point, so the loop stops on "no improvement" rather than a
count, and a refit is KEPT only where it lowers chisq — which is what makes it
safe to run unattended.

"Poor" is local: chisq scales with the counts, so a bright pixel legitimately
has a bigger one and any global threshold flags a whole region or nothing. A
position with fewer than two neighbours is not judged on chisq at all — the
median of ONE number is that number, so at a scan corner the test would reduce
to "worse than the single pixel beside it", which a steep gradient satisfies on
its own.

Two bugs found while verifying this in the app:

* anyplotlib fires a `pointer_move` on a PROGRAMMATIC widget move as well as a
  dragged one, so every handle written by `update_widgets` re-entered the drag
  handler, which treats it as a user edit and discards the fit. Moving the
  handles onto a freshly fitted model therefore threw that fit away: the maps
  would not open ("run the fit before committing") and Commit vanished.
* The `update_widgets` / `draw_preview` order was wrong at all five call sites
  for the same reason it was wrong inside the drag handler: the redraw's full
  panel push carried the OLD handle geometry.

Verified in the app by fit_all_spectra.spec.ts: the button's name, that a
result window opens on its own with a chi-squared map among the chips, that
coverage reaches 1024/1024, that "Refit poor (14)" takes it to 0, and that
refitting replaces the maps window instead of stacking another.
…y does

A HyperSpy parameter already carries `parameter.map` — a structured array over
the navigation grid with values / std / is_set — and that IS "hold the
parameters for the model at each position". It is what `m.multifit()` fills and
what `m.store()` persists. SpyDE was keeping a dict beside it instead.

`FitStore` (spyde/fitting/store.py) wraps a live model built from the spec and
writes into those arrays. All SpyDE adds is the packing: the batched engine
works in `ModelSpec.parameter_names()` column order, so this converts between
that vector and the per-parameter maps. Nothing duplicates the storage.

What the dict could not do, and this can:

* **is_set.** "Not fitted" and "fitted to zero" were the same thing, so a map
  could not show a hole. They are now distinct, which is what lets the maps
  open EMPTY and fill in.
* **Every position, allocated up front.** The dict grew as the user explored,
  so the maps could only exist once a fit had finished.
* **chi-squared and std** per position, in the same place as the parameters.
* **It is a HyperSpy model**, so the result can be handed back to HyperSpy.

The behaviour that follows, which is the point:

* "Fit components" opens with the CARET, flat, one map per component plus chi
  squared — verified: the chips read Gaussian | Gaussian 2 | chi squared before
  anything has been fitted.
* Fitting one spectrum fills one pixel of it; "Fit all Spectra" fills them all.
  One window for the life of the caret, refreshed in place — rebuilt only when
  the SET of maps changes, because a window's primary map and chips are fixed
  at creation and refreshing would leave the first component chip-less.
* "Keep these maps" still makes a separate, frozen tree.

Indexing is the one hazard and it is tested on a NON-square scan: navigator
indices are x-first, HyperSpy's maps are y-first like the data, and on a square
scan a transposed store looks exactly right while every position holds its
neighbour's answer.

24 new tests in test_fit_store.py; test_fit_wizard.py updated to the new
contract (132 total). All five fit e2e specs green.
…ed another's fit

The store keyed positions by reversing the selector's indices, reasoning from
`axes_manager.navigation_shape`. The DISPLAY does no such thing: `get_local_frame`
and `_build_nav_lazy_slice` both do `data[point]` with the indices exactly as
given, so the spectrum on screen at crosshair (cx, cy) is `data[cx, cy]`. The
store's whole job is to answer "what was fitted to THAT spectrum", so it has to
index the same way. It did not.

Nothing caught it. The tutorial scan is 32x32, so the shapes matched; coverage
read 1024/1024; every recall succeeded and said so; and the DIAGONAL positions
were even correct. Every other position quietly showed its transpose's fit —
a plausible-looking curve that misses the data. My own store test asserted the
round trip, which a self-consistent-but-transposed convention passes.

Measured in the app, sweeping 25 navigator positions and comparing the drawn
model against the drawn spectrum:

    median misfit   19.9%  ->  2.9%   of the spectrum's range
    worst            47.0% ->  3.9%
    "Fit spectrum" at the worst position:  47% -> 5.4%  becomes  3.9% -> 3.9%

That last line is the reported symptom and now behaves as it should: at an
already-fitted position, fitting again has nothing left to do.

fit_quality.spec.ts is the test that would have caught this and is the one to
keep. Every other fit spec asserts on parameters, coverage or window counts —
all of which were perfectly happy. This one reads BOTH curves out of the same
panel state and compares them, which is what the eye does, and it sweeps
positions rather than trusting whichever one the navigator starts on (the
default position sits on the diagonal and was always fine).

The store tests now pin the convention against the DISPLAY's, not against
themselves.
"A pause and then a snap." Every pointer frame of a navigator drag posts a
`pointer_move`, and the caret sent a `fit_navigated` for each — each costing a
recall, a preview redraw and a full model re-send. They arrived faster than
they were served, so the queue drained AFTER the drag: measured 29 moves over
486 ms with the model settling 1159 ms after the last of them. The pause is the
backlog; the snap is it finally arriving.

Coalesced instead, latest-position-wins: one `fit_navigated` in flight, the
rest collapsed into a single pending flag. Skipping intermediate sends loses
nothing, because `fit_navigated` reads the CURRENT selector position when the
backend handles it — one send after the previous finishes always acts on the
newest position.

The gate opens on `fit_state` coming back, which is the backend's own
completion signal, so it self-tunes to whatever the work actually costs
instead of guessing a throttle interval. A 2-second timer releases it if a
reply is ever lost, so it cannot wedge.

    settling time after the last pointer move:  1159 ms -> 113 ms

`fit_navigated` now emits a state on EVERY path. Two of them returned silently
(no components; nothing stored and adaptive off), which would have stalled the
next move until the wedge timer expired — the same pause, reintroduced by the
back door. Tested.

The old code meant to do this with `if (d.event?.type && !/up|click/.test(...))`,
but anyplotlib's payload field is `event_type`, so `d.event.type` was undefined
and the guard never fired. Removed rather than repaired: filtering moves out
entirely is what makes the model jump on release instead of following the
pointer, which is the behaviour asked for here.

fit_drag_latency.spec.ts drives a real 60 fps stream of moves and measures how
long after the last one the drawn model settles.
…s; save models

You were right that this was a lifecycle problem, not two separate bugs.

**Remove Background had only its backend half.** `bg_open`/`bg_close`,
a `PARAMETERS` schema registered under `bg`, a controller whose `remove()`
deletes the span — all present and correct. Missing were three renderer
pieces, so the action was reached through the plain YAML toolbar path instead:
the click fired `bg_open`, which added the span, and nothing ever unmounted to
fire `bg_close`. The span stayed for the life of the window.

Added the caret (`BackgroundWizard.tsx`), listed it in `WIZARD_ACTIONS`, and
put `bg_state` in SpyDEContext's re-broadcast allowlist — without that last one
the caret never heard where its own band was and showed 0..0 for it. Nothing in
the backend changed; `useWizardLifecycle` now fires the close that the existing
teardown was already waiting for, and FloatingToolbar renders one caret at a
time so switching actions cleans up too.

**The Fit components window had the same shape of leak** — it outlived the
caret, still showing a model it had stopped tracking. It closes with the caret
now. "Keep these maps" is renamed "Commit components", matching every other
Commit, and is still how you get one that stays. The MODEL and the per-position
store deliberately survive: they live on the tree, so reopening restores what
was built and everything already fitted.

**Save / load models, through HyperSpy's own store.** `m.store(name)` puts the
components AND every position's parameter map into the signal's `models`, so
saving the .hspy/.zspy saves the fit inside it — no SpyDE format to keep in
step. `FitStore.restore` reads one back, copying the maps across so `is_set`
survives: a values-only format would turn every unfitted hole in a component
map into a confident zero. Tested through a real save + `hs.load`.

`action_scoping.spec.ts` drives the contract in the app: open Remove
Background, see the span, close the caret, see it gone — then again by
switching to Fit rather than pressing the X — then the same for Fit's own
window and handles.

Also pins fit_quality.spec.ts's navigator position before adding components.
A component is seeded from the spectrum ON SCREEN, so under load (eight apps
back to back) the first paint may not have landed and the whole scan starts
somewhere else — it failed at 25% misfit in a batch run and passed at 3.9%
alone. That is a real property of seeding from the display, not a flake to
paper over, but the test should measure fit quality rather than paint timing.
…the y-axis

Two bugs, both found by dragging it.

**The drag was never heard.** `_on_drag` read `x0`/`x1` off the EVENT.
anyplotlib's `Event` carries x/y and a handful of scalars; a range widget's
geometry is on the widget it hands back as `event.source`. So both were None,
the handler returned at its first check, and the background preview never moved
— not on pointer_move, not even on release. Exactly the mistake the Fit handles
made ("the curves don't move from the drag").

While fixing it, the preview also stopped rebuilding its line every frame:
`remove_line` + `add_line` per pointer frame is heavy AND does not repaint
during a drag, which is the same lesson `rebuild_lines` vs `refresh_lines`
records in fit_action. It updates in place now, and the `bg_state` message
waits for the release rather than going out per frame.

**The extrapolation could take the plot with it.** The background is drawn —
and subtracted — across the WHOLE axis, not just the fitted window, so a
PowerLaw whose `origin` sits on an axis that reaches zero is singular there. A
window on the far side of a peak fitted to 3e9 at the left edge: the y-range
blew away and the spectrum vanished behind it. Now seeded by the same rule as
`fit_action.seed_background` — origin 0 where that is safe (HyperSpy's
convention, and what downstream tools assume), moved a quarter-span below the
axis where it is not. 3e9 -> 2e4 on the same drag.

13 tests in test_background_drag.py, and action_scoping.spec.ts now drives a
real pointer_move on the band and requires the drawn curve to change.

NOTE, not fixed: the preview line still participates in the plot's y-range, so
a background model that suits the data poorly (a PowerLaw over the right half
of a symmetric peak) still squashes the spectrum — 2e4 against a peak of ~1e3
on the tutorial dataset. That is the model being wrong for the data rather than
a defect, but losing sight of your own spectrum to a preview is bad behaviour
and wants the data to own the scale.
…osition

`fit_from_composition` was in the backend and in the staged registry, and
nothing ever sent it: an EELS model was reachable only by placing gaussians by
hand. The Fit caret now offers it, labelled with the elements it would use
("From C, N, O"), and only when there ARE elements — the elements come from
Plot Control's Composition panel via the shared renderer state, so the button
appears the moment they are set.

On an EELS signal `create_model()` auto-populates a background plus one
`EELSCLEdge` per subshell; the edges are then tabulated, because `EELSCLEdge`
has no batched port and without that the fit falls back to HyperSpy's
one-pixel-at-a-time path. On EDS it is `add_lines()` plus a gaussian per X-ray
line. Everything after is the ordinary caret.

**That immediately broke the store, and the fix matters more than the button.**
`FitStore` held its state inside a HyperSpy model, and a tabulated edge exists
only in SpyDE — `to_model` cannot rebuild one, so the store failed to exist for
exactly the models this path produces. It surfaced as `'NoneType' object has no
attribute 'coverage'` the moment you fitted one.

The arrays are now the storage — (values, std, is_set) per position in
`parameter_names()` order, the same triple HyperSpy keeps per parameter, held
once for the model instead of once per parameter. A HyperSpy model is still
built when it CAN be, and `save_as` pushes the arrays into its maps before
storing, so a model HyperSpy can represent still travels with the dataset as
its own. When it cannot, `save_as` says so rather than inventing a private
format only SpyDE could read.

Verified end to end on the bundled synthetic EELS SI: the button appears with
the elements, builds PowerLaw + O_K + N_K + C_K with 3 edges tabulated, and the
model it built fits the spectrum to 0.4% of its range.

Also adds `bg_state` to the protocol's message union — it was relayed at
runtime but missing from the type, so tsc failed.
`EELSCLEdge` CAN be batched, including its fine structure. I claimed otherwise
and shipped a private `TabulatedShape` component that fitted intensity and a
shift and discarded everything else. That was wrong on both counts.

What the edge actually is, from exspy's own `function`: three pieces spliced
along the energy axis — a cubic B-spline over the fine-structure window, the
integrated GOS cross-section, and a power-law tail — all scaled by `intensity`.
The expensive part is `_integrate_GOS`, and it depends on the ELEMENT, the
SUBSHELL and the MICROSCOPE GEOMETRY. Not on the pixel. It is the same curve
for every spectrum in the scan.

Everything left is linear or nearly so:

* `intensity` scales the edge — linear, one column.
* `fine_structure_coeff` are B-spline weights with fixed knots, so the edge is
  EXACTLY `base + sum(c_i * basis_i)`. Probing exspy once per coefficient gives
  those basis curves. Measured against exspy's own `function` for random
  coefficients: 0.0 relative error, not approximately.
* `onset_energy` slides the edge; the precomputed curves are interpolated.

So the fine structure is the LINEAR part — the cheapest thing in the component
to batch — and I dropped it. I think I saw that `fine_structure_coeff` is a
vector parameter, saw that the packed vector excludes vectors, and treated the
exclusion as a constraint instead of fixing it. It is carried as one scalar per
column now and reassembled on the way back to HyperSpy.

**The component stays an `EELSCLEdge`.** `prepare_eels_edges` only attaches the
precomputed curves; it does not change the kind, the parameters or the model's
identity. So the model remains a real exspy model and stores on its own signal
with `m.store()` — which the private component made impossible, and which is
how this was found.

Also fixes the reporting that went with it: `supports()` now resolves by
TRYING to build each component rather than checking kinds against
`available()`. The two differ for an edge, which is a supported kind that still
needs its curves — a kind-only check called a raw edge fittable and then failed
inside the engine. `unsupported()` returns the reason per component, and the
composition path surfaces it.

11 parity tests against exspy (base shape, linearity in intensity, random
coefficients, onset direction, analytic gradients vs autodiff, the HyperSpy
round trip). Verified in the app: PowerLaw + O_K/N_K/C_K at 532/401/284 eV,
fitting the spectrum to 0.4% of its range.

One bug worth recording: `_edge_spec` first REPLACED the reserved `spyde` key
rather than merging it, which dropped `fine_structure_active` — so the rebuilt
edge had fine structure off, kept a filled cross-section where the probe left a
spline-shaped hole, and disagreed with exspy by the full height of the edge
across the whole fine-structure window.

Deletes spyde/spectroscopy/tabulate.py and its tests; `TabulatedShape` is gone
from components, quantify and `available()`.
@CSSFrancis
CSSFrancis changed the base branch from feat/0.3.0-wave2-spectroscopy to feat/0.3.0 July 28, 2026 13:51
@CSSFrancis
CSSFrancis merged commit 3bd73f6 into feat/0.3.0 Jul 28, 2026
1 check 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