Skip to content

feat(fitting): TimeResolvedSeries container + fit_series for swept-parameter decay analysis #48

Description

@garrekstemo

Summary

Add a TimeResolvedSeries container — a Vector{TimeResolvedMatrix} paired with a swept-parameter axis (temperature, fluence, excitation power, position, …) — and a fit_series function that fits each element's decay at a chosen wavelength and returns the fit parameters (τ, amplitude, β, R²) as a function of the sweep value. This is the "lifetime vs temperature / fluence / …" analysis that the time-resolved family currently can't express: today you must loop over files by hand, calling kinetic_trace + fit_exp_decay and collecting τ yourself.

The container is general over the time-resolved family (it holds TimeResolvedMatrix, shared by streak PL and TA pump-probe), so a TA fluence sweep reuses the same type. fit_series is a thin wrapper over the existing single-trace fitter — no new fitting math, no new dependencies.

Motivation / context

Surfaced while designing the QPSLab streak-PL series feature (garrekstemo/QPSLab#123). That issue covers loading multiple .img files into one workspace and overlaying their decays — but the quantitative payoff of a TRPL series is parameter-vs-sweep: fit each file's decay and plot τ (or ⟨τ⟩) against the swept variable. Per the ecosystem layering rule, that analysis belongs upstream here, with the GUI as a thin consumer later (a separate QPSLab issue, out of scope below).

The closest existing precedent is fit_lifetime_spectrum (src/fitting.jl, returns LifetimeSpectrumResult — derived arrays aligned to a wavelength axis). This feature is its sibling: independent fit per element, aligned to a sweep axis. The result type follows LifetimeSpectrumResult's conventions closely (derived quantities exposed as struct fields; NaN for unfitted elements).

Proposed API

Containersrc/types.jl (after LifetimeSpectrumResult, ~line 577):

struct TimeResolvedSeries
    matrices::Vector{TimeResolvedMatrix}
    sweep::Vector{Float64}      # swept value per element; defaults to 1:n if unknown
    sweep_quantity::Symbol      # :temperature, :fluence, :power, :position, … (free token, NOT the spectral vocab)
    sweep_unit::Symbol          # :K, :uJ_per_cm2, :mW, :um, …
    labels::Vector{String}      # per-element label (filename / condition)
    metadata::Dict{Symbol,Any}
end
  • Not a subtype of AbstractSpectroscopyData — it is an ordered collection, not a single 1-D/2-D observation, so the xdata/ydata/xlabel interface doesn't apply (xdata would be ambiguous: the sweep axis, or element 1's wavelength axis?). State this explicitly.
  • Validating keyword constructor using throw(ArgumentError(...)) (the KineticTrace/TimeResolvedMatrix convention — not @assert): require length(matrices) == length(sweep) and ≥ 1 element; sweep defaults to collect(1.0:n) with sweep_quantity = :index, sweep_unit = :none; labels default to source filenames or ["1", …].
  • Base methods: length, getindex(s, i) -> TimeResolvedMatrix, iterate, eachindex, and a compact Base.show. Fields (s.sweep, s.matrices, s.sweep_unit, s.labels) are read directly — no bespoke accessor functions (matches how LifetimeSpectrumResult/GlobalFitResult expose their data).

Fitsrc/fitting.jl (after fit_lifetime_spectrum, ~line 680):

fit_series(s::TimeResolvedSeries; wavelength::Real, band::Real=0.0, min_signal::Real=0.0,
           n_exp::Int=1, irf::Bool=false, irf_width::Float64=0.15,
           t_start::Float64=0.0, t_range=nothing, model::Symbol=:exponential) -> TimeResolvedSeriesFit

