scistackplot 0.1.26__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.
scistackplot/groups.py ADDED
@@ -0,0 +1,110 @@
1
+ """
2
+ Derived grouping factors: bucketing a factor's levels into a new factor.
3
+
4
+ ``session ∈ {pre, post1, post2}`` becomes ``Phase ∈ {baseline, post}`` — a
5
+ factor you can colour or facet by, without editing any data and without the
6
+ database having to have recorded it.
7
+
8
+ This is a **derived table**, the same shape as
9
+ :func:`~scistackplot.variants.apply_variant_sets`: the spec decides it, so it is
10
+ recomputed wherever the spec is read, and everything downstream sees one
11
+ ordinary factor with no idea it was synthesized. See
12
+ ``docs/claude/synthetic-factors.md`` for why that matters and for the three call
13
+ sites this has to be wired into.
14
+
15
+ The source factor **stays**. Unlike a variant selection — where keeping the
16
+ column would state the same thing twice and ``roles.validate`` would refuse the
17
+ figure — ``session`` and ``Phase`` are independently useful: sessions along x,
18
+ phases in colour. Neither is a variant factor, so no pooling guard is involved.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import replace
24
+
25
+ import pandas as pd
26
+ from scistacklog import Log
27
+
28
+ from .spec import LevelGroup, PlotSpec
29
+ from .table import FactorInfo, LongTable
30
+
31
+ LAYER = "scistackplot"
32
+
33
+
34
+ def apply_level_groups(spec: PlotSpec, table: LongTable) -> LongTable:
35
+ """Add one factor per :class:`~scistackplot.spec.LevelGroup` in ``spec``.
36
+
37
+ Returns ``table`` untouched when the spec defines none, so a project that
38
+ never buckets anything pays nothing.
39
+ """
40
+ groups = [group for group in spec.level_groups if group.name and group.source]
41
+ if not groups:
42
+ return table
43
+
44
+ frame = table.frame
45
+ factors = list(table.factors)
46
+
47
+ for group in groups:
48
+ if group.source not in frame.columns:
49
+ # A spec outlives the table it was written against — the source
50
+ # factor may have been filtered away or belong to another variable.
51
+ Log.warn(
52
+ "level group %r reads %r, which this table has no column for "
53
+ "— skipped",
54
+ group.name,
55
+ group.source,
56
+ layer=LAYER,
57
+ )
58
+ continue
59
+ frame, levels = _apply_one(frame, group)
60
+ factors.append(FactorInfo(name=group.name, levels=levels))
61
+ Log.info(
62
+ "level group %r: %d level(s) of %r -> %s",
63
+ group.name,
64
+ len(group.mapping),
65
+ group.source,
66
+ levels,
67
+ layer=LAYER,
68
+ )
69
+
70
+ return replace(table, frame=frame, factors=factors)
71
+
72
+
73
+ def _apply_one(frame: pd.DataFrame, group: LevelGroup) -> tuple[pd.DataFrame, list]:
74
+ """Map one source column into a new column, and report its level order."""
75
+ mapping = {str(key): value for key, value in group.mapping.items()}
76
+ mapped = frame[group.source].astype(str).map(mapping)
77
+
78
+ if group.unmatched is None:
79
+ # Drop rows the mapping did not name. "Just these two groups, ignore
80
+ # the rest" is the common intent, and the alternative — keeping them as
81
+ # NaN — makes them a silent extra series in every legend.
82
+ keep = mapped.notna()
83
+ dropped = int((~keep).sum())
84
+ if dropped:
85
+ Log.info(
86
+ "level group %r dropped %d row(s) whose %s was not in the "
87
+ "mapping",
88
+ group.name,
89
+ dropped,
90
+ group.source,
91
+ layer=LAYER,
92
+ )
93
+ frame = frame[keep]
94
+ mapped = mapped[keep]
95
+ else:
96
+ mapped = mapped.fillna(group.unmatched)
97
+
98
+ frame = frame.assign(**{group.name: mapped})
99
+
100
+ # Declared order: the order the user wrote the buckets in, then the
101
+ # catch-all last. Reading it off the data would reorder a legend whenever a
102
+ # filter happened to remove a group's last row.
103
+ order: list = []
104
+ for value in mapping.values():
105
+ if value not in order:
106
+ order.append(value)
107
+ if group.unmatched is not None and group.unmatched not in order:
108
+ order.append(group.unmatched)
109
+ present = set(frame[group.name].dropna().astype(str))
110
+ return frame, [level for level in order if str(level) in present]