ipaapi 1.0.0__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.
ipaapi/mapping.py ADDED
@@ -0,0 +1,485 @@
1
+ """Describe how the columns of a dataset map onto an IPA analysis.
2
+
3
+ The demo code this package grew out of assumed a very rigid file layout: the
4
+ gene identifier in column 0, followed by ``n_observations x n_measurements``
5
+ value columns in strict repeating order, with every observation carrying the
6
+ same measurement types in the same positions. Real files rarely look like that.
7
+
8
+ :class:`ColumnMapping` replaces that assumption with an explicit declaration.
9
+ You name the gene identifier column and describe each observation as a set of
10
+ ``(column, measurement type)`` pairs. Columns may appear in any order, be named
11
+ anything, and be interleaved with columns the analysis should ignore.
12
+
13
+ One constraint is genuinely imposed by the IPA API and cannot be designed away:
14
+ the *sequence of measurement types* is global to the submission (the wire format
15
+ declares ``expvaltype``, ``expvaltype2``, ... once for the whole request, then
16
+ supplies per-observation column names against those slots). So every observation
17
+ must contribute exactly one column per declared measurement type. Cutoffs are
18
+ likewise global per measurement slot. Both rules are enforced here, before
19
+ anything is sent.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from typing import TYPE_CHECKING, Iterable, List, Optional, Sequence, Tuple, Union
26
+
27
+ from .errors import MappingError
28
+ from .models import MeasurementType
29
+
30
+ if TYPE_CHECKING: # pragma: no cover - typing only
31
+ import pandas as pd
32
+
33
+ __all__ = ["Measurement", "Observation", "ColumnMapping", "is_blank"]
34
+
35
+ MeasurementLike = Union[MeasurementType, str]
36
+
37
+ #: Spellings of "no value" seen in real expression tables.
38
+ _BLANK_TOKENS = {"", "na", "nan", "none", "null", "-", "."}
39
+
40
+
41
+ def _type_hint(declared: MeasurementType, bad_values: list, total: int) -> str:
42
+ """Suggest the measurement type the data actually looks like.
43
+
44
+ A column's name rarely settles what scale it is on -- "Fold_change" is used
45
+ for both linear ratios and log2 values -- so the distribution is the better
46
+ witness. Naming the likely correct type turns a rejection into an answer.
47
+ """
48
+ if declared is not MeasurementType.FOLD_CHANGE:
49
+ return ""
50
+
51
+ # Fold change is barred from (-1, 1). Values sitting there, especially with
52
+ # both signs, are the shape of a log ratio: log2 of 0.72 is -0.47.
53
+ interval = [v for v in bad_values if -1 < float(v) < 1]
54
+ if not interval or len(interval) < 0.2 * max(total, 1):
55
+ return ""
56
+
57
+ mixed_signs = any(float(v) < 0 for v in interval) and any(
58
+ float(v) > 0 for v in interval
59
+ )
60
+ hint = (
61
+ f"\n {len(interval)} of those sit between -1 and 1"
62
+ + (", with both signs" if mixed_signs else "")
63
+ + ", which is where fold change cannot go but a log ratio spends most of "
64
+ "its time. If this column is log2 fold change, declare it 'logratio' "
65
+ "instead -- a value of -0.47 is then read as 0.72-fold rather than "
66
+ "rejected."
67
+ )
68
+ return hint
69
+
70
+
71
+ def is_blank(value) -> bool:
72
+ """Return whether *value* should be treated as a missing identifier."""
73
+ if value is None:
74
+ return True
75
+ if isinstance(value, float) and value != value: # NaN
76
+ return True
77
+ return str(value).strip().lower() in _BLANK_TOKENS
78
+
79
+
80
+ def _coerce_type(value: MeasurementLike) -> MeasurementType:
81
+ if isinstance(value, MeasurementType):
82
+ return value
83
+ try:
84
+ return MeasurementType(str(value).strip().lower())
85
+ except ValueError:
86
+ allowed = ", ".join(m.value for m in MeasurementType)
87
+ raise MappingError(
88
+ f"Unknown measurement type {value!r}. Allowed types: {allowed}."
89
+ ) from None
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class Measurement:
94
+ """One value column within an observation.
95
+
96
+ Args:
97
+ column: Column header as it appears in the dataset.
98
+ type: Measurement type for the column.
99
+ cutoff: Optional significance cutoff. Because IPA applies cutoffs per
100
+ measurement slot rather than per observation, every observation that
101
+ declares this measurement type must give the same cutoff.
102
+ label: Name shown for the column inside IPA. Defaults to ``column``.
103
+ """
104
+
105
+ column: str
106
+ type: MeasurementType
107
+ cutoff: Optional[float] = None
108
+ label: Optional[str] = None
109
+
110
+ def __post_init__(self) -> None:
111
+ object.__setattr__(self, "type", _coerce_type(self.type))
112
+ if not str(self.column).strip():
113
+ raise MappingError("Measurement.column must be a non-empty column name.")
114
+
115
+ @property
116
+ def display_name(self) -> str:
117
+ """Name IPA should show for this column."""
118
+ return self.label if self.label else self.column
119
+
120
+
121
+ @dataclass
122
+ class Observation:
123
+ """A named sample or contrast, and the value columns belonging to it.
124
+
125
+ Args:
126
+ name: Observation name as it should appear in IPA.
127
+ measurements: The value columns for this observation, in any order.
128
+ """
129
+
130
+ name: str
131
+ measurements: List[Measurement] = field(default_factory=list)
132
+
133
+ def __post_init__(self) -> None:
134
+ if not str(self.name).strip():
135
+ raise MappingError("Observation.name must be non-empty.")
136
+ coerced: List[Measurement] = []
137
+ for m in self.measurements:
138
+ if isinstance(m, Measurement):
139
+ coerced.append(m)
140
+ elif isinstance(m, dict):
141
+ coerced.append(Measurement(**m))
142
+ elif isinstance(m, (tuple, list)) and len(m) >= 2:
143
+ coerced.append(Measurement(*m))
144
+ else:
145
+ raise MappingError(
146
+ f"Cannot interpret {m!r} as a Measurement in observation {self.name!r}."
147
+ )
148
+ self.measurements = coerced
149
+ if not self.measurements:
150
+ raise MappingError(f"Observation {self.name!r} has no measurement columns.")
151
+ seen = set()
152
+ for m in self.measurements:
153
+ if m.type in seen:
154
+ raise MappingError(
155
+ f"Observation {self.name!r} declares measurement type "
156
+ f"{m.type.value!r} more than once. Each observation may supply "
157
+ "at most one column per measurement type."
158
+ )
159
+ seen.add(m.type)
160
+
161
+ def by_type(self, mtype: MeasurementType) -> Measurement:
162
+ """Return this observation's column for *mtype*."""
163
+ for m in self.measurements:
164
+ if m.type is mtype:
165
+ return m
166
+ raise MappingError(
167
+ f"Observation {self.name!r} has no column of type {mtype.value!r}."
168
+ )
169
+
170
+ @property
171
+ def columns(self) -> List[str]:
172
+ return [m.column for m in self.measurements]
173
+
174
+
175
+ @dataclass
176
+ class ColumnMapping:
177
+ """A complete description of how a dataset maps onto an IPA submission.
178
+
179
+ Args:
180
+ gene_id_column: Header of the column holding gene identifiers.
181
+ gene_id_type: IPA gene identifier type, e.g. ``"ensembl"``,
182
+ ``"entrezgene"``, ``"genesymbol"``, ``"affymetrix"``.
183
+ observations: One :class:`Observation` per sample or contrast.
184
+ gene_id_label: Name shown for the identifier column in IPA. Defaults to
185
+ ``gene_id_column``.
186
+
187
+ Example:
188
+ >>> mapping = ColumnMapping(
189
+ ... gene_id_column="ID",
190
+ ... gene_id_type="ensembl",
191
+ ... observations=[
192
+ ... Observation("Gemfib vs ctrl", [
193
+ ... Measurement("FC", MeasurementType.FOLD_CHANGE, cutoff=1.5),
194
+ ... Measurement("pval", MeasurementType.P_VALUE),
195
+ ... ]),
196
+ ... ],
197
+ ... )
198
+ """
199
+
200
+ gene_id_column: str
201
+ gene_id_type: str
202
+ observations: List[Observation] = field(default_factory=list)
203
+ gene_id_label: Optional[str] = None
204
+ gene_id_fallback_column: Optional[str] = None
205
+ gene_id_fallback_type: Optional[str] = None
206
+
207
+ def __post_init__(self) -> None:
208
+ if not str(self.gene_id_column).strip():
209
+ raise MappingError("gene_id_column must be a non-empty column name.")
210
+ if not str(self.gene_id_type).strip():
211
+ raise MappingError("gene_id_type must be set, e.g. 'ensembl'.")
212
+ if self.gene_id_fallback_column is not None:
213
+ if not str(self.gene_id_fallback_column).strip():
214
+ raise MappingError(
215
+ "gene_id_fallback_column must be a non-empty column name, or None."
216
+ )
217
+ if self.gene_id_fallback_column == self.gene_id_column:
218
+ raise MappingError(
219
+ "gene_id_fallback_column must differ from gene_id_column."
220
+ )
221
+ if not (self.gene_id_fallback_type or "").strip():
222
+ raise MappingError(
223
+ "gene_id_fallback_type must be set when a fallback identifier "
224
+ "column is given, e.g. 'genesymbol'."
225
+ )
226
+ elif self.gene_id_fallback_type:
227
+ raise MappingError(
228
+ "gene_id_fallback_type was set without gene_id_fallback_column."
229
+ )
230
+ coerced: List[Observation] = []
231
+ for obs in self.observations:
232
+ if isinstance(obs, Observation):
233
+ coerced.append(obs)
234
+ elif isinstance(obs, dict):
235
+ coerced.append(Observation(**obs))
236
+ else:
237
+ raise MappingError(f"Cannot interpret {obs!r} as an Observation.")
238
+ self.observations = coerced
239
+ if not self.observations:
240
+ raise MappingError("A ColumnMapping needs at least one observation.")
241
+
242
+ names = [o.name for o in self.observations]
243
+ duplicates = {n for n in names if names.count(n) > 1}
244
+ if duplicates:
245
+ raise MappingError(
246
+ "Observation names must be unique; repeated: "
247
+ + ", ".join(sorted(duplicates))
248
+ )
249
+
250
+ self._check_consistent_types()
251
+ self._check_consistent_cutoffs()
252
+
253
+ # -- invariants imposed by the IPA wire format -------------------------
254
+
255
+ def _check_consistent_types(self) -> None:
256
+ reference = set(self.measurement_types)
257
+ for obs in self.observations[1:]:
258
+ got = set(m.type for m in obs.measurements)
259
+ if got != reference:
260
+ missing = sorted(t.value for t in reference - got)
261
+ extra = sorted(t.value for t in got - reference)
262
+ detail = []
263
+ if missing:
264
+ detail.append("missing " + ", ".join(missing))
265
+ if extra:
266
+ detail.append("unexpected " + ", ".join(extra))
267
+ raise MappingError(
268
+ f"Observation {obs.name!r} declares a different set of measurement "
269
+ f"types than {self.observations[0].name!r} ({'; '.join(detail)}). "
270
+ "IPA declares measurement types once for the whole submission, so "
271
+ "every observation must supply exactly one column per type."
272
+ )
273
+
274
+ def _check_consistent_cutoffs(self) -> None:
275
+ for mtype in self.measurement_types:
276
+ values = {}
277
+ for obs in self.observations:
278
+ values[obs.name] = obs.by_type(mtype).cutoff
279
+ distinct = set(values.values())
280
+ if len(distinct) > 1:
281
+ detail = ", ".join(f"{k}={v!r}" for k, v in values.items())
282
+ raise MappingError(
283
+ f"Conflicting cutoffs for measurement type {mtype.value!r}: {detail}. "
284
+ "IPA applies one cutoff per measurement type across the whole "
285
+ "submission, so it cannot vary between observations."
286
+ )
287
+
288
+ # -- derived views -----------------------------------------------------
289
+
290
+ @property
291
+ def measurement_types(self) -> List[MeasurementType]:
292
+ """Canonical measurement slot order, taken from the first observation."""
293
+ return [m.type for m in self.observations[0].measurements]
294
+
295
+ @property
296
+ def cutoffs(self) -> List[Optional[float]]:
297
+ """Cutoff per measurement slot, in canonical order."""
298
+ first = self.observations[0]
299
+ return [first.by_type(t).cutoff for t in self.measurement_types]
300
+
301
+ def ordered_measurements(self, obs: Observation) -> List[Measurement]:
302
+ """Return *obs*'s measurements re-ordered into canonical slot order."""
303
+ return [obs.by_type(t) for t in self.measurement_types]
304
+
305
+ @property
306
+ def value_columns(self) -> List[str]:
307
+ """Every value column used, in canonical submission order."""
308
+ out: List[str] = []
309
+ for obs in self.observations:
310
+ out.extend(m.column for m in self.ordered_measurements(obs))
311
+ return out
312
+
313
+ @property
314
+ def used_columns(self) -> List[str]:
315
+ """The identifier column(s) plus every value column."""
316
+ columns = [self.gene_id_column]
317
+ if self.gene_id_fallback_column:
318
+ columns.append(self.gene_id_fallback_column)
319
+ return columns + self.value_columns
320
+
321
+ # -- identifier resolution ---------------------------------------------
322
+
323
+ def resolve_gene_ids(self, frame: "pd.DataFrame") -> Tuple["pd.Series", int]:
324
+ """Return the identifier column actually uploaded, and how many rows were filled.
325
+
326
+ With no fallback configured this is just the primary column. When a
327
+ fallback is configured, rows whose primary identifier is blank or
328
+ missing take the fallback column's value instead.
329
+
330
+ .. warning::
331
+ IPA is told a single ``geneidtype`` for the whole submission -- the
332
+ primary column's type. Rows filled from a fallback column of a
333
+ *different* type are therefore uploaded under the primary's type
334
+ declaration, and IPA may fail to map them. The fill count is returned
335
+ so callers can surface this rather than let it pass unnoticed.
336
+
337
+ Returns:
338
+ ``(series, n_filled)`` where *n_filled* counts rows that took a
339
+ usable value from the fallback column.
340
+ """
341
+ primary = frame[self.gene_id_column]
342
+ if not self.gene_id_fallback_column:
343
+ return primary, 0
344
+
345
+ fallback = frame[self.gene_id_fallback_column]
346
+ primary_blank = primary.map(is_blank)
347
+ fallback_usable = ~fallback.map(is_blank)
348
+ fill = primary_blank & fallback_usable
349
+ resolved = primary.where(~fill, fallback)
350
+ return resolved, int(fill.sum())
351
+
352
+ def unresolved_gene_ids(self, frame: "pd.DataFrame") -> int:
353
+ """Count rows that end up with no usable identifier at all."""
354
+ resolved, _ = self.resolve_gene_ids(frame)
355
+ return int(resolved.map(is_blank).sum())
356
+
357
+ # -- validation against real data --------------------------------------
358
+
359
+ def validate(self, frame: "pd.DataFrame", check_ranges: bool = True) -> None:
360
+ """Check this mapping against *frame*, raising :class:`MappingError`.
361
+
362
+ Verifies that every declared column exists, that no column is claimed
363
+ twice, and (when *check_ranges*) that the values in each column fall
364
+ inside the range IPA accepts for its measurement type.
365
+ """
366
+ available = list(frame.columns)
367
+ missing = [c for c in self.used_columns if c not in available]
368
+ if missing:
369
+ raise MappingError(
370
+ "Columns declared in the mapping are not present in the dataset: "
371
+ + ", ".join(repr(c) for c in missing)
372
+ + ". Available columns: "
373
+ + ", ".join(repr(c) for c in available[:25])
374
+ + (" ..." if len(available) > 25 else "")
375
+ )
376
+
377
+ used = self.used_columns
378
+ repeated = sorted({c for c in used if used.count(c) > 1})
379
+ if repeated:
380
+ raise MappingError(
381
+ "The same column is claimed more than once by the mapping: "
382
+ + ", ".join(repr(c) for c in repeated)
383
+ )
384
+
385
+ if check_ranges:
386
+ self._check_ranges(frame)
387
+
388
+ def _check_ranges(self, frame: "pd.DataFrame") -> None:
389
+ import pandas as pd
390
+
391
+ problems: List[str] = []
392
+ for obs in self.observations:
393
+ for m in obs.measurements:
394
+ series = pd.to_numeric(frame[m.column], errors="coerce").dropna()
395
+ if series.empty:
396
+ continue
397
+ bad = [v for v in series.tolist() if not m.type.is_plausible(float(v))]
398
+ if bad:
399
+ sample = ", ".join(f"{v:g}" for v in bad[:3])
400
+ note = (
401
+ f"column {m.column!r} (observation {obs.name!r}) is declared "
402
+ f"{m.type.value!r} but holds {len(bad)} out-of-range value(s), "
403
+ f"e.g. {sample}"
404
+ )
405
+ note += _type_hint(m.type, bad, len(series))
406
+ problems.append(note)
407
+ if problems:
408
+ raise MappingError(
409
+ "Values do not match their declared measurement types:\n - "
410
+ + "\n - ".join(problems)
411
+ + "\nPass check_ranges=False to submit anyway."
412
+ )
413
+
414
+ # -- convenience constructors ------------------------------------------
415
+
416
+ @classmethod
417
+ def from_blocks(
418
+ cls,
419
+ columns: Sequence[str],
420
+ gene_id_type: str,
421
+ observation_names: Sequence[str],
422
+ measurement_types: Iterable[MeasurementLike],
423
+ cutoffs: Optional[Sequence[Optional[float]]] = None,
424
+ gene_id_column: Optional[str] = None,
425
+ ) -> "ColumnMapping":
426
+ """Build a mapping from the rigid layout the original demo assumed.
427
+
428
+ Treats *columns* as the identifier column followed by contiguous blocks
429
+ of measurements, one block per observation, each block in
430
+ *measurement_types* order. Useful for files that really are laid out
431
+ that way, and as a migration path from the old ``ipa_analyze`` call.
432
+ """
433
+ columns = list(columns)
434
+ types = [_coerce_type(t) for t in measurement_types]
435
+ gene_col = gene_id_column if gene_id_column is not None else columns[0]
436
+ value_cols = [c for c in columns if c != gene_col]
437
+
438
+ expected = len(observation_names) * len(types)
439
+ if len(value_cols) < expected:
440
+ raise MappingError(
441
+ f"Expected at least {expected} value columns for "
442
+ f"{len(observation_names)} observation(s) x {len(types)} measurement(s), "
443
+ f"but found {len(value_cols)}."
444
+ )
445
+
446
+ cut = list(cutoffs) if cutoffs is not None else [None] * len(types)
447
+ if len(cut) != len(types):
448
+ raise MappingError(
449
+ f"Got {len(cut)} cutoff(s) for {len(types)} measurement type(s); "
450
+ "supply one per type, using None where there is no cutoff."
451
+ )
452
+
453
+ observations = []
454
+ for i, obs_name in enumerate(observation_names):
455
+ block = value_cols[i * len(types) : (i + 1) * len(types)]
456
+ observations.append(
457
+ Observation(
458
+ name=obs_name,
459
+ measurements=[
460
+ Measurement(column=col, type=t, cutoff=c)
461
+ for col, t, c in zip(block, types, cut)
462
+ ],
463
+ )
464
+ )
465
+ return cls(
466
+ gene_id_column=gene_col,
467
+ gene_id_type=gene_id_type,
468
+ observations=observations,
469
+ )
470
+
471
+ def describe(self) -> str:
472
+ """Return a human-readable summary, handy for logging before upload."""
473
+ lines = [f"gene id: {self.gene_id_column!r} ({self.gene_id_type})"]
474
+ if self.gene_id_fallback_column:
475
+ lines.append(
476
+ f" fallback: {self.gene_id_fallback_column!r} "
477
+ f"({self.gene_id_fallback_type}) -- used only where the primary is blank"
478
+ )
479
+ lines.append(f"observations: {len(self.observations)}")
480
+ for obs in self.observations:
481
+ lines.append(f" {obs.name}:")
482
+ for m in self.ordered_measurements(obs):
483
+ cut = "" if m.cutoff is None else f", cutoff {m.cutoff:g}"
484
+ lines.append(f" {m.column!r} -> {m.type.label}{cut}")
485
+ return "\n".join(lines)
ipaapi/models.py ADDED
@@ -0,0 +1,193 @@
1
+ """Enumerations and value objects shared across the package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import Optional, Tuple
7
+
8
+ __all__ = ["MeasurementType", "AnalysisStatus", "ReferenceSet", "GENE_ID_TYPES"]
9
+
10
+ #: Every accepted ``geneidtype`` value, from the IPA Integration Module
11
+ #: documentation (April 2026), §3.1. Maps the wire value to the database it
12
+ #: refers to.
13
+ #:
14
+ #: Note that **species is carried by the identifier type**, not by a separate
15
+ #: parameter: ``hugo`` is human, ``mousesymeg`` mouse, ``ratsymeg`` rat. There
16
+ #: is no species argument in the API.
17
+ #:
18
+ #: Several entries are aliases for the same thing (``hugo`` / ``humansymeg`` /
19
+ #: ``humanegsym``). The list is not guessable from the interface -- the desktop
20
+ #: client's label "Gene Symbol - human (HUGO / HGNC, Entrez Gene)" corresponds
21
+ #: to ``hugo``, while ``genesymbol`` and ``hgnc`` are not accepted at all.
22
+ GENE_ID_TYPES = {
23
+ "affymetrix": "Affymetrix",
24
+ "affymetrixsnp": "Affymetrix SNP ID",
25
+ "agilent": "Agilent",
26
+ "abi": "Life Technologies (Applied Biosystems)",
27
+ "life": "Life Technologies (Applied Biosystems)",
28
+ "cas": "CAS Registry",
29
+ "codelink": "CodeLink",
30
+ "dbsnp": "dbSNP",
31
+ "ensembl": "Ensembl",
32
+ "entrezgene": "Entrez Gene",
33
+ "locuslink": "Entrez Gene",
34
+ "genbank": "GenBank",
35
+ "genpept": "GenPept",
36
+ "ginumber": "GI Number",
37
+ "hugo": "Gene symbol -- human (Hugo / HGNC, Entrez Gene)",
38
+ "humansymeg": "Gene symbol -- human (Hugo / HGNC, Entrez Gene)",
39
+ "humanegsym": "Gene symbol -- human (Hugo / HGNC, Entrez Gene)",
40
+ "mousesymeg": "Gene Symbol -- mouse (Entrez Gene)",
41
+ "mouseegsym": "Gene Symbol -- mouse (Entrez Gene)",
42
+ "ratsymeg": "Gene Symbol -- rat (Entrez Gene)",
43
+ "rategsym": "Gene Symbol -- rat (Entrez Gene)",
44
+ "hmdb": "Human Metabolome Database",
45
+ "illumina": "Illumina",
46
+ "ipi": "International Protein Index",
47
+ "kegg": "KEGG ID",
48
+ "mirbasemature": "miRBase (mature)",
49
+ "mirbasestemloop": "miRBase (stemloop)",
50
+ "pubchem": "PubChem CID",
51
+ "refseq": "RefSeq",
52
+ "ucsc_hg18": "UCSC isoform ids (hg18)",
53
+ "ucsc_hg19": "UCSC isoform ids (hg19)",
54
+ "swissprot": "UniProt/SwissProt Accession",
55
+ "unigene": "UniGene",
56
+ }
57
+
58
+
59
+ class MeasurementType(str, Enum):
60
+ """A measurement type accepted by IPA for an expression value column.
61
+
62
+ The wire value is what IPA expects in the ``expvaltype`` parameters. Each
63
+ member also knows the value range IPA considers valid, which the package
64
+ uses to sanity-check data before upload.
65
+ """
66
+
67
+ RATIO = "ratio"
68
+ FOLD_CHANGE = "foldchange"
69
+ LOG_RATIO = "logratio"
70
+ P_VALUE = "pvalue"
71
+ FALSE_DISCOVERY = "falsediscovery"
72
+ INTENSITY = "intensity"
73
+ OTHER = "other"
74
+ GAIN_LOSS = "gain_loss"
75
+ CLASSIFICATION = "classification"
76
+
77
+ @property
78
+ def label(self) -> str:
79
+ """Human-readable name, as shown in the IPA interface."""
80
+ return _LABELS[self]
81
+
82
+ @property
83
+ def valid_range(self) -> Optional[Tuple[float, float]]:
84
+ """Inclusive ``(low, high)`` bounds, or ``None`` if unbounded.
85
+
86
+ Note that :attr:`FOLD_CHANGE` is discontinuous -- IPA accepts
87
+ ``(-inf, -1]`` and ``[1, +inf)`` but not the open interval between --
88
+ so its bounds are reported as ``None`` and checked separately by
89
+ :meth:`is_plausible`.
90
+ """
91
+ return _RANGES[self]
92
+
93
+ def is_plausible(self, value: float) -> bool:
94
+ """Return whether *value* falls inside this type's accepted range."""
95
+ if value != value: # NaN is always allowed; IPA treats it as missing.
96
+ return True
97
+ if self is MeasurementType.FOLD_CHANGE:
98
+ return value >= 1.0 or value <= -1.0
99
+ bounds = self.valid_range
100
+ if bounds is None:
101
+ return True
102
+ low, high = bounds
103
+ return low <= value <= high
104
+
105
+
106
+ _LABELS = {
107
+ MeasurementType.RATIO: "Ratio",
108
+ MeasurementType.FOLD_CHANGE: "Fold Change",
109
+ MeasurementType.LOG_RATIO: "Log Ratio",
110
+ MeasurementType.P_VALUE: "p-value",
111
+ MeasurementType.FALSE_DISCOVERY: "False Discovery Rate (q-value)",
112
+ MeasurementType.INTENSITY: "Intensity",
113
+ MeasurementType.OTHER: "Other (normalized around zero)",
114
+ MeasurementType.GAIN_LOSS: "Variant Gain/Loss",
115
+ MeasurementType.CLASSIFICATION: "Variant ACMG Classification",
116
+ }
117
+
118
+ _INF = float("inf")
119
+ _RANGES = {
120
+ MeasurementType.RATIO: (0.0, _INF),
121
+ MeasurementType.FOLD_CHANGE: None, # discontinuous; see is_plausible()
122
+ MeasurementType.LOG_RATIO: None,
123
+ MeasurementType.P_VALUE: (0.0, 1.0),
124
+ MeasurementType.FALSE_DISCOVERY: (0.0, 100.0),
125
+ MeasurementType.INTENSITY: (0.0, _INF),
126
+ MeasurementType.OTHER: None,
127
+ MeasurementType.GAIN_LOSS: (-2.0, 2.0),
128
+ MeasurementType.CLASSIFICATION: (-2.0, 2.0),
129
+ }
130
+
131
+
132
+ class AnalysisStatus(str, Enum):
133
+ """Terminal and non-terminal states reported by ``/analysisstatus``.
134
+
135
+ Only codes ``3``, ``4`` and ``5`` are documented by the API as terminal.
136
+ Anything else is reported as :attr:`IN_PROGRESS`, which is why the raw code
137
+ is preserved on :attr:`code`.
138
+ """
139
+
140
+ IN_PROGRESS = "in_progress"
141
+ SUCCEEDED = "3"
142
+ FAILED = "4"
143
+ CANCELED = "5"
144
+
145
+ @classmethod
146
+ def from_code(cls, code: str) -> "AnalysisStatus":
147
+ """Map a raw status code from the API onto a member."""
148
+ code = (code or "").strip()
149
+ for member in (cls.SUCCEEDED, cls.FAILED, cls.CANCELED):
150
+ if code == member.value:
151
+ return member
152
+ return cls.IN_PROGRESS
153
+
154
+ @property
155
+ def is_terminal(self) -> bool:
156
+ """Whether the analysis has stopped running, successfully or not."""
157
+ return self is not AnalysisStatus.IN_PROGRESS
158
+
159
+ @property
160
+ def succeeded(self) -> bool:
161
+ return self is AnalysisStatus.SUCCEEDED
162
+
163
+
164
+ class ReferenceSet(str, Enum):
165
+ """Background set an analysis is scored against.
166
+
167
+ Values per the IPA Integration Module documentation (April 2026), §4.1.3.
168
+
169
+ .. important::
170
+ **Omitting the parameter leaves the choice to IPA, and the rule is not
171
+ reliable.** §4.1.3.1 says IPA picks by size when neither ``referenceset``
172
+ nor ``referencesettype`` is given -- :attr:`IPKB` below 2000 identifiers,
173
+ :attr:`DATASET` at 2000 or more.
174
+
175
+ That has *not* been observed to hold: submissions of 1,804 to 6,245
176
+ identifiers all came back scored against
177
+ "Ingenuity Knowledge Base (Genes Only)". Since the behaviour is not
178
+ predictable from the documentation, set this explicitly for any set of
179
+ analyses you intend to compare against each other.
180
+
181
+ Attributes:
182
+ DATASET: The uploaded dataset is the background. Appropriate when the
183
+ upload is a complete measured transcriptome.
184
+ IPKB: The Ingenuity Knowledge Base -- "Genes Only" if the upload holds
185
+ only genes, "Genes + Endogenous Chemicals" if chemicals are present.
186
+
187
+ Array platforms (Affymetrix, Illumina and so on) may also be named, paired
188
+ with a ``referencesettype``; those are not modelled here. See §4.1.3 of the
189
+ documentation and the platform list it links to.
190
+ """
191
+
192
+ DATASET = "dataset"
193
+ IPKB = "ipkb"