For each element: trace = kinetic_trace(m; wavelength, band)fit = fit_exp_decay(trace; n_exp, irf, irf_width, t_start, t_range, model). Every fit kwarg passes straight through to the existing fit_exp_decay (src/fitting.jl:150), so single-exp / multi-exp (n_exp>1) / stretched (model=:stretched) all work unchanged.

  • Per-element fitting is wrapped in try/catch, mirroring fit_lifetime_spectrum (src/fitting.jl:657-662) — a failed element → fits[i] = nothing, fitted[i] = false; fit_series never throws for one bad file. The catch must rethrow InterruptException (e isa InterruptException && rethrow()) so Ctrl-C during a long series fit isn't swallowed. (fit_exp_decay genuinely throws: ArgumentError for a stretched region with <5 points at fitting.jl:227, and BoundsError/solver errors on degenerate input — so the guard is justified.)
  • min_signal mirrors fit_lifetime_spectrum (fitting.jl:653): if a requested wavelength lands off-axis, kinetic_trace's nearest-column fallback (timeresolved.jl:110-119) returns near-zero background that would fit to garbage; min_signal lets those elements skip cleanly (fitted[i]=false) instead of relying on the fitter to fail.

Resultsrc/types.jl, same skeleton as LifetimeSpectrumResult (derived arrays as fields, NaN for unfitted) plus a fits field holding the per-element structs:

struct TimeResolvedSeriesFit
    sweep::Vector{Float64}
    sweep_quantity::Symbol
    sweep_unit::Symbol
    labels::Vector{String}
    wavelength::Float64
    band::Float64
    taus::Matrix{Float64}        # (n_elements × n_exp); NaN where !fitted
    amplitudes::Matrix{Float64}  # (n_elements × n_exp); NaN where !fitted
    betas::Vector{Float64}       # (n_elements,); NaN unless model == :stretched
    rsquared::Vector{Float64}    # (n_elements,); NaN where !fitted
    fits::Vector{Union{ExpDecayFit,MultiexpDecayFit,StretchedDecayFit,Nothing}}  # full per-element fit; nothing = failed
    fitted::BitVector
    n_exp::Int
    model::Symbol
