robustmargin 0.1.1__py3-none-any.whl

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.
robustmargin/types.py ADDED
@@ -0,0 +1,541 @@
1
+ """robustmargin's shared vocabulary for severity and outcome data.
2
+
3
+ ``Censoring`` is the model-based and grid-rule censoring classification (documented on the
4
+ class itself). Everything else here is new to robustmargin: ``Axis`` describes the
5
+ severity axis a series of observations was measured on; ``Observations`` holds the raw
6
+ aggregated-or-per-observation data a fit is built from; ``Definition`` names which of three
7
+ target-performance definitions a threshold solves for; ``Cell`` is one pooled row of counts at
8
+ a single severity; ``Status`` and ``IntervalShape`` are the enums results report through.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass
16
+ from enum import StrEnum
17
+
18
+ __all__ = [
19
+ "Axis",
20
+ "Cell",
21
+ "Censoring",
22
+ "Definition",
23
+ "IntervalShape",
24
+ "JSONValue",
25
+ "Observations",
26
+ "Status",
27
+ ]
28
+
29
+ # A JSON value as this package writes and reads it: the recursive union `report.py` validates
30
+ # provenance mappings against and the schema-loading code returns. Not itself validated here --
31
+ # `report.to_dict` is what actually walks a provenance mapping and rejects anything outside
32
+ # this union (str keys only, no tuple, no set, no NaN).
33
+ JSONValue = None | bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"]
34
+
35
+
36
+ class Censoring(StrEnum):
37
+ """How a result's tested range relates to a criterion.
38
+
39
+ Every member is a ``str`` subclass whose value equals its member name (``Censoring.RIGHT
40
+ == "RIGHT"``). That value is the literal string a serialised result writes for this field,
41
+ not an incidental implementation detail.
42
+
43
+ **Model-based thresholds** use the full classification. It assumes performance is
44
+ monotone in severity, and a result that uses it says so. ``RIGHT`` and ``LEFT`` are decided
45
+ by exact one-sided tests at the edge of the tested range, not by point estimates.
46
+
47
+ **The grid rule** (:func:`robustmargin.grid_break_point`) uses only ``NONE`` and ``RIGHT``, and
48
+ only as point-estimate labels, with no exact test and no monotonicity assumption. Their
49
+ meaning for a grid result is documented on :class:`robustmargin.GridBreakPoint` and is weaker
50
+ than the definitions below.
51
+
52
+ Attributes
53
+ ----------
54
+ NONE
55
+ Model-based threshold: none of the conditions below applies, so a value is reported
56
+ with both interval sides closed. Grid result: at least one tested level failed. This
57
+ does not imply the criterion was bracketed.
58
+ RIGHT
59
+ Model-based threshold: the exact one-sided bound at the highest tested level shows the
60
+ criterion still holds there. No value is reported. The top of the tested range is
61
+ reported as a one-sided lower bound ("held up to the highest level tested") and is
62
+ never phrased as "does not fail" or "unbreakable". Grid result: no tested level's
63
+ performance fell below the criterion.
64
+ LEFT
65
+ Model-based threshold: the exact one-sided bound at the lowest nonzero tested level
66
+ shows it already fails, while the control passes. No value is reported. The lowest
67
+ nonzero level is reported as a one-sided upper bound. Never produced by the grid rule.
68
+ OPEN_UPPER
69
+ Model-based threshold: the value lies inside the tested range, but the upper side of
70
+ its profile interval does not close within the search limit. The value and the closed
71
+ lower side are still reported. Never produced by the grid rule.
72
+ OPEN_LOWER
73
+ The mirror of ``OPEN_UPPER``: the lower side of the profile interval does not close
74
+ within the search limit. The value and the closed upper side are still reported. Never
75
+ produced by the grid rule.
76
+ """
77
+
78
+ NONE = "NONE"
79
+ RIGHT = "RIGHT"
80
+ LEFT = "LEFT"
81
+ OPEN_UPPER = "OPEN_UPPER"
82
+ OPEN_LOWER = "OPEN_LOWER"
83
+
84
+
85
+ class Status(StrEnum):
86
+ """How a fit or a threshold derived from it stands, following ``Censoring``: each member's
87
+ value equals its name.
88
+
89
+ Attributes
90
+ ----------
91
+ OK
92
+ The fit converged and the threshold, if any, was solved for.
93
+ SEPARATION
94
+ The data are completely or quasi-completely separated: some or all outcomes are
95
+ perfectly predicted by severity, so the likelihood has no finite maximum on at least
96
+ one parameter. No model-based interval is produced; a threshold built from this fit
97
+ falls back to the exact censoring bound.
98
+ NOT_CONVERGED
99
+ The optimizer did not converge, or converged to a point where the Hessian is not
100
+ positive-definite. Same fallback as ``SEPARATION``.
101
+ UNREACHABLE
102
+ The target performance implied by a threshold's ``Definition`` lies outside the fitted
103
+ curve's range (at or beyond one of the asymptotes), so no severity solves for it.
104
+ FAILS_AT_BASELINE
105
+ The zero-severity control's exact one-sided bound already fails the criterion: the
106
+ threshold does not exist because the system was never passing to begin with.
107
+ CONTROL_INCOMPATIBLE
108
+ The upper asymptote is fixed (commonly at 1) but the zero-severity control has
109
+ failures, which drives the likelihood to zero for that fixed value. Refit with
110
+ ``upper="estimate"``.
111
+ """
112
+
113
+ OK = "OK"
114
+ SEPARATION = "SEPARATION"
115
+ NOT_CONVERGED = "NOT_CONVERGED"
116
+ UNREACHABLE = "UNREACHABLE"
117
+ FAILS_AT_BASELINE = "FAILS_AT_BASELINE"
118
+ CONTROL_INCOMPATIBLE = "CONTROL_INCOMPATIBLE"
119
+
120
+
121
+ class IntervalShape(StrEnum):
122
+ """The geometry of a ratio's confidence interval on the real line, following ``Censoring``:
123
+ each member's value equals its name.
124
+
125
+ A Fieller interval for the ratio of two normal-ish quantities is not always a bounded
126
+ interval; which shape it takes depends on how much relative uncertainty the denominator
127
+ threshold carries.
128
+
129
+ Attributes
130
+ ----------
131
+ BOUNDED
132
+ The usual case: ``[lo, hi]``, a single closed interval. Also covers a Fieller set bounded
133
+ above but not below on its own: intersected with the positive parameter space that is
134
+ ``[0, hi]``, and ``0`` is a true lower bound for a severity ratio, so ``BOUNDED`` --
135
+ never ``HALF_OPEN`` -- is reported (the upper-only geometry stays ``BOUNDED``).
136
+ ``HALF_OPEN`` is therefore always lower-bounded, never upper-bounded, and that asymmetry
137
+ is deliberate.
138
+ UNBOUNDED
139
+ The confidence set, intersected with the positive parameter space, is the whole of
140
+ ``(0, inf)``; ``lo`` and ``hi`` are both ``None``.
141
+ EXCLUSIVE
142
+ Two disjoint rays, ``(-inf, lo] union [hi, inf)``, with both ``lo`` and ``hi`` on the
143
+ same side of zero (both negative is the ``UNBOUNDED`` case; a positive ``lo`` never
144
+ arises under ``dependence="independent"``). **Unreachable in v0.1**: under
145
+ ``dependence="independent"`` the covariance term is 0, which forces opposite-signed roots
146
+ whenever this branch's other conditions hold, so the real result is always ``HALF_OPEN``
147
+ instead. The member and :class:`~robustmargin.Ratio`'s invariants for it stay enforced
148
+ against v0.2's ``dependence="paired"``, which can make the covariance term nonzero and
149
+ reach this shape for the first time.
150
+ HALF_OPEN
151
+ One ray, ``[lo, inf)``: ``lo`` is set, ``hi`` is ``None``. This is what a two-ray
152
+ Fieller set with ``K > 0`` is once intersected with the positive parameter space. It arises
153
+ whenever the denominator threshold is poorly determined (``z^2*Vb > theta_b^2``) while
154
+ the numerator is not -- an open region of the parameter space, not a measure-zero
155
+ boundary.
156
+ """
157
+
158
+ BOUNDED = "BOUNDED"
159
+ UNBOUNDED = "UNBOUNDED"
160
+ EXCLUSIVE = "EXCLUSIVE"
161
+ HALF_OPEN = "HALF_OPEN"
162
+
163
+
164
+ _VALID_SCALES = frozenset({"log", "linear"})
165
+ _VALID_DIRECTIONS = frozenset({"decreasing", "increasing"})
166
+ _VALID_DEFINITION_KINDS = frozenset({"absolute", "baseline_fraction", "relative"})
167
+
168
+
169
+ def _check_finite_number(value: float, *, label: str) -> None:
170
+ """Reject a bool masquerading as a number, and reject a non-finite value (``nan``,
171
+ ``inf`` or ``-inf``).
172
+
173
+ Every numeric field across the package that must be a genuine finite number --
174
+ ``Definition.value``, ``Cell.severity``, ``Observations.severity``, ``Fit.log_likelihood``,
175
+ ``Threshold``'s ``value``/``lo``/``hi``/``level``, ``Ratio``'s ``estimate``/``lo``/``hi``/
176
+ ``level``, and every ``Covariance.matrix`` entry -- routes its bool/finite check through
177
+ this one function, so the rule cannot silently drift between them.
178
+ """
179
+ if isinstance(value, bool):
180
+ raise ValueError(f"{label} must not be a bool, got {value!r}")
181
+ if not math.isfinite(value):
182
+ raise ValueError(f"{label} must be finite, got {value!r}")
183
+
184
+
185
+ def _as_whole_int(value: float, *, label: str) -> int:
186
+ """Coerce a whole-number ``int`` or ``float`` (for example ``200`` or ``200.0``) to a
187
+ genuine ``int``. A bool, a non-finite value, or a non-whole float (``2.5``) raises
188
+ ``ValueError``.
189
+ """
190
+ _check_finite_number(value, label=label)
191
+ as_int = int(value)
192
+ if as_int != value:
193
+ raise ValueError(f"{label} must be a whole number, got {value!r}")
194
+ return as_int
195
+
196
+
197
+ @dataclass(frozen=True, kw_only=True)
198
+ class Axis:
199
+ """The severity axis a series of observations was measured on.
200
+
201
+ Attributes
202
+ ----------
203
+ name
204
+ A short, non-empty, domain-neutral name for the axis (for example ``"noise"``).
205
+ unit
206
+ The unit severity is expressed in (for example ``"m"``). Non-empty. A ratio between two
207
+ thresholds is allowed only when both axes match on ``name`` and ``unit``.
208
+ scale
209
+ Either ``"log"`` or ``"linear"``: which scale the dose-response model is fit on.
210
+ citation
211
+ An optional free-text citation for where the axis or its unit convention comes from (for
212
+ example a datasheet reference). ``None`` by default; if given, must be non-empty.
213
+ """
214
+
215
+ name: str
216
+ unit: str
217
+ scale: str
218
+ citation: str | None = None
219
+
220
+ def __post_init__(self) -> None:
221
+ if not self.name.strip():
222
+ raise ValueError("Axis.name must be non-empty (after stripping whitespace)")
223
+ if not self.unit.strip():
224
+ raise ValueError("Axis.unit must be non-empty (after stripping whitespace)")
225
+ if self.scale not in _VALID_SCALES:
226
+ raise ValueError(
227
+ f"Axis.scale must be one of {sorted(_VALID_SCALES)}, got {self.scale!r}"
228
+ )
229
+ if self.citation is not None and not self.citation.strip():
230
+ raise ValueError("Axis.citation, if given, must be non-empty")
231
+
232
+
233
+ @dataclass(frozen=True, kw_only=True)
234
+ class Definition:
235
+ """Which target performance a threshold solves for.
236
+
237
+ Three definitions are in play, and they coincide only when the fitted curve's asymptotes
238
+ are exactly 1 and 0:
239
+
240
+ - ``absolute(a)``: performance crosses a fixed level ``a``.
241
+ Whether ``a`` is reachable given the fitted asymptotes is decided at threshold-solving
242
+ time (:data:`Status.UNREACHABLE`), not at construction time, so any finite ``a`` --
243
+ including one that looks out of range, such as 1.5 -- constructs successfully.
244
+ - ``baseline_fraction(p)``: performance falls to ``p`` times the performance at zero
245
+ severity. Requires ``0 < p < 1``.
246
+ - ``relative(p)``: a fraction ``p`` of the fitted dynamic range (upper minus lower
247
+ asymptote) is lost (R ``drc``'s ``type = "relative"``). Requires ``0 < p < 1``.
248
+
249
+ Attributes
250
+ ----------
251
+ kind
252
+ One of ``"absolute"``, ``"baseline_fraction"``, ``"relative"``.
253
+ value
254
+ The threshold parameter: ``a`` for ``absolute``, ``p`` for the other two. Must be
255
+ finite.
256
+ """
257
+
258
+ kind: str
259
+ value: float
260
+
261
+ def __post_init__(self) -> None:
262
+ if self.kind not in _VALID_DEFINITION_KINDS:
263
+ raise ValueError(
264
+ f"Definition.kind must be one of {sorted(_VALID_DEFINITION_KINDS)}, "
265
+ f"got {self.kind!r}"
266
+ )
267
+ _check_finite_number(self.value, label="Definition.value")
268
+ if self.kind in ("baseline_fraction", "relative") and not (0.0 < self.value < 1.0):
269
+ raise ValueError(
270
+ f"Definition.value for kind={self.kind!r} must satisfy 0 < value < 1, "
271
+ f"got {self.value!r}"
272
+ )
273
+
274
+ @classmethod
275
+ def absolute(cls, a: float, /) -> Definition:
276
+ """Build an ``absolute(a)`` definition: performance crosses the fixed level ``a``."""
277
+ return cls(kind="absolute", value=a)
278
+
279
+ @classmethod
280
+ def baseline_fraction(cls, p: float, /) -> Definition:
281
+ """Build a ``baseline_fraction(p)`` definition: performance falls to ``p`` times the
282
+ zero-severity performance. Requires ``0 < p < 1``."""
283
+ return cls(kind="baseline_fraction", value=p)
284
+
285
+ @classmethod
286
+ def relative(cls, p: float, /) -> Definition:
287
+ """Build a ``relative(p)`` definition: a fraction ``p`` of the fitted dynamic range is
288
+ lost. Requires ``0 < p < 1``."""
289
+ return cls(kind="relative", value=p)
290
+
291
+
292
+ def _check_counts(
293
+ *, severity: float, successes: float, trials: float, label: str
294
+ ) -> tuple[int, int]:
295
+ """Validate one ``(severity, successes, trials)`` row and return ``successes``/``trials``
296
+ coerced to ``int``.
297
+
298
+ ``successes`` and ``trials`` each accept a whole-number ``float`` (for example ``200.0``)
299
+ as well as a genuine ``int``: a JSON-Schema "integer" cannot tell ``200.0`` from ``200``, so
300
+ both direct construction and ``from_dict`` must accept it. A bool or a
301
+ non-whole float (``2.5``) still raises. The coerced ``int`` values are what gets stored on
302
+ the dataclass, never the original float, so ``type(cell.successes) is int`` always holds.
303
+ """
304
+ _check_finite_number(severity, label=f"{label}.severity")
305
+ successes_int = _as_whole_int(successes, label=f"{label}.successes")
306
+ trials_int = _as_whole_int(trials, label=f"{label}.trials")
307
+ if severity < 0.0:
308
+ raise ValueError(f"{label}.severity must be non-negative, got {severity!r}")
309
+ if trials_int < 1:
310
+ raise ValueError(f"{label}.trials must be >= 1, got {trials_int!r}")
311
+ if successes_int < 0:
312
+ raise ValueError(f"{label}.successes must be non-negative, got {successes_int!r}")
313
+ if successes_int > trials_int:
314
+ raise ValueError(
315
+ f"{label}.successes ({successes_int!r}) must not exceed {label}.trials ({trials_int!r})"
316
+ )
317
+ return successes_int, trials_int
318
+
319
+
320
+ @dataclass(frozen=True, kw_only=True)
321
+ class Cell:
322
+ """One pooled row of counts at a single severity: the unit :meth:`Observations.cells`
323
+ returns and :class:`robustmargin.Fit` stores its per-level table as.
324
+
325
+ Attributes
326
+ ----------
327
+ severity
328
+ The (non-negative, finite) severity level this cell was pooled at. Must not be a
329
+ ``bool``.
330
+ successes
331
+ The number of successes observed at this level. Accepts a whole-number ``float`` (for
332
+ example ``200.0``) as well as an ``int``; either way, the stored value is a genuine
333
+ ``int`` (``type(cell.successes) is int``). A ``bool`` or a non-whole float (``2.5``)
334
+ raises.
335
+ trials
336
+ The number of trials observed at this level. Must be at least 1, and at least
337
+ ``successes``. Accepts a whole-number ``float`` the same way ``successes`` does.
338
+ """
339
+
340
+ severity: float
341
+ successes: int
342
+ trials: int
343
+
344
+ def __post_init__(self) -> None:
345
+ successes, trials = _check_counts(
346
+ severity=self.severity, successes=self.successes, trials=self.trials, label="Cell"
347
+ )
348
+ object.__setattr__(self, "successes", successes)
349
+ object.__setattr__(self, "trials", trials)
350
+
351
+
352
+ @dataclass(frozen=True, kw_only=True)
353
+ class Observations:
354
+ """Raw severity/outcome data for one series, either aggregated counts or per-observation
355
+ rows -- both stored the same way, with each per-observation row as ``trials=1``.
356
+
357
+ Severity is non-negative: a signed axis (for example a bidirectional mass offset) is split
358
+ by the caller into two ``Observations``, one per side. On a log axis, a severity of
359
+ exactly ``0.0`` is the zero-severity control; it is handled in the fitted likelihood,
360
+ never by taking ``log(0)`` and never by an epsilon substitute.
361
+
362
+ Attributes
363
+ ----------
364
+ axis
365
+ The :class:`Axis` this series was measured on.
366
+ outcome
367
+ A short, non-empty, domain-neutral name for what "success" means (for example
368
+ ``"success"`` or ``"consistent"``). Required, with no default: a wrong default would
369
+ silently flip a threshold.
370
+ direction
371
+ Either ``"decreasing"`` (performance falls as severity rises) or ``"increasing"``.
372
+ Required, with no default, for the same reason as ``outcome``.
373
+ severity
374
+ One entry per row, non-negative and finite. Must not be a ``bool``.
375
+ successes
376
+ One entry per row, a non-negative whole number not exceeding the matching ``trials``
377
+ entry. Accepts a whole-number ``float`` (for example ``200.0``) as well as an ``int``;
378
+ either way the stored value is a genuine ``int``. A ``bool`` or a non-whole float
379
+ (``2.5``) raises.
380
+ trials
381
+ One entry per row, a whole number of at least 1. Accepts a whole-number ``float`` the
382
+ same way ``successes`` does.
383
+ cluster
384
+ Optional cluster ids, one per row, each a ``str`` or non-bool ``int`` (never ``None``
385
+ or a ``bool``). Any method assuming independence must refuse clustered input unless the
386
+ caller explicitly asks for it; ``Observations`` itself never drops or ignores these
387
+ ids.
388
+ """
389
+
390
+ axis: Axis
391
+ outcome: str
392
+ direction: str
393
+ severity: tuple[float, ...]
394
+ successes: tuple[int, ...]
395
+ trials: tuple[int, ...]
396
+ cluster: tuple[str | int, ...] | None = None
397
+
398
+ def __post_init__(self) -> None:
399
+ object.__setattr__(self, "severity", tuple(self.severity))
400
+ object.__setattr__(self, "successes", tuple(self.successes))
401
+ object.__setattr__(self, "trials", tuple(self.trials))
402
+ if self.cluster is not None:
403
+ object.__setattr__(self, "cluster", tuple(self.cluster))
404
+
405
+ if not self.outcome:
406
+ raise ValueError("Observations.outcome must be non-empty")
407
+ if self.direction not in _VALID_DIRECTIONS:
408
+ raise ValueError(
409
+ f"Observations.direction must be one of {sorted(_VALID_DIRECTIONS)}, "
410
+ f"got {self.direction!r}"
411
+ )
412
+
413
+ n = len(self.severity)
414
+ if n == 0:
415
+ raise ValueError("Observations must not be empty")
416
+ if len(self.successes) != n or len(self.trials) != n:
417
+ raise ValueError(
418
+ "Observations.severity, .successes and .trials must have equal length: "
419
+ f"got {n}, {len(self.successes)}, {len(self.trials)}"
420
+ )
421
+
422
+ # ``_check_counts`` rejects a bool or non-finite severity, and accepts a whole-number
423
+ # float for successes/trials (for example ``200.0``) the same way direct ``Cell``
424
+ # construction does, coercing both to ``int`` for storage.
425
+ coerced_successes: list[int] = []
426
+ coerced_trials: list[int] = []
427
+ for severity, successes, trials in zip(
428
+ self.severity, self.successes, self.trials, strict=True
429
+ ):
430
+ row_successes, row_trials = _check_counts(
431
+ severity=severity, successes=successes, trials=trials, label="Observations"
432
+ )
433
+ coerced_successes.append(row_successes)
434
+ coerced_trials.append(row_trials)
435
+ object.__setattr__(self, "successes", tuple(coerced_successes))
436
+ object.__setattr__(self, "trials", tuple(coerced_trials))
437
+
438
+ if self.cluster is not None:
439
+ if len(self.cluster) != n:
440
+ raise ValueError(
441
+ f"Observations.cluster must have length {n} to match severity, "
442
+ f"got {len(self.cluster)}"
443
+ )
444
+ for cluster_id in self.cluster:
445
+ if cluster_id is None or isinstance(cluster_id, bool):
446
+ raise ValueError(
447
+ "Observations.cluster ids must be a str or int, never None or bool, "
448
+ f"got {cluster_id!r}"
449
+ )
450
+ if not isinstance(cluster_id, (str, int)):
451
+ raise ValueError(
452
+ f"Observations.cluster ids must be a str or int, got {cluster_id!r}"
453
+ )
454
+
455
+ @classmethod
456
+ def from_counts(
457
+ cls,
458
+ axis: Axis,
459
+ *,
460
+ severity: Sequence[float],
461
+ successes: Sequence[float],
462
+ trials: Sequence[float],
463
+ outcome: str,
464
+ direction: str,
465
+ cluster: Sequence[str | int] | None = None,
466
+ ) -> Observations:
467
+ """Build ``Observations`` from already-aggregated ``(severity, successes, trials)``
468
+ rows.
469
+
470
+ ``successes`` and ``trials`` accept whole-number floats (for example ``200.0``) as well
471
+ as ints; a non-whole value (``2.5``) raises.
472
+ """
473
+ return cls(
474
+ axis=axis,
475
+ outcome=outcome,
476
+ direction=direction,
477
+ severity=tuple(float(v) for v in severity),
478
+ successes=tuple(_as_whole_int(v, label="successes") for v in successes),
479
+ trials=tuple(_as_whole_int(v, label="trials") for v in trials),
480
+ cluster=tuple(cluster) if cluster is not None else None,
481
+ )
482
+
483
+ @classmethod
484
+ def from_observations(
485
+ cls,
486
+ axis: Axis,
487
+ *,
488
+ severity: Sequence[float],
489
+ success: Sequence[float],
490
+ outcome: str,
491
+ direction: str,
492
+ cluster: Sequence[str | int] | None = None,
493
+ ) -> Observations:
494
+ """Build ``Observations`` from one row per observation, each stored with ``trials=1``.
495
+
496
+ ``success`` entries must be ``0``, ``1``, ``True`` or ``False``; anything else raises.
497
+ """
498
+ successes: list[int] = []
499
+ for value in success:
500
+ if isinstance(value, bool):
501
+ successes.append(1 if value else 0)
502
+ elif value in (0, 1):
503
+ successes.append(int(value))
504
+ else:
505
+ raise ValueError(
506
+ f"Observations.from_observations: success entries must be 0, 1, True or "
507
+ f"False, got {value!r}"
508
+ )
509
+ return cls(
510
+ axis=axis,
511
+ outcome=outcome,
512
+ direction=direction,
513
+ severity=tuple(float(v) for v in severity),
514
+ successes=tuple(successes),
515
+ trials=tuple(1 for _ in successes),
516
+ cluster=tuple(cluster) if cluster is not None else None,
517
+ )
518
+
519
+ def cells(self) -> tuple[Cell, ...]:
520
+ """Pool rows with exactly equal severity by summing counts, sorted ascending.
521
+
522
+ Returns
523
+ -------
524
+ tuple[Cell, ...]
525
+ One :class:`Cell` per distinct severity value, ascending.
526
+ """
527
+ pooled: dict[float, tuple[int, int]] = {}
528
+ for severity, successes, trials in zip(
529
+ self.severity, self.successes, self.trials, strict=True
530
+ ):
531
+ existing_successes, existing_trials = pooled.get(severity, (0, 0))
532
+ pooled[severity] = (existing_successes + successes, existing_trials + trials)
533
+ return tuple(
534
+ Cell(severity=severity, successes=successes, trials=trials)
535
+ for severity, (successes, trials) in sorted(pooled.items())
536
+ )
537
+
538
+ @property
539
+ def is_clustered(self) -> bool:
540
+ """Whether this series carries cluster ids."""
541
+ return self.cluster is not None