jaxspec 0.1.3__tar.gz → 0.2.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 (39) hide show
  1. {jaxspec-0.1.3 → jaxspec-0.2.0}/PKG-INFO +36 -16
  2. {jaxspec-0.1.3 → jaxspec-0.2.0}/README.md +21 -0
  3. {jaxspec-0.1.3 → jaxspec-0.2.0}/pyproject.toml +16 -18
  4. jaxspec-0.2.0/src/jaxspec/_fit/_build_model.py +63 -0
  5. jaxspec-0.2.0/src/jaxspec/analysis/_plot.py +194 -0
  6. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/analysis/results.py +238 -336
  7. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/data/instrument.py +47 -12
  8. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/data/obsconf.py +12 -2
  9. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/data/observation.py +68 -11
  10. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/data/ogip.py +32 -13
  11. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/data/util.py +5 -75
  12. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/fit.py +101 -140
  13. jaxspec-0.2.0/src/jaxspec/model/_graph_util.py +151 -0
  14. jaxspec-0.2.0/src/jaxspec/model/abc.py +437 -0
  15. jaxspec-0.2.0/src/jaxspec/model/additive.py +531 -0
  16. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/model/background.py +94 -87
  17. jaxspec-0.2.0/src/jaxspec/model/multiplicative.py +254 -0
  18. jaxspec-0.2.0/src/jaxspec/scripts/__init__.py +0 -0
  19. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/scripts/debug.py +1 -1
  20. jaxspec-0.2.0/src/jaxspec/util/__init__.py +0 -0
  21. jaxspec-0.1.3/src/jaxspec/util/__init__.py → jaxspec-0.2.0/src/jaxspec/util/misc.py +1 -21
  22. jaxspec-0.2.0/src/jaxspec/util/typing.py +5 -0
  23. jaxspec-0.1.3/src/jaxspec/analysis/_plot.py +0 -35
  24. jaxspec-0.1.3/src/jaxspec/data/grouping.py +0 -23
  25. jaxspec-0.1.3/src/jaxspec/model/abc.py +0 -576
  26. jaxspec-0.1.3/src/jaxspec/model/additive.py +0 -544
  27. jaxspec-0.1.3/src/jaxspec/model/multiplicative.py +0 -238
  28. jaxspec-0.1.3/src/jaxspec/util/typing.py +0 -68
  29. {jaxspec-0.1.3 → jaxspec-0.2.0}/LICENSE.md +0 -0
  30. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/__init__.py +0 -0
  31. {jaxspec-0.1.3/src/jaxspec/analysis → jaxspec-0.2.0/src/jaxspec/_fit}/__init__.py +0 -0
  32. {jaxspec-0.1.3/src/jaxspec/model → jaxspec-0.2.0/src/jaxspec/analysis}/__init__.py +0 -0
  33. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/analysis/compare.py +0 -0
  34. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/data/__init__.py +0 -0
  35. {jaxspec-0.1.3/src/jaxspec/scripts → jaxspec-0.2.0/src/jaxspec/model}/__init__.py +0 -0
  36. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/model/list.py +0 -0
  37. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/util/abundance.py +0 -0
  38. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/util/integrate.py +0 -0
  39. {jaxspec-0.1.3 → jaxspec-0.2.0}/src/jaxspec/util/online_storage.py +0 -0
@@ -1,43 +1,42 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: jaxspec
3
- Version: 0.1.3
3
+ Version: 0.2.0
4
4
  Summary: jaxspec is a bayesian spectral fitting library for X-ray astronomy.
5
- Home-page: https://github.com/renecotyfanboy/jaxspec
6
5
  License: MIT
7
6
  Author: sdupourque
8
7
  Author-email: sdupourque@irap.omp.eu
9
- Requires-Python: >=3.10,<3.12
8
+ Requires-Python: >=3.10,<3.13
10
9
  Classifier: License :: OSI Approved :: MIT License
11
10
  Classifier: Programming Language :: Python :: 3
12
11
  Classifier: Programming Language :: Python :: 3.10
13
12
  Classifier: Programming Language :: Python :: 3.11
14
- Requires-Dist: arviz (>=0.17.1,<0.20.0)
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Dist: arviz (>=0.17.1,<0.21.0)
15
15
  Requires-Dist: astropy (>=6.0.0,<7.0.0)
16
+ Requires-Dist: catppuccin (>=2.3.4,<3.0.0)
16
17
  Requires-Dist: chainconsumer (>=1.1.2,<2.0.0)
17
18
  Requires-Dist: cmasher (>=1.6.3,<2.0.0)
18
- Requires-Dist: dm-haiku (>=0.0.12,<0.0.13)
19
- Requires-Dist: gpjax (>=0.8.0,<0.9.0)
19
+ Requires-Dist: flax (>=0.10.1,<0.11.0)
20
20
  Requires-Dist: interpax (>=0.3.3,<0.4.0)
21
- Requires-Dist: jax (>=0.4.33,<0.5.0)
22
- Requires-Dist: jaxlib (>=0.4.30,<0.5.0)
23
- Requires-Dist: jaxns (<2.6)
21
+ Requires-Dist: jax (>=0.4.37,<0.5.0)
22
+ Requires-Dist: jaxns (>=2.6.7,<3.0.0)
24
23
  Requires-Dist: jaxopt (>=0.8.1,<0.9.0)
25
24
  Requires-Dist: matplotlib (>=3.8.0,<4.0.0)
26
- Requires-Dist: mendeleev (>=0.15,<0.18)
25
+ Requires-Dist: mendeleev (>=0.15,<0.20)
27
26
  Requires-Dist: networkx (>=3.1,<4.0)
28
27
  Requires-Dist: numpy (<2.0.0)
29
- Requires-Dist: numpyro (>=0.15.3,<0.16.0)
30
- Requires-Dist: optimistix (>=0.0.7,<0.0.8)
28
+ Requires-Dist: numpyro (>=0.16.1,<0.17.0)
29
+ Requires-Dist: optimistix (>=0.0.7,<0.0.10)
31
30
  Requires-Dist: pandas (>=2.2.0,<3.0.0)
32
31
  Requires-Dist: pooch (>=1.8.2,<2.0.0)
33
- Requires-Dist: pyzmq (<27)
34
32
  Requires-Dist: scipy (<1.15)
35
33
  Requires-Dist: seaborn (>=0.13.1,<0.14.0)
36
- Requires-Dist: simpleeval (>=0.9.13,<0.10.0)
37
- Requires-Dist: sparse (>=0.15.1,<0.16.0)
34
+ Requires-Dist: simpleeval (>=0.9.13,<1.1.0)
35
+ Requires-Dist: sparse (>=0.15.4,<0.16.0)
38
36
  Requires-Dist: tinygp (>=0.3.0,<0.4.0)
39
37
  Requires-Dist: watermark (>=2.4.3,<3.0.0)
40
38
  Project-URL: Documentation, https://jaxspec.readthedocs.io/en/latest/
39
+ Project-URL: Homepage, https://github.com/renecotyfanboy/jaxspec
41
40
  Description-Content-Type: text/markdown
42
41
 
43
42
  <p align="center">
@@ -78,3 +77,24 @@ Once the environment is set up, you can install jaxspec directly from pypi
78
77
  pip install jaxspec --upgrade
79
78
  ```
80
79
 
80
+ ## Citation
81
+
82
+ If you use `jaxspec` in your research, please consider citing the following article
83
+
84
+ ```
85
+ @ARTICLE{2024A&A...690A.317D,
86
+ author = {{Dupourqu{\'e}}, S. and {Barret}, D. and {Diez}, C.~M. and {Guillot}, S. and {Quintin}, E.},
87
+ title = "{jaxspec: A fast and robust Python library for X-ray spectral fitting}",
88
+ journal = {\aap},
89
+ keywords = {methods: data analysis, methods: statistical, X-rays: general},
90
+ year = 2024,
91
+ month = oct,
92
+ volume = {690},
93
+ eid = {A317},
94
+ pages = {A317},
95
+ doi = {10.1051/0004-6361/202451736},
96
+ adsurl = {https://ui.adsabs.harvard.edu/abs/2024A&A...690A.317D},
97
+ adsnote = {Provided by the SAO/NASA Astrophysics Data System}
98
+ }
99
+ ```
100
+
@@ -35,3 +35,24 @@ Once the environment is set up, you can install jaxspec directly from pypi
35
35
  ```
36
36
  pip install jaxspec --upgrade
37
37
  ```
38
+
39
+ ## Citation
40
+
41
+ If you use `jaxspec` in your research, please consider citing the following article
42
+
43
+ ```
44
+ @ARTICLE{2024A&A...690A.317D,
45
+ author = {{Dupourqu{\'e}}, S. and {Barret}, D. and {Diez}, C.~M. and {Guillot}, S. and {Quintin}, E.},
46
+ title = "{jaxspec: A fast and robust Python library for X-ray spectral fitting}",
47
+ journal = {\aap},
48
+ keywords = {methods: data analysis, methods: statistical, X-rays: general},
49
+ year = 2024,
50
+ month = oct,
51
+ volume = {690},
52
+ eid = {A317},
53
+ pages = {A317},
54
+ doi = {10.1051/0004-6361/202451736},
55
+ adsurl = {https://ui.adsabs.harvard.edu/abs/2024A&A...690A.317D},
56
+ adsnote = {Provided by the SAO/NASA Astrophysics Data System}
57
+ }
58
+ ```
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "jaxspec"
3
- version = "0.1.3"
3
+ version = "0.2.0"
4
4
  description = "jaxspec is a bayesian spectral fitting library for X-ray astronomy."
5
5
  authors = ["sdupourque <sdupourque@irap.omp.eu>"]
6
6
  license = "MIT"
@@ -10,39 +10,37 @@ documentation = "https://jaxspec.readthedocs.io/en/latest/"
10
10
 
11
11
 
12
12
  [tool.poetry.dependencies]
13
- python = ">=3.10,<3.12"
14
- jax = "^0.4.33"
15
- jaxlib = "^0.4.30"
13
+ python = ">=3.10,<3.13"
14
+ jax = "^0.4.37"
16
15
  numpy = "<2.0.0"
17
16
  pandas = "^2.2.0"
18
17
  astropy = "^6.0.0"
19
- numpyro = "^0.15.3"
20
- dm-haiku = "^0.0.12"
18
+ numpyro = "^0.16.1"
21
19
  networkx = "^3.1"
22
20
  matplotlib = "^3.8.0"
23
- arviz = ">=0.17.1,<0.20.0"
21
+ arviz = ">=0.17.1,<0.21.0"
24
22
  chainconsumer = "^1.1.2"
25
- simpleeval = "^0.9.13"
23
+ simpleeval = ">=0.9.13,<1.1.0"
26
24
  cmasher = "^1.6.3"
27
- gpjax = "^0.8.0"
28
25
  jaxopt = "^0.8.1"
29
26
  tinygp = "^0.3.0"
30
27
  seaborn = "^0.13.1"
31
- sparse = "^0.15.1"
32
- optimistix = "^0.0.7"
28
+ sparse = "^0.15.4"
29
+ optimistix = ">=0.0.7,<0.0.10"
33
30
  scipy = "<1.15"
34
- mendeleev = ">=0.15,<0.18"
35
- pyzmq = "<27"
36
- jaxns = "<2.6"
31
+ mendeleev = ">=0.15,<0.20"
32
+ jaxns = "^2.6.7"
37
33
  pooch = "^1.8.2"
38
34
  interpax = "^0.3.3"
39
35
  watermark = "^2.4.3"
36
+ catppuccin = "^2.3.4"
37
+ flax = "^0.10.1"
40
38
 
41
39
 
42
40
  [tool.poetry.group.docs.dependencies]
43
41
  mkdocs = "^1.6.1"
44
42
  mkdocs-material = "^9.4.6"
45
- mkdocstrings = {extras = ["python"], version = ">=0.24,<0.27"}
43
+ mkdocstrings = {extras = ["python"], version = ">=0.24,<0.28"}
46
44
  mkdocs-jupyter = "^0.25.0"
47
45
 
48
46
 
@@ -50,15 +48,15 @@ mkdocs-jupyter = "^0.25.0"
50
48
  chex = "^0.1.83"
51
49
  mktestdocs = "^0.2.1"
52
50
  coverage = "^7.3.2"
53
- pytest-cov = ">=4.1,<6.0"
51
+ pytest-cov = ">=4.1,<7.0"
54
52
  flake8 = "^7.0.0"
55
53
  pytest = "^8.0.0"
56
54
  testbook = "^0.4.2"
57
55
 
58
56
 
59
57
  [tool.poetry.group.dev.dependencies]
60
- pre-commit = "^3.5.0"
61
- ruff = ">=0.2.1,<0.7.0"
58
+ pre-commit = ">=3.5,<5.0"
59
+ ruff = ">=0.2.1,<0.9.0"
62
60
  jupyterlab = "^4.0.7"
63
61
  notebook = "^7.0.6"
64
62
  ipywidgets = "^8.1.1"
@@ -0,0 +1,63 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ import jax.numpy as jnp
4
+ import numpy as np
5
+ import numpyro
6
+
7
+ from jax.experimental.sparse import BCOO
8
+ from jax.typing import ArrayLike
9
+ from numpyro.distributions import Distribution
10
+
11
+ if TYPE_CHECKING:
12
+ from ..data import ObsConfiguration
13
+ from ..model.abc import SpectralModel
14
+ from ..util.typing import PriorDictType
15
+
16
+
17
+ def forward_model(
18
+ model: "SpectralModel",
19
+ parameters,
20
+ obs_configuration: "ObsConfiguration",
21
+ sparse=False,
22
+ ):
23
+ energies = np.asarray(obs_configuration.in_energies)
24
+
25
+ if sparse:
26
+ # folding.transfer_matrix.data.density > 0.015 is a good criterion to consider sparsify
27
+ transfer_matrix = BCOO.from_scipy_sparse(
28
+ obs_configuration.transfer_matrix.data.to_scipy_sparse().tocsr()
29
+ )
30
+
31
+ else:
32
+ transfer_matrix = np.asarray(obs_configuration.transfer_matrix.data.todense())
33
+
34
+ expected_counts = transfer_matrix @ model.photon_flux(parameters, *energies)
35
+
36
+ # The result is clipped at 1e-6 to avoid 0 round-off and diverging likelihoods
37
+ return jnp.clip(expected_counts, a_min=1e-6)
38
+
39
+
40
+ def build_prior(prior: "PriorDictType", expand_shape: tuple = (), prefix=""):
41
+ """
42
+ Transform a dictionary of prior distributions into a dictionary of parameters sampled from the prior.
43
+ Must be used within a numpyro model.
44
+ """
45
+ parameters = {}
46
+
47
+ for key, value in prior.items():
48
+ # Split the key to extract the module name and parameter name
49
+ module_name, param_name = key.rsplit("_", 1)
50
+ if isinstance(value, Distribution):
51
+ parameters[key] = jnp.ones(expand_shape) * numpyro.sample(
52
+ f"{prefix}{module_name}_{param_name}", value
53
+ )
54
+
55
+ elif isinstance(value, ArrayLike):
56
+ parameters[key] = jnp.ones(expand_shape) * value
57
+
58
+ else:
59
+ raise ValueError(
60
+ f"Invalid prior type {type(value)} for parameter {prefix}{module_name}_{param_name} : {value}"
61
+ )
62
+
63
+ return parameters
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import catppuccin
4
+ import matplotlib.pyplot as plt
5
+ import numpy as np
6
+
7
+ from astropy import units as u
8
+ from catppuccin.extras.matplotlib import load_color
9
+ from cycler import cycler
10
+ from jax.typing import ArrayLike
11
+ from scipy.integrate import trapezoid
12
+ from scipy.stats import nbinom, norm
13
+
14
+ from jaxspec.data import ObsConfiguration
15
+
16
+ PALETTE = catppuccin.PALETTE.latte
17
+
18
+ COLOR_CYCLE = [
19
+ load_color(PALETTE.identifier, color)
20
+ for color in ["sky", "teal", "green", "yellow", "peach", "maroon", "red", "pink", "mauve"][::-1]
21
+ ]
22
+
23
+ LINESTYLE_CYCLE = ["dashed", "dotted", "dashdot", "solid"]
24
+
25
+ SPECS_CYCLE = cycler(linestyle=LINESTYLE_CYCLE) * cycler(color=COLOR_CYCLE)
26
+
27
+ SPECTRUM_COLOR = load_color(PALETTE.identifier, "blue")
28
+ SPECTRUM_DATA_COLOR = load_color(PALETTE.identifier, "overlay2")
29
+ BACKGROUND_COLOR = load_color(PALETTE.identifier, "sapphire")
30
+ BACKGROUND_DATA_COLOR = load_color(PALETTE.identifier, "overlay0")
31
+
32
+
33
+ def sigma_to_percentile_intervals(sigmas):
34
+ intervals = []
35
+ for sigma in sigmas:
36
+ lower_bound = 100 * norm.cdf(-sigma)
37
+ upper_bound = 100 * norm.cdf(sigma)
38
+ intervals.append((lower_bound, upper_bound))
39
+ return intervals
40
+
41
+
42
+ def _plot_poisson_data_with_error(
43
+ ax: plt.Axes,
44
+ x_bins: ArrayLike,
45
+ y: ArrayLike,
46
+ y_low: ArrayLike,
47
+ y_high: ArrayLike,
48
+ color=SPECTRUM_DATA_COLOR,
49
+ linestyle="none",
50
+ alpha=0.3,
51
+ ):
52
+ """
53
+ Plot Poisson data with error bars. We extrapolate the intrinsic error of the observation assuming a prior rate
54
+ distributed according to a Gamma RV.
55
+ """
56
+
57
+ ax_to_plot = ax.errorbar(
58
+ np.sqrt(x_bins[0] * x_bins[1]),
59
+ y,
60
+ xerr=np.abs(x_bins - np.sqrt(x_bins[0] * x_bins[1])),
61
+ yerr=[
62
+ y - y_low,
63
+ y_high - y,
64
+ ],
65
+ color=color,
66
+ linestyle=linestyle,
67
+ alpha=alpha,
68
+ capsize=2,
69
+ )
70
+
71
+ return ax_to_plot
72
+
73
+
74
+ def _plot_binned_samples_with_error(
75
+ ax: plt.Axes,
76
+ x_bins: ArrayLike,
77
+ y_samples: ArrayLike,
78
+ color=SPECTRUM_COLOR,
79
+ alpha_median: float = 0.7,
80
+ alpha_envelope: (float, float) = (0.15, 0.25),
81
+ linestyle="solid",
82
+ n_sigmas=3,
83
+ ):
84
+ """
85
+ Helper function to plot the posterior predictive distribution of the model. The function
86
+ computes the percentiles of the posterior predictive distribution and plot them as a shaded
87
+ area. If the observed data is provided, it is also plotted as a step function.
88
+
89
+ Parameters:
90
+ x_bins: The bin edges of the data (2 x N).
91
+ y_samples: The samples of the posterior predictive distribution (Samples X N).
92
+ ax: The matplotlib axes object.
93
+ color: The color of the posterior predictive distribution.
94
+ """
95
+
96
+ median = ax.stairs(
97
+ list(np.median(y_samples, axis=0)),
98
+ edges=[*list(x_bins[0]), x_bins[1][-1]],
99
+ color=color,
100
+ alpha=alpha_median,
101
+ linestyle=linestyle,
102
+ )
103
+
104
+ # The legend cannot handle fill_between, so we pass a fill to get a fancy icon
105
+ (envelope,) = ax.fill(np.nan, np.nan, alpha=alpha_envelope[-1], facecolor=color)
106
+
107
+ if n_sigmas == 1:
108
+ alpha_envelope = (alpha_envelope[1], alpha_envelope[0])
109
+
110
+ for percentile, alpha in zip(
111
+ sigma_to_percentile_intervals(list(range(n_sigmas, 0, -1))),
112
+ np.linspace(*alpha_envelope, n_sigmas),
113
+ ):
114
+ percentiles = np.percentile(y_samples, percentile, axis=0)
115
+ ax.stairs(
116
+ percentiles[1],
117
+ edges=[*list(x_bins[0]), x_bins[1][-1]],
118
+ baseline=percentiles[0],
119
+ alpha=alpha,
120
+ fill=True,
121
+ color=color,
122
+ )
123
+
124
+ return [(median, envelope)]
125
+
126
+
127
+ def _compute_effective_area(
128
+ obsconf: ObsConfiguration,
129
+ x_unit: str | u.Unit = "keV",
130
+ ):
131
+ """
132
+ Helper function to compute the bins and effective area of an observational configuration
133
+
134
+ Parameters:
135
+ obsconf: The observational configuration.
136
+ x_unit: The unit of the x-axis. It can be either a string (parsable by astropy.units) or an astropy unit. It must be homogeneous to either a length, a frequency or an energy.
137
+ """
138
+
139
+ # Note to Simon : do not change xbins[1] - xbins[0] to
140
+ # np.diff, you already did this twice and forgot that it does not work since diff keeps the dimensions
141
+ # and enable weird broadcasting that makes the plot fail
142
+
143
+ xbins = obsconf.out_energies * u.keV
144
+ xbins = xbins.to(x_unit, u.spectral())
145
+
146
+ # This computes the total effective area within all bins
147
+ # This is a bit weird since the following computation is equivalent to ignoring the RMF
148
+ exposure = obsconf.exposure.data * u.s
149
+ mid_bins_arf = obsconf.in_energies.mean(axis=0) * u.keV
150
+ mid_bins_arf = mid_bins_arf.to(x_unit, u.spectral())
151
+ e_grid = np.linspace(*xbins, 10)
152
+ interpolated_arf = np.interp(e_grid, mid_bins_arf, obsconf.area)
153
+ integrated_arf = (
154
+ trapezoid(interpolated_arf, x=e_grid, axis=0)
155
+ / (
156
+ np.abs(
157
+ xbins[1] - xbins[0]
158
+ ) # Must fold in abs because some units reverse the ordering of the bins
159
+ )
160
+ * u.cm**2
161
+ )
162
+
163
+ return xbins, exposure, integrated_arf
164
+
165
+
166
+ def _error_bars_for_observed_data(observed_counts, denominator, units, sigma=1):
167
+ r"""
168
+ Compute the error bars for the observed data assuming a prior Gamma distribution
169
+
170
+ Parameters:
171
+ observed_counts: array of integer counts
172
+ denominator: normalization factor (e.g. effective area)
173
+ units: unit to convert to
174
+ sigma: dispersion to use for quantiles computation
175
+
176
+ Returns:
177
+ y_observed: observed counts in the desired units
178
+ y_observed_low: lower bound of the error bars
179
+ y_observed_high: upper bound of the error bars
180
+ """
181
+
182
+ percentile = sigma_to_percentile_intervals([sigma])[0]
183
+
184
+ y_observed = (observed_counts * u.ct / denominator).to(units)
185
+
186
+ y_observed_low = (
187
+ nbinom.ppf(percentile[0] / 100, observed_counts, 0.5) * u.ct / denominator
188
+ ).to(units)
189
+
190
+ y_observed_high = (
191
+ nbinom.ppf(percentile[1] / 100, observed_counts, 0.5) * u.ct / denominator
192
+ ).to(units)
193
+
194
+ return y_observed, y_observed_low, y_observed_high