end
  • fit_series populates taus/amplitudes/betas/rsquared by branching on each element's fit type, exactly like fit_lifetime_spectrum's extraction (src/fitting.jl:664-672): ExpDecayFittaus[i,1]/amplitudes[i,1] from its scalar fields; StretchedDecayFittaus[i,1] from .tau, betas[i] from .beta; MultiexpDecayFit.taus/.amplitudes into row i. A missing StretchedDecayFit arm is a runtime MethodError, so cover all three.
  • Quantities are read as fields (r.taus, r.amplitudes, r.betas, r.rsquared, r.sweep, r.sweep_unit, r.labels) — no new exported generic functions named after the fields (defining taus/amplitudes/rsquared/betas as functions would shadow the field names used across the other result types and risk Aqua ambiguities).
  • Add a compact Base.show (model of LifetimeSpectrumResult's, types.jl:578-580), e.g. "TimeResolvedSeriesFit: $(count(r.fitted))/$(length(r.fitted)) fitted, model=$(r.model), n_exp=$(r.n_exp)".

Derived ⟨τ⟩ — extend the existing exported mean_lifetime (don't invent a plural):

  • Add mean_lifetime(::ExpDecayFit) = fit.tau and mean_lifetime(::MultiexpDecayFit) = amplitude-weighted ⟨τ⟩ Σaᵢτᵢ / Σaᵢ, returning NaN when Σaᵢ ≈ 0 (ESA/GSB amplitude cancellation in TA data — do not copy the uniform-weight fallback from weights(::MultiexpDecayFit); ⟨τ⟩ is physically undefined there). mean_lifetime(::StretchedDecayFit) already exists (types.jl:531).
  • Add mean_lifetime(r::TimeResolvedSeriesFit) -> Vector{Float64} that maps mean_lifetime over r.fits, emitting NaN for nothing/unfitted elements. mean_lifetime is already exported (src/OpticalSpectroscopy.jl:86), so these are new methods, needing no new export.
  • Add n_exp(r::TimeResolvedSeriesFit) = r.n_exp for symmetry with n_exp(::GlobalFitResult) (types.jl:1211). Note: n_exp is public-by-use but NOT exported today (tests call OpticalSpectroscopy.n_exp(...), runtests.jl:805) — keep it unexported; consumers read r.n_exp directly.

So the headline plot is (r.sweep, mean_lifetime(r)) — or (r.sweep, r.taus[:, k]) for the k-th component.

Design decisions (locked with @garrekstemo)

  • General TimeResolvedSeries over the time-resolved family — not a streak-only type (TA sweeps reuse it).
  • fit_series only in v1 — no fit_global_series (shared-τ joint fit). Deferred (below).
  • Per-element fits stored + quantities as fields. r.fits keeps each element's full struct (so predict/format_results work per element later); taus/amplitudes/betas/rsquared are precomputed fields like LifetimeSpectrumResult for cheap aligned access.
  • mean_lifetime is unified and amplitude-weighted for multi-exp, NaN on amplitude cancellation.
  • TimeResolvedSeries is intentionally not an AbstractSpectroscopyData (it's a collection).

Conventions to honor

  • No new dependencies (reuse kinetic_trace, fit_exp_decay; gamma for stretched is already via SpecialFunctions). Julia 1.10 floor (Project.toml:41).
  • checkdocs = :exports (docs/make.jl:7): the new exports are just TimeResolvedSeries, TimeResolvedSeriesFit, fit_series (added to the export block in src/OpticalSpectroscopy.jl — types ~line 74, fns ~line 82) — each needs a docstring (# Arguments / # Returns style, see fit_exp_decay). The mean_lifetime/n_exp additions are new methods on existing functions, no new export names.
  • Aqua (test/runtests.jl:13): keep every new method tightly typed on ::TimeResolvedSeries/::TimeResolvedSeriesFit (no ::Any) so test_ambiguities/piracy stay clean.
  • No version bump, no Registrator. Current version = "0.1.0" (Project.toml:3) — leave it (ignore any stray v0.2.0 reference in sibling notes). The package is unreleased/unregistered (consumed via [sources] URL).
  • Branch protection: feature branch → PR.

Tasks

  1. TimeResolvedSeries type + constructor + Base methods (src/types.jl), exported & documented; @testset: validating constructor (length mismatch, empty, index/filename defaults), getindex/length/iterate, show.
  2. TimeResolvedSeriesFit result type + Base.show (src/types.jl), exported & documented; + the mean_lifetime methods for ExpDecayFit/MultiexpDecayFit/TimeResolvedSeriesFit and the n_exp(::TimeResolvedSeriesFit) method. @testset: build a result by hand with one nothing element, assert field shapes (n × n_exp), NaN placement for !fitted, the multi-exp mean_lifetime weighting and its Σa≈0 → NaN case, and mean_lifetime(::StretchedDecayFit) delegation.
  3. fit_series (src/fitting.jl), exported & documented; uses ArgumentError (not @assert), the InterruptException-rethrow try/catch, min_signal skip, and the type-branched array fill. Integration @testset: synthetic series with a known τ(sweep) law (reuse the _exp_decay_irf_conv helper) → assert mean_lifetime(r) / r.taus recover the input within tolerance; a deliberately-bad element yields fitted[i]==false; model=:stretched populates r.betas; n_exp=2 populates the second τ column.
  4. Docs + green CI: add TimeResolvedSeries, TimeResolvedSeriesFit, fit_series to docs/src/api.md under the "Time-Resolved Analysis" section (after fit_lifetime_spectrum, api.md:34); confirm checkdocs and Aqua pass; PR. (No version bump.)

Out of scope

  • fit_global_series (joint shared-τ fit across the series) — natural follow-up extending fit_global; deferred.
  • The GUI that exposes this — a "Series Analysis" panel on the QPSLab streak-series container (#123): assign sweep quantity/unit per file, call fit_series, plot param-vs-sweep. Separate QPSLab issue, filed once this lands.
  • Multi-wavelength series fits (a (sweep × wavelength) τ surface) — possible later generalization; v1 fits one wavelength/band.
  • File loaders for a series — multi-file IO belongs in QPSTools, not here; TimeResolvedSeries is constructed from already-loaded matrices.

Notes

  • Line numbers above are from the audit against main as of 2026-06-23 — verify against current main before acting.
  • sweep_quantity / sweep_unit are free Symbols (temperature/fluence/… are not in tokens.jl's spectral vocabulary) — do not route them through token validation.
  • Elements stay in input order; sorting by r.sweep for plotting is the caller's job (sweep values may be non-monotonic or NaN/unknown).

🤖 Filed by Claude Code on behalf of @garrekstemo.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions