flimkit 0.13.8__tar.gz → 0.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. {flimkit-0.13.8 → flimkit-0.14.0}/PKG-INFO +26 -3
  2. {flimkit-0.13.8 → flimkit-0.14.0}/README.md +22 -1
  3. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/FLIM/fit_tools.py +84 -16
  4. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/FLIM/fitters.py +357 -91
  5. flimkit-0.14.0/flimkit/FLIM/nb_freetau.py +188 -0
  6. flimkit-0.14.0/flimkit/FLIM/nb_freetau_cuda.py +274 -0
  7. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/GPU/__init__.py +59 -7
  8. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/GPU/_base.py +17 -8
  9. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/GPU/torch_backend.py +122 -8
  10. flimkit-0.14.0/flimkit/GPU/torch_lm.py +122 -0
  11. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/controller.py +3 -0
  12. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/expert_settings.py +20 -25
  13. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/gui.py +405 -52
  14. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/phasor_panel.py +1 -2
  15. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/project_panel.py +29 -18
  16. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/roi_tools.py +1 -4
  17. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/_version.py +4 -2
  18. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/BH/reader.py +27 -13
  19. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/PS/reader.py +54 -31
  20. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/PTU/reader.py +6 -3
  21. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/PTU/stitch.py +13 -5
  22. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/signal.py +2 -2
  23. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/interactive.py +13 -1
  24. flimkit-0.14.0/flimkit/phasor/export.py +150 -0
  25. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/plugins/__init__.py +10 -0
  26. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/plugins/registry.py +78 -2
  27. flimkit-0.14.0/flimkit/utils/app_update.py +206 -0
  28. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/apply_settings.py +36 -6
  29. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/export_png.py +2 -2
  30. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/misc.py +33 -21
  31. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/update_check.py +25 -29
  32. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit.egg-info/PKG-INFO +26 -3
  33. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit.egg-info/SOURCES.txt +5 -0
  34. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit.egg-info/requires.txt +4 -1
  35. {flimkit-0.13.8 → flimkit-0.14.0}/pyproject.toml +4 -1
  36. {flimkit-0.13.8 → flimkit-0.14.0}/LICENSE.md +0 -0
  37. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/FLIM/__init__.py +0 -0
  38. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/FLIM/assemble.py +0 -0
  39. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/FLIM/batch.py +0 -0
  40. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/FLIM/bg_tools.py +0 -0
  41. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/FLIM/irf_tools.py +0 -0
  42. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/FLIM/models.py +0 -0
  43. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/GPU/cuda.py +0 -0
  44. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/GPU/mlx_backend.py +0 -0
  45. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/GPU/mps.py +0 -0
  46. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/GPU/rocm.py +0 -0
  47. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/app_state.py +0 -0
  48. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/fit_help.py +0 -0
  49. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/fov_preview.py +0 -0
  50. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/icon.icns +0 -0
  51. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/icon.ico +0 -0
  52. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/icon.png +0 -0
  53. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/irf_widget.py +0 -0
  54. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/mode_controller.py +0 -0
  55. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/modes/__init__.py +0 -0
  56. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/modes/base.py +0 -0
  57. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/modes/batch_mode.py +0 -0
  58. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/modes/fov_mode.py +0 -0
  59. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/modes/irf_mode.py +0 -0
  60. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/modes/phasor_mode.py +0 -0
  61. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/modes/stitch_mode.py +0 -0
  62. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/progress_window.py +0 -0
  63. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/results_panel.py +0 -0
  64. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/UI/utils.py +0 -0
  65. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/__init__.py +0 -0
  66. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/cli.py +0 -0
  67. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/configs.py +0 -0
  68. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/dialogs.py +0 -0
  69. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/BH/__init__.py +0 -0
  70. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/BH/writer.py +0 -0
  71. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/ISS/__init__.py +0 -0
  72. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/ISS/fdflim.py +0 -0
  73. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/ISS/image.py +0 -0
  74. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/ISS/reader.py +0 -0
  75. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/PS/__init__.py +0 -0
  76. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/PTU/__init__.py +0 -0
  77. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/PTU/decode.py +0 -0
  78. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/PTU/phu.py +0 -0
  79. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/PTU/series.py +0 -0
  80. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/PTU/tools.py +0 -0
  81. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/__init__.py +0 -0
  82. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/flim_file.py +0 -0
  83. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/formats/phasor.py +0 -0
  84. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/image/tools.py +0 -0
  85. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/mpl_backend.py +0 -0
  86. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/phasor/__init__.py +0 -0
  87. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/phasor/filters.py +0 -0
  88. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/phasor/fret.py +0 -0
  89. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/phasor/interactive.py +0 -0
  90. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/phasor/peaks.py +0 -0
  91. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/phasor/signal.py +0 -0
  92. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/phasor_launcher.py +0 -0
  93. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/plugins/bindings.py +0 -0
  94. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/plugins/builtin/__init__.py +0 -0
  95. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/plugins/builtin/core_tools.py +0 -0
  96. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/plugins/loader.py +0 -0
  97. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/project.py +0 -0
  98. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/synth.py +0 -0
  99. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/__init__.py +0 -0
  100. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/batch_fit.py +0 -0
  101. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/config_manager.py +0 -0
  102. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/config_snapshot.py +0 -0
  103. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/crash_handler.py +0 -0
  104. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/display.py +0 -0
  105. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/enhanced_outputs.py +0 -0
  106. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/fancy.py +0 -0
  107. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/lifetime_image.py +0 -0
  108. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/ome_zarr.py +0 -0
  109. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/plotting.py +0 -0
  110. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/roi.py +0 -0
  111. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/session.py +0 -0
  112. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/xlsx_tools.py +0 -0
  113. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit/utils/xml_utils.py +0 -0
  114. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit.egg-info/dependency_links.txt +0 -0
  115. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit.egg-info/entry_points.txt +0 -0
  116. {flimkit-0.13.8 → flimkit-0.14.0}/flimkit.egg-info/top_level.txt +0 -0
  117. {flimkit-0.13.8 → flimkit-0.14.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flimkit
3
- Version: 0.13.8
3
+ Version: 0.14.0
4
4
  Summary: Fluorescence lifetime imaging analysis: reading, fitting, phasors and stitching
5
5
  Author-email: Alex Hunt <alexander.hunt@ed.ac.uk>
6
6
  License-Expression: MIT
@@ -29,7 +29,7 @@ Requires-Dist: pandas==3.0.1
29
29
  Requires-Dist: Pillow>=10.0.0
30
30
  Requires-Dist: psutil
31
31
  Requires-Dist: phasorpy==0.12
32
- Requires-Dist: photonsfile
32
+ Requires-Dist: photonsfile>=2026.9.29.1
33
33
  Requires-Dist: ptufile
34
34
  Requires-Dist: PyWavelets>=1.4.0
35
35
  Requires-Dist: scipy==1.17.1
@@ -54,6 +54,8 @@ Provides-Extra: mlx
54
54
  Requires-Dist: mlx; extra == "mlx"
55
55
  Provides-Extra: torch
56
56
  Requires-Dist: torch; extra == "torch"
57
+ Provides-Extra: cuda
58
+ Requires-Dist: numba-cuda[cu12]; extra == "cuda"
57
59
  Provides-Extra: test
58
60
  Requires-Dist: pytest>=9.1.0; extra == "test"
59
61
  Provides-Extra: all
@@ -98,6 +100,9 @@ to every release.
98
100
  On macOS the app is self-signed, so the first launch needs right-click then
99
101
  Open. A double-click will be refused.
100
102
 
103
+ To update it later, Help > Check for Updates downloads the new build and swaps
104
+ it in place. On macOS, move the app out of Downloads first.
105
+
101
106
  This route gets the desktop application only. Everything below is for scripting,
102
107
  the terminal, or GPU fitting.
103
108
 
@@ -105,6 +110,19 @@ the terminal, or GPU fitting.
105
110
 
106
111
  Python ≥ 3.12 required (3.14 recommended, official builds use 3.14).
107
112
 
113
+ Make an environment first, with conda or uv:
114
+
115
+ ```bash
116
+ conda create -n flimkit -c conda-forge python=3.14 && conda activate flimkit
117
+ # or
118
+ uv venv --seed --python 3.14 flimkit-env && source flimkit-env/bin/activate
119
+ ```
120
+
121
+ With uv, `uv pip install` works in place of `pip install` below. `--seed` adds
122
+ pip, which `install.py` needs. The
123
+ [installation docs](https://github.com/FLIMKit/FLIMKit/wiki/Requirements-and-Installation)
124
+ cover Windows and which of the two to pick.
125
+
108
126
  For analysis in scripts, notebooks and the terminal:
109
127
 
110
128
  ```bash
@@ -131,6 +149,11 @@ For development work (PyInstaller + test dependencies):
131
149
  python install.py --dev
132
150
  ```
133
151
 
152
+ To update a clone later, `python update.py` pulls the latest code and reruns
153
+ `install.py`. It also rebuilds an app you built into `dist/`, and
154
+ `--app-path /Applications/FLIMKit.app` copies that build over the one you open.
155
+ `--dry-run` shows what it would do without changing anything.
156
+
134
157
  ## Docker / TrueNAS SCALE
135
158
 
136
159
  Pre-built images are on Docker Hub as `alex1075/flimkit`. They run FLIMKit with the [web UI](https://github.com/FLIMKit/flimkit-web-ui), so the whole app is used from a browser with nothing installed on the client.
@@ -289,7 +312,7 @@ Per-pixel fitting uses a batched matrix solver that runs on GPU when a supported
289
312
 
290
313
  `python install.py` detects your hardware and installs the right backend automatically. No extra flags needed at runtime.
291
314
 
292
- **Limitations:** `--free-tau-perpixel` mode uses batched Adam on GPU when a backend is available (n_exp ≥ 2). `fit_summed` (single global fit) is always CPU - it's fast enough not to matter.
315
+ **Limitations:** `--free-tau-perpixel` with two or more components prepares the batch on the GPU but fits each pixel with SciPy on the CPU, so a GPU gains little there. `fit_summed` (single global fit) is always CPU, and fast enough not to matter.
293
316
 
294
317
  **Compiled app and GPU:** The compiled app bundles whatever GPU libraries are installed on the *build* machine. A binary built on Apple Silicon will have MLX/MPS; one built on a CUDA machine will have CUDA. If you need GPU in the compiled app, build it yourself on the target hardware. See [Compiled App](#compiled-app-macos--windows--linux).
295
318
 
@@ -36,6 +36,9 @@ to every release.
36
36
  On macOS the app is self-signed, so the first launch needs right-click then
37
37
  Open. A double-click will be refused.
38
38
 
39
+ To update it later, Help > Check for Updates downloads the new build and swaps
40
+ it in place. On macOS, move the app out of Downloads first.
41
+
39
42
  This route gets the desktop application only. Everything below is for scripting,
40
43
  the terminal, or GPU fitting.
41
44
 
@@ -43,6 +46,19 @@ the terminal, or GPU fitting.
43
46
 
44
47
  Python ≥ 3.12 required (3.14 recommended, official builds use 3.14).
45
48
 
49
+ Make an environment first, with conda or uv:
50
+
51
+ ```bash
52
+ conda create -n flimkit -c conda-forge python=3.14 && conda activate flimkit
53
+ # or
54
+ uv venv --seed --python 3.14 flimkit-env && source flimkit-env/bin/activate
55
+ ```
56
+
57
+ With uv, `uv pip install` works in place of `pip install` below. `--seed` adds
58
+ pip, which `install.py` needs. The
59
+ [installation docs](https://github.com/FLIMKit/FLIMKit/wiki/Requirements-and-Installation)
60
+ cover Windows and which of the two to pick.
61
+
46
62
  For analysis in scripts, notebooks and the terminal:
47
63
 
48
64
  ```bash
@@ -69,6 +85,11 @@ For development work (PyInstaller + test dependencies):
69
85
  python install.py --dev
70
86
  ```
71
87
 
88
+ To update a clone later, `python update.py` pulls the latest code and reruns
89
+ `install.py`. It also rebuilds an app you built into `dist/`, and
90
+ `--app-path /Applications/FLIMKit.app` copies that build over the one you open.
91
+ `--dry-run` shows what it would do without changing anything.
92
+
72
93
  ## Docker / TrueNAS SCALE
73
94
 
74
95
  Pre-built images are on Docker Hub as `alex1075/flimkit`. They run FLIMKit with the [web UI](https://github.com/FLIMKit/flimkit-web-ui), so the whole app is used from a browser with nothing installed on the client.
@@ -227,7 +248,7 @@ Per-pixel fitting uses a batched matrix solver that runs on GPU when a supported
227
248
 
228
249
  `python install.py` detects your hardware and installs the right backend automatically. No extra flags needed at runtime.
229
250
 
230
- **Limitations:** `--free-tau-perpixel` mode uses batched Adam on GPU when a backend is available (n_exp ≥ 2). `fit_summed` (single global fit) is always CPU - it's fast enough not to matter.
251
+ **Limitations:** `--free-tau-perpixel` with two or more components prepares the batch on the GPU but fits each pixel with SciPy on the CPU, so a GPU gains little there. `fit_summed` (single global fit) is always CPU, and fast enough not to matter.
231
252
 
232
253
  **Compiled app and GPU:** The compiled app bundles whatever GPU libraries are installed on the *build* machine. A binary built on Apple Silicon will have MLX/MPS; one built on a CUDA machine will have CUDA. If you need GPU in the compiled app, build it yourself on the target hardware. See [Compiled App](#compiled-app-macos--windows--linux).
233
254
 
@@ -2,12 +2,10 @@ import numpy as np
2
2
  from scipy.ndimage import gaussian_filter1d
3
3
  from scipy.optimize import curve_fit
4
4
 
5
-
6
5
  def distribution_dof(n_fit, n_components=1, fit_tvb=False):
7
6
  n_params = 3 * n_components + 1 + int(fit_tvb)
8
7
  return max(int(n_fit) - n_params, 1)
9
8
 
10
-
11
9
  def chi2_terms(data, model, axis=None, dtype=float):
12
10
  data_arr, model_arr = np.broadcast_arrays(
13
11
  np.asarray(data, dtype=dtype), np.asarray(model, dtype=dtype))
@@ -22,7 +20,6 @@ def chi2_terms(data, model, axis=None, dtype=float):
22
20
  expected = np.sum(np.minimum(model_arr, one), axis=axis)
23
21
  return numerator, expected, valid
24
22
 
25
-
26
23
  def calibrated_from_terms(numerator, expected, valid):
27
24
  if np.ndim(expected) == 0:
28
25
  return (float(numerator / expected)
@@ -32,14 +29,12 @@ def calibrated_from_terms(numerator, expected, valid):
32
29
  numerator, expected, out=result,
33
30
  where=valid & (expected > 0) & np.isfinite(numerator))
34
31
 
35
-
36
32
  def calibrated_chi2(data, model, axis=None):
37
33
  numerator, expected, valid = chi2_terms(data, model, axis=axis)
38
34
  if np.ndim(expected) == 0:
39
35
  return float(numerator / expected) if valid and expected > 0 else np.nan
40
36
  return calibrated_from_terms(numerator, expected, valid)
41
37
 
42
-
43
38
  def coates_pileup_correction(decay: np.ndarray, n_sync: int):
44
39
  m = np.asarray(decay, dtype=float)
45
40
  n_s = float(n_sync or 0.0)
@@ -50,7 +45,6 @@ def coates_pileup_correction(decay: np.ndarray, n_sync: int):
50
45
  f'n_sync={n_s:,.0f} for {total:,.0f} photons. N_sync must exceed the photon count.')
51
46
  cumulative = np.concatenate([[0.0], np.cumsum(m[:-1])])
52
47
  denom = n_s - cumulative
53
- # Argument of log: 1 - M(t)/(N_s - C(t))
54
48
  with np.errstate(divide='ignore', invalid='ignore'):
55
49
  ratio = np.where(denom > 0, m / denom, 0.0)
56
50
  arg = 1.0 - ratio
@@ -62,9 +56,6 @@ def bins_from_ns(value_ns, tcspc_res):
62
56
  return int(round(float(value_ns) * 1e-9 / tcspc_res))
63
57
 
64
58
  def build_fit_idx(fit_start, fit_end, n_bins, exclude_bins=None):
65
- # explicit bin set the fit runs over: a contiguous window minus any excluded
66
- # bands (eg a reflection peak mid-decay). Dropping bins keeps the fit linear
67
- # in the amplitudes, so the per-pixel projection is unaffected.
68
59
  idx = np.arange(max(0, int(fit_start)), min(int(n_bins), int(fit_end)), dtype=int)
69
60
  if exclude_bins:
70
61
  keep = np.ones(idx.shape, dtype=bool)
@@ -78,7 +69,6 @@ def build_fit_idx(fit_start, fit_end, n_bins, exclude_bins=None):
78
69
  def find_irf_peak_bin(decay: np.ndarray, smooth_sigma: float = 1.5):
79
70
  smoothed = gaussian_filter1d(decay.astype(float), sigma=smooth_sigma)
80
71
  deriv = np.gradient(smoothed)
81
- # Only search in the first half of the histogram (rising edge region)
82
72
  half = len(decay) // 2
83
73
  peak_bin = int(np.argmax(deriv[:half]))
84
74
  return peak_bin
@@ -109,7 +99,6 @@ def find_fit_start(decay: np.ndarray, irf_prompt: np.ndarray,
109
99
  fit_start = max(0, onset - pre_bins)
110
100
  return fit_start
111
101
 
112
-
113
102
  def find_tail_fit_start(decay, peak_bin, n_bins, frac=0.9):
114
103
  d = np.asarray(decay, dtype=float)
115
104
  peak_val = float(d[peak_bin]) if 0 <= peak_bin < n_bins else 0.0
@@ -165,8 +154,8 @@ def find_fit_end(decay, peak_bin, tau_max_s, tcspc_res, n_bins):
165
154
  if len(spikes) > 0:
166
155
  spike_abs = search_start + spikes[0]
167
156
  if spike_abs < candidate:
168
- print(f" Next-period artefact at bin {spike_abs} "
169
- f"({spike_abs*tcspc_res*1e9:.2f} ns). Truncating fit window.")
157
+ print(f' Next-period artefact at bin {spike_abs} '
158
+ f'({spike_abs*tcspc_res*1e9:.2f} ns). Truncating fit window.')
170
159
  candidate = spike_abs
171
160
  return candidate
172
161
 
@@ -207,7 +196,6 @@ def _build_bounds_dist(n_components, tau_min, tau_max, decay_peak,
207
196
  lo += [0.0]; hi += [_tvb_hi]
208
197
  return lo, hi
209
198
 
210
-
211
199
  def _pack_p0_dist(n_components, tau_min, tau_max, decay_peak,
212
200
  fit_bg, fit_sigma, bg_init, fit_tvb=False, tvb_init=0.0):
213
201
  tmin = max(tau_min, 1e-14) * 1.001
@@ -224,7 +212,6 @@ def _pack_p0_dist(n_components, tau_min, tau_max, decay_peak,
224
212
  base = np.concatenate([base, [tvb_init]])
225
213
  return base
226
214
 
227
-
228
215
  def _pack_p0(n_exp, tau_min, tau_max, decay_peak,
229
216
  has_tail, fit_bg, fit_sigma, bg_init,
230
217
  tau_override=None, fit_tvb=False, tvb_init=0.0):
@@ -244,4 +231,85 @@ def _pack_p0(n_exp, tau_min, tau_max, decay_peak,
244
231
  base = np.concatenate([base, [tvb_init]])
245
232
  if has_tail:
246
233
  base = np.concatenate([base, [0.5, 20.0]])
247
- return base
234
+ return base
235
+
236
+ def _param_scale(p, lo, hi):
237
+ span = hi - lo
238
+ scale = np.where(np.abs(p) > 0, np.abs(p), np.where(np.isfinite(span), span, 1.0))
239
+ return np.maximum(scale, 1e-300)
240
+
241
+ def fit_uncertainties(model_fn, popt, fit_idx, lo=None, hi=None, rel_step=1e-4):
242
+ p = np.asarray(popt, dtype=float)
243
+ k = p.size
244
+ lo = np.full(k, -np.inf) if lo is None else np.asarray(lo, dtype=float)
245
+ hi = np.full(k, np.inf) if hi is None else np.asarray(hi, dtype=float)
246
+ span = hi - lo
247
+ tol = np.where(np.isfinite(span), 1e-6 * span, 0.0)
248
+ free = ~((p - lo <= tol) | (hi - p <= tol))
249
+ scale = _param_scale(p, lo, hi)
250
+ m0 = np.asarray(model_fn(p), dtype=float)[fit_idx]
251
+ jac = np.zeros((len(fit_idx), k))
252
+ for j in np.flatnonzero(free):
253
+ h = rel_step * scale[j]
254
+ up = p.copy()
255
+ dn = p.copy()
256
+ if hi[j] - p[j] >= h and p[j] - lo[j] >= h:
257
+ up[j] += h
258
+ dn[j] -= h
259
+ width = 2.0 * h
260
+ elif hi[j] - p[j] >= h:
261
+ up[j] += h
262
+ width = h
263
+ else:
264
+ dn[j] -= h
265
+ width = h
266
+ diff = np.asarray(model_fn(up), dtype=float)[fit_idx] - np.asarray(model_fn(dn), dtype=float)[fit_idx]
267
+ jac[:, j] = diff / width * scale[j]
268
+ live = free & np.any(jac != 0.0, axis=0)
269
+ idx = np.flatnonzero(live)
270
+ cov = np.full((k, k), np.nan)
271
+ if idx.size:
272
+ js = jac[:, idx]
273
+ weight = 1.0 / np.maximum(m0, 1.0)
274
+ fisher = js.T @ (js * weight[:, None])
275
+ try:
276
+ cov_s = np.linalg.inv(fisher)
277
+ except np.linalg.LinAlgError:
278
+ cov_s = np.linalg.pinv(fisher)
279
+ cov[np.ix_(idx, idx)] = cov_s * np.outer(scale[idx], scale[idx])
280
+ diag = np.diag(cov)
281
+ stderr = np.where(np.isfinite(diag) & (diag >= 0), np.sqrt(np.abs(diag)), np.nan)
282
+ with np.errstate(invalid='ignore', divide='ignore'):
283
+ corr = cov / np.outer(stderr, stderr)
284
+ return dict(cov=cov, stderr=stderr, corr=corr, free=live, scale=scale)
285
+
286
+ def propagate_uncertainty(fn, popt, unc, rel_step=1e-4):
287
+ p = np.asarray(popt, dtype=float)
288
+ idx = np.flatnonzero(unc['free'])
289
+ if idx.size == 0:
290
+ return np.nan
291
+ grad = np.zeros(idx.size)
292
+ for n, j in enumerate(idx):
293
+ h = rel_step * unc['scale'][j]
294
+ up = p.copy()
295
+ dn = p.copy()
296
+ up[j] += h
297
+ dn[j] -= h
298
+ grad[n] = (float(fn(up)) - float(fn(dn))) / (2.0 * h)
299
+ var = float(grad @ unc['cov'][np.ix_(idx, idx)] @ grad)
300
+ return float(np.sqrt(var)) if np.isfinite(var) and var >= 0 else np.nan
301
+
302
+ def uncertainty_warnings(taus_ns, taus_err_ns, tau_corr, rel_limit=0.1, corr_limit=0.95):
303
+ notes = []
304
+ taus_ns = np.atleast_1d(np.asarray(taus_ns, dtype=float))
305
+ errs = np.atleast_1d(np.asarray(taus_err_ns, dtype=float))
306
+ for i, (tau, err) in enumerate(zip(taus_ns, errs), start=1):
307
+ if np.isfinite(err) and tau > 0 and err / tau > rel_limit:
308
+ notes.append(f'τ{i} is poorly determined: ±{err:.3g} ns on {tau:.3g} ns ({100 * err / tau:.0f}%)')
309
+ corr = np.atleast_2d(np.asarray(tau_corr, dtype=float))
310
+ for i in range(corr.shape[0]):
311
+ for j in range(i + 1, corr.shape[1]):
312
+ if np.isfinite(corr[i, j]) and abs(corr[i, j]) > corr_limit:
313
+ notes.append(f'τ{i + 1} and τ{j + 1} are strongly correlated (r = {corr[i, j]:+.2f}): '
314
+ f'the data cannot separate them well')
315
+ return notes