Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ jobs:
# transitive deps out of the constraints file so pip resolves those
# itself below — mirroring a user whose direct deps sit at our floors.
run: |
python -m pip install uv==0.11.27 # bump alongside the [dev] pins
python -m pip install uv==0.11.28 # bump alongside the [dev] pins
uv pip compile --resolution lowest-direct --no-deps \
-o min-constraints.txt pyproject.toml
cat min-constraints.txt
Expand Down
28 changes: 28 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Agent Orientation

Orientation for AI coding agents working on this repository — developing
`trspecfit` itself, not using it for analysis. If you want to *use*
`trspecfit` inside notebooks or scripts, read [llms.txt](llms.txt) instead.
If your tool does not load these files automatically, read them before
making changes.

- [CLAUDE.md](CLAUDE.md) — authoritative: behavior rules, architecture
guardrails, code style, and testing conventions. Follow it even if you
are not Claude.
- [TODO.md](TODO.md) — high-level project goals; the `[ACTIVE]` tag marks
the feature currently in progress.
- [PLAN.md](PLAN.md) — live plan for active multi-step feature work (states
when nothing is in progress).
- [docs/design/repo_architecture.md](docs/design/repo_architecture.md) —
module map and the two-layer design (readable authoring layer vs.
compiled hot path).
- [docs/design/supported_models.md](docs/design/supported_models.md) —
source of truth for supported model combinations, expressions, and
compositions.
- [docs/ai/](docs/ai/index.md) — step-by-step recipes for common repo
tasks; consult the matching recipe before hand-rolling one.

Quick facts: run tests with `pytest -q`; Ruff is the linter and formatter;
never commit without explicit user approval of the exact message; on
renames or public-API changes, grep the entire repo — notebooks, YAML,
tests, and docs all reference the public API.
21 changes: 7 additions & 14 deletions TODO.md

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions docs/ai/check-example.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,8 @@ notebook's actual topic — say so.
Artifact severity: **committed** CSV/PNG/`.fit.h5` fit outputs FAIL (they
pollute the repo). **Untracked/gitignored** outputs are reported INFO, not a
failure — they are transient (left by a local run, or expected for the export
demos) as long as they are gitignored. Empty `*_fits/` directory trees from
`create_model_path` are the known eager-mkdir quirk (see TODO.md) and are
ignored entirely. `data/*.csv` inputs are never counted as artifacts.
demos) as long as they are gitignored. `data/*.csv` inputs are never counted
as artifacts.

## 5. One main message, why-driven narrative & roadmap-as-TOC

Expand Down
2 changes: 1 addition & 1 deletion docs/api/trspecfit.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Utility Methods
Most users won't need to call these directly.

.. automethod:: trspecfit.trspecfit.File.model_list_to_name
.. automethod:: trspecfit.trspecfit.File.create_model_path
.. automethod:: trspecfit.trspecfit.File.model_path
.. automethod:: trspecfit.trspecfit.File.get_fit_results
.. automethod:: trspecfit.trspecfit.File.save_sbs_fit
.. automethod:: trspecfit.trspecfit.File.save_2d_fit
3 changes: 3 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
# -- Options for HTML output -------------------------------------------------
html_theme = "sphinx_rtd_theme"
html_static_path = []
# serve the repo-root llms.txt (agent/LLM orientation for package users)
# at the root of the built docs site
html_extra_path = ["../llms.txt"]

# -- Extension configuration -------------------------------------------------

Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A Python library for fitting multi-component spectral models to time-resolved sp
quickstart
examples/index
design/supported_models
stability

.. toctree::
:maxdepth: 2
Expand Down
41 changes: 41 additions & 0 deletions docs/stability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Stability and Deprecation Policy

`trspecfit` is pre-1.0: the public API is still being shaped by real usage.
This page states what can change and what you can rely on, so adopting the
package early is safe on both sides.

## Before v1.0.0

Any part of the API may change between minor releases, without a deprecation
cycle. What you can rely on:

- **The changelog is complete.** Renames, removals, and behavior changes are
listed in `CHANGELOG.md` under the release that made them; skim it before
upgrading.
- **Schemas refuse rather than misread.** The fit archive (`.fit.h5`) carries
a schema version and refuses to read or append across mismatched versions.
A format-version key for model YAML files is planned so future syntax
changes can warn or migrate instead of silently misparsing old files.
- **Version numbers signal risk.** Patch releases (`0.x.y` → `0.x.y+1`)
contain only fixes and backwards-compatible additions; anything breaking
lands in a minor release (`0.x` → `0.x+1`).

Pin to a minor version (e.g. `trspecfit>=0.12,<0.13`) where you need
reproducibility. Model YAML files and `.fit.h5` archives you accumulate are
treated as long-lived artifacts on our side.

## From v1.0.0 on

The user API — `Project`, `File`, `FitResults`, `Simulator`, `PlotConfig`
(the top-level exports) and the YAML model format — gains a deprecation
cycle: before a public name is removed or renamed, the old name keeps
working and emits a `DeprecationWarning` pointing to the replacement for
at least six months and at least one intervening minor release, whichever
is longer.

The rest of the importable surface (e.g. the model-building layer in
`trspecfit.mcp`) is not yet classified. An API-tier guide separating stable,
advanced, and internal modules is planned before v1.0.0; once it exists, the
stability commitment for the advanced tier will be stated there. Compiled
internals (`graph_ir`, `eval_1d`, `eval_2d`, `eval_jax`, and low-level
parsing/HDF5 helpers) carry no stability guarantees at any version.
122 changes: 122 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# trspecfit

> Python package for fitting 1D energy-resolved and 2D time-and-energy-resolved
> spectroscopy data. Models are composed from named components (peaks,
> backgrounds, convolution kernels) declared in YAML; parameters can be linked
> by expressions, evolve in time (dynamics), or vary along an auxiliary axis
> (profiles). Fitting builds on lmfit, with confidence intervals and optional
> MCMC. Install: `pip install trspecfit` (Python >= 3.12).

This file orients AI agents (and humans in a hurry) who want to *use*
trspecfit for analysis. If you are developing the package itself, read
`AGENTS.md` in the repository root instead.

## Core workflow

```python
from trspecfit import Project, File

project = Project(path='my_project', name='my_experiment')
file = File(parent_project=project, path='my_dataset',
data=data2d, energy=energy_axis, time=time_axis)

# 1) fit a static "baseline" model on the pre-trigger region
file.define_baseline(time_start=0, time_stop=3) # absolute time values
file.load_model('models_energy.yaml', 'base')
file.set_fit_limits(energy_limits=[...], time_limits=[...])
file.fit_baseline('base')

# 2) fit the full 2D dataset with time-dependent parameters
file.load_model('models_energy.yaml', '2D')
file.add_time_dependence('2D', 'GLP_01_x0', 'models_time.yaml', 'shift')
file.fit_2d('2D')

# 3) inspect results
df = file.get_fit_results(fit_type='2d') # pandas DataFrame
file.save_fit(...) # HDF5 fit archive
```

Simulation mirrors this: build a model the same way, then use
`trspecfit.Simulator` (`simulate_1d`, `simulate_2d`, `simulate_n`) to
generate clean or noisy synthetic data from it.

## YAML model format

One file holds multiple named models; each model lists components in order,
each component lists its parameters:

```yaml
base:
LinBack:
m: [1E-2, True, -1, 1] # [value, vary, min, max]
b: [0, False] # fixed parameter (short form)
xStart: [0, False]
xStop: [20, False]
GLP:
A: [10, True, 5, 15]
x0: [8, True, 5, 15]
F: [1.5, True, 0.75, 2.5]
m: [0.3, True, 0, 1]
GLP:
A: ["3/4*GLP_01_A"] # expression: linked to first GLP
x0: ["GLP_01_x0 + 3.67"] # fixed energy splitting
F: ["GLP_01_F"]
m: ["GLP_01_m"]
```

Component types (see the API reference for signatures):

- Peaks: `Gauss`, `GaussAsym`, `Lorentz`, `Voigt`, `GLP`, `GLS`, `DS`
- Backgrounds: `LinBack`, `Shirley`, `Offset`
- Time dynamics: `linFun`, `expFun`, `sinFun`, `sinDivX`, `erfFun`, `sqrtFun`
- Convolution kernels: `gaussCONV`, `expSymCONV`, `expDecayCONV`,
`expRiseCONV`, `boxCONV`
- Profile functions (auxiliary axis, always `p`-prefixed): `pExpDecay`,
`pLinear`, `pGauss`

## Naming and composition rules

- Repeated components are auto-numbered in YAML order: `GLP_01`, `GLP_02`.
Full parameter names join with underscores — `GLP_01_x0` — which is why
function and parameter names themselves never contain underscores.
- Attached dynamics/profile parameters extend the chain:
`GLP_01_A_expFun_01_tau` is the `tau` of the `expFun` dynamics on the `A`
of the first `GLP`. Use these full names in expressions and results.
- Supported composition: base parameter -> profile, then profile parameter
-> dynamics. Disallowed: profile + dynamics on the same base parameter,
and expression chains that pass through a time-dependent parameter.
- The full composition contract lives in the Supported Models page below —
treat it as the source of truth.

## Pitfalls for scripted / headless use

- Fit and plot methods display figures by default. Pass `show_plot=False`
where available, or `save_img=-1` (save without display; `0` = display,
`1` = both) when running outside a notebook.
- Time axes for multi-cycle dynamics should not sample exactly on subcycle
boundaries — assignment there flips with floating-point representation,
and `trspecfit` warns when it detects this.
- Pre-1.0, any API may change between minor releases; pin a minor version
(e.g. `trspecfit>=0.12,<0.13`) and read the changelog before upgrading.

## Docs

- [Quick start](https://time-resolved-spectroscopy-fit.readthedocs.io/en/latest/quickstart.html):
install, first notebook, the typical workflow step by step
- [Examples](https://time-resolved-spectroscopy-fit.readthedocs.io/en/latest/examples/index.html):
runnable notebooks — basic fitting, linked parameters, multi-cycle
dynamics, profiles, model comparison, save/load, uncertainty/MCMC,
multi-file workspaces, synthetic data
- [Supported models](https://time-resolved-spectroscopy-fit.readthedocs.io/en/latest/design/supported_models.html):
source of truth for model combinations, expressions, and compositions
- [API reference](https://time-resolved-spectroscopy-fit.readthedocs.io/en/latest/api/index.html):
all public classes and component-function signatures
- [Stability policy](https://time-resolved-spectroscopy-fit.readthedocs.io/en/latest/stability.html):
what can change before/after v1.0.0

## Optional

- [Repository](https://github.com/InfinityMonkeyAtWork/time-resolved-spectroscopy-fit/):
source, issues, and Q&A discussions
- [Architecture](https://time-resolved-spectroscopy-fit.readthedocs.io/en/latest/design/repo_architecture.html):
module map, only needed when reading the source
26 changes: 22 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "trspecfit"
version = "0.12.0"
version = "0.12.11"
authors = [
{name = "Johannes Mahl", email = "infinitymonkeyatwork@gmail.com"},
]
Expand Down Expand Up @@ -62,6 +62,7 @@ dev = [
"nbformat==5.10.4", # used by scripts/normalize_notebooks.py (pre-commit hook)
"nbstripout==0.9.1",
"pytest==9.1.1",
"pytest-xdist==3.8.0",
"pyright==1.1.411",
"ruff==0.15.20",
"build==1.5.0",
Expand Down Expand Up @@ -105,22 +106,39 @@ warn_unused_configs = true
ignore_missing_imports = true

[tool.pyright]
include = ["src"]
include = ["src", "tests"]
venvPath = "."
venv = ".venv"
pythonVersion = "3.12"
reportMissingImports = true
typeCheckingMode = "basic"

# Tests run at reduced strictness: File/Model attributes are `X | None` until
# a fit populates them, and tests access them post-fit constantly — narrowing
# every site would mean hundreds of asserts. Only Optional-driven rules are
# off; everything else stays at basic-mode severity.
[[tool.pyright.executionEnvironments]]
root = "tests"
reportOptionalMemberAccess = "none"
reportOptionalSubscript = "none"
reportOptionalOperand = "none"
reportOptionalIterable = "none"
reportArgumentType = "none"
reportCallIssue = "none"

[tool.pytest.ini_options]
pythonpath = ["src", "tests"]
testpaths = ["tests"]
addopts = "-m 'not slow'"
# parallel by default (pytest-xdist); tests/conftest.py pins BLAS to one
# thread per worker. For a sequential run (e.g. --pdb debugging) pass -n 0.
addopts = "-m 'not slow' -n auto --dist worksteal"
markers = ["slow: long-running round-trip tests (skipped by default, use -m slow)"]
filterwarnings = [
# test fixtures often sample exactly on subcycle boundaries (integer-step
# axes); the knife-edge warning is asserted explicitly via pytest.warns
"ignore:.*subcycle boundary:UserWarning",
"ignore:FigureCanvasAgg is non-interactive:UserWarning",
"ignore:invalid value encountered in scalar divide:RuntimeWarning:lmfit",
"ignore:This process .* use of fork\\(\\) may lead to deadlocks in the child\\.:DeprecationWarning",
]

[tool.ruff]
Expand Down
37 changes: 26 additions & 11 deletions src/trspecfit/fitlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import copy
import math
import multiprocessing
import pathlib
import time
from collections.abc import Callable, Sequence
Expand Down Expand Up @@ -842,17 +843,27 @@ def _method_kws(method: str) -> dict[str, Any]:
)
# burn necessary if starting point not close to max(probability distribution)
# i.e. not close to the optimized parameter set, so burn=0 is ok here!
emcee_fin = mini.emcee(
params=par_fin_params,
steps=mc_settings.steps,
nwalkers=mc_settings.nwalkers,
burn=mc_settings.burn,
thin=mc_settings.thin,
ntemps=mc_settings.ntemps,
workers=mc_settings.workers,
is_weighted=mc_settings.is_weighted,
progress=show_output >= 1,
)
emcee_kwargs: dict[str, Any] = {
"params": par_fin_params,
"steps": mc_settings.steps,
"nwalkers": mc_settings.nwalkers,
"burn": mc_settings.burn,
"thin": mc_settings.thin,
"ntemps": mc_settings.ntemps,
"is_weighted": mc_settings.is_weighted,
"progress": show_output >= 1,
}
if isinstance(mc_settings.workers, int) and mc_settings.workers > 1:
# lmfit would build a default-context Pool, which fork()s on
# Linux < 3.14 — deadlock-prone in multithreaded processes.
# Supply a spawn-backed pool instead (lmfit hands any object
# with .map to emcee), matching the slice-by-slice executor.
ctx = multiprocessing.get_context("spawn")
with ctx.Pool(mc_settings.workers) as pool:
# lmfit annotates workers as int but accepts pool-likes
emcee_fin = mini.emcee(workers=cast("int", pool), **emcee_kwargs)
else:
emcee_fin = mini.emcee(workers=mc_settings.workers, **emcee_kwargs)
emcee_fin_params = _result_params(emcee_fin)
emcee_flatchain = cast(
"pd.DataFrame", getattr(emcee_fin, "flatchain", pd.DataFrame())
Expand Down Expand Up @@ -941,6 +952,8 @@ def _method_kws(method: str) -> dict[str, Any]:
# optional save (figures are saved above)
# [if statements check for empty list/dataframe]
if abs(save_output) == 1:
# save_path is a file prefix; make sure its directory exists
pathlib.Path(save_path).parent.mkdir(parents=True, exist_ok=True)
# par_ini (pandas DataFrame) as csv file
df_par_ini.to_csv(
str(save_path) + "_par_ini.csv",
Expand Down Expand Up @@ -1092,6 +1105,7 @@ def results_to_df(
save_array.append(-2)

if do_save:
pathlib.Path(save_path).mkdir(parents=True, exist_ok=True)
# save the dataframe (index, x axis, parameter1, parameter2, ...
df.to_csv(
pathlib.Path(save_path) / "fit_pars.csv",
Expand Down Expand Up @@ -1223,6 +1237,7 @@ def results_to_fit_2d(
fit_2d = np.asarray(lst)
#
if abs(save_2d) == 1:
pathlib.Path(save_path).mkdir(parents=True, exist_ok=True)
np.savetxt(
pathlib.Path(save_path) / "fit_2d.csv", fit_2d, fmt=num_fmt, delimiter=delim
)
Expand Down
Loading