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
Container — src/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).
Fit — src/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.
Result — src/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): ExpDecayFit → taus[i,1]/amplitudes[i,1] from its scalar fields; StretchedDecayFit → taus[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
TimeResolvedSeries type + constructor + Base methods (src/types.jl), exported & documented; @testset: validating constructor (length mismatch, empty, index/filename defaults), getindex/length/iterate, show.
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.
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.
- 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.
Summary
Add a
TimeResolvedSeriescontainer — aVector{TimeResolvedMatrix}paired with a swept-parameter axis (temperature, fluence, excitation power, position, …) — and afit_seriesfunction 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, callingkinetic_trace+fit_exp_decayand 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_seriesis 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
.imgfiles 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, returnsLifetimeSpectrumResult— derived arrays aligned to a wavelength axis). This feature is its sibling: independent fit per element, aligned to a sweep axis. The result type followsLifetimeSpectrumResult's conventions closely (derived quantities exposed as struct fields;NaNfor unfitted elements).Proposed API
Container —
src/types.jl(afterLifetimeSpectrumResult, ~line 577):AbstractSpectroscopyData— it is an ordered collection, not a single 1-D/2-D observation, so thexdata/ydata/xlabelinterface doesn't apply (xdatawould be ambiguous: the sweep axis, or element 1's wavelength axis?). State this explicitly.throw(ArgumentError(...))(theKineticTrace/TimeResolvedMatrixconvention — not@assert): requirelength(matrices) == length(sweep)and≥ 1element;sweepdefaults tocollect(1.0:n)withsweep_quantity = :index,sweep_unit = :none;labelsdefault to source filenames or["1", …].Basemethods:length,getindex(s, i) -> TimeResolvedMatrix,iterate,eachindex, and a compactBase.show. Fields (s.sweep,s.matrices,s.sweep_unit,s.labels) are read directly — no bespoke accessor functions (matches howLifetimeSpectrumResult/GlobalFitResultexpose their data).Fit —
src/fitting.jl(afterfit_lifetime_spectrum, ~line 680):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 existingfit_exp_decay(src/fitting.jl:150), so single-exp / multi-exp (n_exp>1) / stretched (model=:stretched) all work unchanged.try/catch, mirroringfit_lifetime_spectrum(src/fitting.jl:657-662) — a failed element →fits[i] = nothing,fitted[i] = false;fit_seriesnever throws for one bad file. Thecatchmust rethrowInterruptException(e isa InterruptException && rethrow()) so Ctrl-C during a long series fit isn't swallowed. (fit_exp_decaygenuinely throws:ArgumentErrorfor a stretched region with<5points atfitting.jl:227, andBoundsError/solver errors on degenerate input — so the guard is justified.)min_signalmirrorsfit_lifetime_spectrum(fitting.jl:653): if a requestedwavelengthlands off-axis,kinetic_trace's nearest-column fallback (timeresolved.jl:110-119) returns near-zero background that would fit to garbage;min_signallets those elements skip cleanly (fitted[i]=false) instead of relying on the fitter to fail.Result —
src/types.jl, same skeleton asLifetimeSpectrumResult(derived arrays as fields,NaNfor unfitted) plus afitsfield holding the per-element structs:fit_seriespopulatestaus/amplitudes/betas/rsquaredby branching on each element's fit type, exactly likefit_lifetime_spectrum's extraction (src/fitting.jl:664-672):ExpDecayFit→taus[i,1]/amplitudes[i,1]from its scalar fields;StretchedDecayFit→taus[i,1]from.tau,betas[i]from.beta;MultiexpDecayFit→.taus/.amplitudesinto rowi. A missingStretchedDecayFitarm is a runtimeMethodError, so cover all three.r.taus,r.amplitudes,r.betas,r.rsquared,r.sweep,r.sweep_unit,r.labels) — no new exported generic functions named after the fields (definingtaus/amplitudes/rsquared/betasas functions would shadow the field names used across the other result types and risk Aqua ambiguities).Base.show(model ofLifetimeSpectrumResult'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):mean_lifetime(::ExpDecayFit) = fit.tauandmean_lifetime(::MultiexpDecayFit)= amplitude-weighted ⟨τ⟩Σaᵢτᵢ / Σaᵢ, returningNaNwhenΣaᵢ ≈ 0(ESA/GSB amplitude cancellation in TA data — do not copy the uniform-weight fallback fromweights(::MultiexpDecayFit); ⟨τ⟩ is physically undefined there).mean_lifetime(::StretchedDecayFit)already exists (types.jl:531).mean_lifetime(r::TimeResolvedSeriesFit) -> Vector{Float64}that mapsmean_lifetimeoverr.fits, emittingNaNfornothing/unfitted elements.mean_lifetimeis already exported (src/OpticalSpectroscopy.jl:86), so these are new methods, needing no new export.n_exp(r::TimeResolvedSeriesFit) = r.n_expfor symmetry withn_exp(::GlobalFitResult)(types.jl:1211). Note:n_expis public-by-use but NOT exported today (tests callOpticalSpectroscopy.n_exp(...),runtests.jl:805) — keep it unexported; consumers readr.n_expdirectly.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)
TimeResolvedSeriesover the time-resolved family — not a streak-only type (TA sweeps reuse it).fit_seriesonly in v1 — nofit_global_series(shared-τ joint fit). Deferred (below).r.fitskeeps each element's full struct (sopredict/format_resultswork per element later);taus/amplitudes/betas/rsquaredare precomputed fields likeLifetimeSpectrumResultfor cheap aligned access.mean_lifetimeis unified and amplitude-weighted for multi-exp,NaNon amplitude cancellation.TimeResolvedSeriesis intentionally not anAbstractSpectroscopyData(it's a collection).Conventions to honor
kinetic_trace,fit_exp_decay;gammafor stretched is already viaSpecialFunctions). Julia 1.10 floor (Project.toml:41).checkdocs = :exports(docs/make.jl:7): the new exports are justTimeResolvedSeries,TimeResolvedSeriesFit,fit_series(added to theexportblock insrc/OpticalSpectroscopy.jl— types ~line 74, fns ~line 82) — each needs a docstring (# Arguments/# Returnsstyle, seefit_exp_decay). Themean_lifetime/n_expadditions are new methods on existing functions, no new export names.test/runtests.jl:13): keep every new method tightly typed on::TimeResolvedSeries/::TimeResolvedSeriesFit(no::Any) sotest_ambiguities/piracy stay clean.version = "0.1.0"(Project.toml:3) — leave it (ignore any strayv0.2.0reference in sibling notes). The package is unreleased/unregistered (consumed via[sources]URL).Tasks
TimeResolvedSeriestype + constructor +Basemethods (src/types.jl), exported & documented;@testset: validating constructor (length mismatch, empty, index/filename defaults),getindex/length/iterate,show.TimeResolvedSeriesFitresult type +Base.show(src/types.jl), exported & documented; + themean_lifetimemethods forExpDecayFit/MultiexpDecayFit/TimeResolvedSeriesFitand then_exp(::TimeResolvedSeriesFit)method.@testset: build a result by hand with onenothingelement, assert field shapes(n × n_exp),NaNplacement for!fitted, the multi-expmean_lifetimeweighting and itsΣa≈0 → NaNcase, andmean_lifetime(::StretchedDecayFit)delegation.fit_series(src/fitting.jl), exported & documented; usesArgumentError(not@assert), theInterruptException-rethrow try/catch,min_signalskip, and the type-branched array fill. Integration@testset: synthetic series with a known τ(sweep) law (reuse the_exp_decay_irf_convhelper) → assertmean_lifetime(r)/r.tausrecover the input within tolerance; a deliberately-bad element yieldsfitted[i]==false;model=:stretchedpopulatesr.betas;n_exp=2populates the second τ column.TimeResolvedSeries,TimeResolvedSeriesFit,fit_seriestodocs/src/api.mdunder the "Time-Resolved Analysis" section (afterfit_lifetime_spectrum,api.md:34); confirmcheckdocsand Aqua pass; PR. (No version bump.)Out of scope
fit_global_series(joint shared-τ fit across the series) — natural follow-up extendingfit_global; deferred.fit_series, plot param-vs-sweep. Separate QPSLab issue, filed once this lands.(sweep × wavelength)τ surface) — possible later generalization; v1 fits one wavelength/band.TimeResolvedSeriesis constructed from already-loaded matrices.Notes
mainas of 2026-06-23 — verify against currentmainbefore acting.sweep_quantity/sweep_unitare free Symbols (temperature/fluence/… are not intokens.jl's spectral vocabulary) — do not route them through token validation.r.sweepfor plotting is the caller's job (sweep values may be non-monotonic orNaN/unknown).🤖 Filed by Claude Code on behalf of @garrekstemo.