scistackplot 0.1.40__tar.gz → 0.1.42__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 (50) hide show
  1. {scistackplot-0.1.40 → scistackplot-0.1.42}/.gitignore +3 -0
  2. {scistackplot-0.1.40 → scistackplot-0.1.42}/PKG-INFO +3 -2
  3. {scistackplot-0.1.40 → scistackplot-0.1.42}/pyproject.toml +3 -0
  4. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/codegen.py +33 -2
  5. scistackplot-0.1.42/src/scistackplot/design.py +275 -0
  6. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/diffbars.py +44 -1
  7. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/export.py +62 -0
  8. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/series_stats.py +7 -5
  9. scistackplot-0.1.42/src/scistackplot/spread.py +68 -0
  10. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/ylimits.py +18 -10
  11. {scistackplot-0.1.40 → scistackplot-0.1.42}/README.md +0 -0
  12. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/__init__.py +0 -0
  13. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/aliases.py +0 -0
  14. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/autosize.py +0 -0
  15. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/capability.py +0 -0
  16. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/cell.py +0 -0
  17. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/colors.py +0 -0
  18. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/compare.py +0 -0
  19. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/custom.py +0 -0
  20. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/dedup.py +0 -0
  21. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/figsize.py +0 -0
  22. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/figure_file.py +0 -0
  23. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/framesize.py +0 -0
  24. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/groups.py +0 -0
  25. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/numeric.py +0 -0
  26. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/panels.py +0 -0
  27. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/paper.py +0 -0
  28. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/presets.py +0 -0
  29. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/reduce.py +0 -0
  30. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/reducer.py +0 -0
  31. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/render/__init__.py +0 -0
  32. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/render/base.py +0 -0
  33. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/render/mpl.py +0 -0
  34. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/render/plotly_.py +0 -0
  35. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/resolved.py +0 -0
  36. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/restore.py +0 -0
  37. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/roles.py +0 -0
  38. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/shape.py +0 -0
  39. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/sources/__init__.py +0 -0
  40. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/sources/base.py +0 -0
  41. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/sources/csv.py +0 -0
  42. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/sources/frame.py +0 -0
  43. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/spaghetti.py +0 -0
  44. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/spec.py +0 -0
  45. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/table.py +0 -0
  46. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/textsize.py +0 -0
  47. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/ticklabels.py +0 -0
  48. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/variants.py +0 -0
  49. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/weights.py +0 -0
  50. {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/xaxis.py +0 -0
@@ -32,3 +32,6 @@ scistack-gui/frontend/dist/
32
32
  *.log
33
33
  output.txt
34
34
  scimatlab/tests/matlab/test-failures.txt
35
+
36
+ # csv-stats is its own repo (github.com/mtillman14/csv-stats), cloned here for editable dev
37
+ /csv-stats/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scistackplot
3
- Version: 0.1.40
3
+ Version: 0.1.42
4
4
  Summary: Spec-driven plotting for long-format scientific data — standalone, GUI-friendly
5
5
  Author: SciStack Contributors
6
6
  License-Expression: MIT
@@ -17,9 +17,10 @@ Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Topic :: Scientific/Engineering :: Visualization
18
18
  Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.10
20
+ Requires-Dist: csv-stats>=0.2.1
20
21
  Requires-Dist: numpy>=1.21
21
22
  Requires-Dist: pandas>=1.5
22
- Requires-Dist: scistacklog==0.1.40
23
+ Requires-Dist: scistacklog==0.1.42
23
24
  Provides-Extra: dev
24
25
  Requires-Dist: matplotlib>=3.6; extra == 'dev'
25
26
  Requires-Dist: plotly>=5.0; extra == 'dev'
@@ -47,6 +47,9 @@ classifiers = [
47
47
  dependencies = [
48
48
  "pandas>=1.5",
49
49
  "numpy>=1.21",
50
+ # The one owner of every descriptive formula (error bars: spread.py).
51
+ # Core only: numpy, pandas, scipy. Not a scistack sibling, so not coversioned.
52
+ "csv-stats>=0.2.1",
50
53
  "scistacklog",
51
54
  ]
52
55
 
@@ -88,7 +88,9 @@ SPEC_BEGIN = "scistackplot-spec:"
88
88
  _SEABORN_ERRORBAR = {
89
89
  ErrorBand.SD: '"sd"',
90
90
  ErrorBand.SEM: '"se"',
91
- ErrorBand.CI95: '("ci", 95)',
91
+ # Not seaborn's ("ci", 95): that is a bootstrap; the preview is t-based.
92
+ # `_ci95` is csvstats.descriptive.ci_interval, bound by `_ci95_lines`.
93
+ ErrorBand.CI95: "_ci95",
92
94
  ErrorBand.IQR: '("pi", 50)',
93
95
  ErrorBand.NONE: "None",
94
96
  }
@@ -227,6 +229,7 @@ def _generate_plot_function(
227
229
  else []
228
230
  ),
229
231
  *([" import numpy as np"] if collapsing else []),
232
+ *([" from csvstats.descriptive import ci_interval"] if _uses_ci95(spec, shape) else []),
230
233
  *(
231
234
  [" import re"]
232
235
  if spec.kind is PlotKind.SPAGHETTI
@@ -484,7 +487,8 @@ def _docstring(spec: PlotSpec, table: LongTable, roles: dict, y_plan: _YLimitPla
484
487
  chain = " -> ".join([*steps.pre, " x ".join(steps.sample)])
485
488
  if spec.kind in (PlotKind.BAR, PlotKind.BAND):
486
489
  what = (
487
- f"error bars: {spec.aggregate.error} across "
490
+ f"error bars: {spec.aggregate.error}"
491
+ f"{' (t-based)' if spec.aggregate.error is ErrorBand.CI95 else ''} across "
488
492
  f"{' x '.join(steps.sample)}{' (pooled)' if len(steps.sample) > 1 else ''}"
489
493
  )
490
494
  elif spec.kind in (PlotKind.BOX, PlotKind.VIOLIN):
@@ -1695,6 +1699,32 @@ def _alias_relabel_lines(aliases: _ExportAliases, x_categorical: bool) -> list[s
1695
1699
  return lines
1696
1700
 
1697
1701
 
1702
+ def _uses_ci95(spec, shape) -> bool:
1703
+ """True when the plot call passes `errorbar=_ci95` (bar and band only)."""
1704
+ return (
1705
+ shape is not Shape.MATRIX_2D
1706
+ and spec.kind in (PlotKind.BAR, PlotKind.BAND)
1707
+ and spec.aggregate.error is ErrorBand.CI95
1708
+ )
1709
+
1710
+
1711
+ def _ci95_lines(spec, shape) -> list[str]:
1712
+ """Bind `_ci95`, the errorbar callable: csv-stats' t-based CI.
1713
+
1714
+ The preview gets its CI95 from `csvstats.descriptive` (via `spread`), so
1715
+ the exported code calls the same function rather than restating it.
1716
+ """
1717
+ if not _uses_ci95(spec, shape):
1718
+ return []
1719
+ if spec.aggregate.statistic is Statistic.MEDIAN:
1720
+ return [
1721
+ "# t-based 95% CI (csvstats.descriptive), on the median like the bars",
1722
+ '_ci95 = lambda values: ci_interval(values, centre="median")',
1723
+ "",
1724
+ ]
1725
+ return ["# t-based 95% CI of the mean (csvstats.descriptive)", "_ci95 = ci_interval", ""]
1726
+
1727
+
1698
1728
  def _plot_call(
1699
1729
  spec, table, roles, shape, y_plan: _YLimitPlan, base: LongTable | None = None
1700
1730
  ) -> list[str]:
@@ -1865,6 +1895,7 @@ def _plot_call(
1865
1895
  aliases = _export_aliases(spec, table, roles, shape)
1866
1896
  lines = [
1867
1897
  *order_lines,
1898
+ *_ci95_lines(spec, shape),
1868
1899
  *palette_lines,
1869
1900
  *_alias_definition_lines(aliases),
1870
1901
  f"g = {call}(",
@@ -0,0 +1,275 @@
1
+ """Which plot settings are the DATA DESIGN, the one owner (statistics S11).
2
+
3
+ A figure and a StatStudio table may hold different designs (user decision
4
+ 2026-10-17). Both studios show a banner when they diverge, and each has a
5
+ one-way "Sync to…" button. What syncs is the design and only the design: the
6
+ variables, the roles, the grouping order, the collapse, filters and
7
+ exclusions, joined factors, combines and variant selection. Display settings
8
+ (kind, colours, sizes, text, layout) never sync. Neither do stats-only
9
+ settings, which do not live on a ``PlotSpec`` at all.
10
+
11
+ :data:`DESIGN_FIELDS` classifies every ``PlotSpec`` field once. It is read by:
12
+
13
+ - :func:`design_of` / :func:`design_fingerprint`: what a stats spec stores,
14
+ and what the banners compare;
15
+ - :func:`design_diff`: the banner's list of differences, with labels;
16
+ - :func:`copy_design`: the Sync buttons, in both directions.
17
+
18
+ ``tests/test_design.py`` fails on an unclassified field, the same guard
19
+ ``presets.FIELD_CLASSES`` has. The two tables answer different questions
20
+ (what travels with a preset vs. what is the design), so they are separate,
21
+ but each has one owner.
22
+
23
+ The plot layer never imports statistics. A stats spec *holds* a ``PlotSpec``
24
+ as its design carrier, and the GUI service compares the two.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import json
31
+ from dataclasses import asdict, dataclass, fields, replace
32
+ from enum import Enum
33
+ from typing import Any
34
+
35
+ from scistacklog import Log
36
+
37
+ from .spec import Aggregation, PlotSpec, Role
38
+
39
+ LAYER = "scistackplot"
40
+
41
+
42
+ class SyncClass(str, Enum):
43
+ """Whether a setting is part of the data design."""
44
+
45
+ #: Changes which rows exist or what a row means. Syncs.
46
+ DESIGN = "design"
47
+ #: How the rows are drawn. Never syncs.
48
+ DISPLAY = "display"
49
+
50
+ def __str__(self) -> str:
51
+ return self.value
52
+
53
+
54
+ _D, _P = SyncClass.DESIGN, SyncClass.DISPLAY
55
+
56
+ #: Blocks classified per sub-field: ``aggregate.pooled`` changes the rows
57
+ #: (weight by N), while ``aggregate.statistic`` / ``.error`` only draw them.
58
+ _SPLIT_BLOCKS: dict[str, type] = {"aggregate": Aggregation}
59
+
60
+ #: Every ``PlotSpec`` field, classified. Paths into split blocks are dotted.
61
+ DESIGN_FIELDS: dict[str, SyncClass] = {
62
+ "measures": _D,
63
+ "variant_sets": _D,
64
+ "x_measure": _D,
65
+ "roles": _D,
66
+ "groups": _D,
67
+ # Which grouping layer is painted. The layer itself is in `groups`.
68
+ "color": _P,
69
+ # Kind decides the drawing. The stats layer reads its rows at a fixed
70
+ # analysis kind, so a bar-vs-box choice never moves a number.
71
+ "kind": _P,
72
+ "custom": _P,
73
+ "aggregate.statistic": _P,
74
+ "aggregate.error": _P,
75
+ "aggregate.pooled": _D,
76
+ # A 1-D measure's cell statistic makes the scalar each row holds.
77
+ "cell_statistic": _D,
78
+ "index_column": _D,
79
+ "show_sample": _P,
80
+ "join_sample": _P,
81
+ "sample_color": _P,
82
+ "sample_in_legend": _P,
83
+ "show_missing_levels": _P,
84
+ "facet": _P,
85
+ "y_axis": _P,
86
+ "style": _P,
87
+ "aliases": _P,
88
+ "colors": _P,
89
+ "panel_overrides": _P,
90
+ "difference_bars": _P,
91
+ # Compare-to-reference changes the values themselves.
92
+ "comparison": _D,
93
+ "filters": _D,
94
+ "location_filter": _D,
95
+ "factor_variables": _D,
96
+ "level_groups": _D,
97
+ }
98
+
99
+ #: What the banner calls each design field.
100
+ LABELS: dict[str, str] = {
101
+ "measures": "Variable",
102
+ "variant_sets": "Variants",
103
+ "x_measure": "X variable",
104
+ "roles": "Roles",
105
+ "groups": "Grouping order",
106
+ "aggregate.pooled": "Weight by N",
107
+ "cell_statistic": "Cell statistic",
108
+ "index_column": "Index column",
109
+ "comparison": "Compare to reference",
110
+ "filters": "Filters",
111
+ "location_filter": "Schema locations",
112
+ "factor_variables": "Joined factors",
113
+ "level_groups": "Combines",
114
+ }
115
+
116
+
117
+ def unclassified_fields() -> tuple[list[str], list[str]]:
118
+ """``(missing, stale)``: PlotSpec fields with no class, and entries naming
119
+ no field. Drives ``test_every_plot_field_is_classified``."""
120
+ expected: set[str] = set()
121
+ for f in fields(PlotSpec):
122
+ if not f.init:
123
+ continue
124
+ if f.name in _SPLIT_BLOCKS:
125
+ expected.update(f"{f.name}.{sub.name}" for sub in fields(_SPLIT_BLOCKS[f.name]))
126
+ else:
127
+ expected.add(f.name)
128
+ known = set(DESIGN_FIELDS)
129
+ return sorted(expected - known), sorted(known - expected)
130
+
131
+
132
+ def design_paths() -> list[str]:
133
+ return [path for path, cls in DESIGN_FIELDS.items() if cls is SyncClass.DESIGN]
134
+
135
+
136
+ def _value_at(spec: PlotSpec, path: str) -> Any:
137
+ head, _, tail = path.partition(".")
138
+ value = getattr(spec, head)
139
+ return getattr(value, tail) if tail else value
140
+
141
+
142
+ def _jsonable(value: Any) -> Any:
143
+ """A design value as plain JSON, through the spec's own serialisers."""
144
+ if isinstance(value, Enum):
145
+ return str(value)
146
+ if hasattr(value, "to_dict"):
147
+ return value.to_dict()
148
+ if isinstance(value, dict):
149
+ return {str(k): _jsonable(v) for k, v in value.items()}
150
+ if isinstance(value, (list, tuple)):
151
+ return [_jsonable(v) for v in value]
152
+ if hasattr(value, "__dataclass_fields__"):
153
+ return _jsonable(asdict(value))
154
+ return value
155
+
156
+
157
+ def design_of(spec: PlotSpec) -> dict[str, Any]:
158
+ """The design fields of ``spec`` as JSON, keyed by path."""
159
+ return {path: _jsonable(_value_at(spec, path)) for path in design_paths()}
160
+
161
+
162
+ def design_fingerprint(spec: PlotSpec) -> str:
163
+ """A short stable digest of :func:`design_of`, for "has it diverged?"."""
164
+ text = json.dumps(design_of(spec), sort_keys=True, default=str)
165
+ return hashlib.sha1(text.encode("utf-8")).hexdigest()[:16]
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class DesignDifference:
170
+ """One design field that differs between two specs."""
171
+
172
+ path: str
173
+ label: str
174
+ a: str
175
+ b: str
176
+
177
+ def to_dict(self) -> dict[str, str]:
178
+ return {"path": self.path, "label": self.label, "a": self.a, "b": self.b}
179
+
180
+
181
+ def _summary(path: str, value: Any) -> str:
182
+ """Short human text for a design value (the banner's two sides)."""
183
+ if path == "roles" and isinstance(value, dict):
184
+ return ", ".join(f"{k}={v}" for k, v in sorted(value.items())) or "(none)"
185
+ if path == "groups" and isinstance(value, list):
186
+ # Innermost first in the spec; read outermost-first, as nested ticks do.
187
+ return " × ".join(reversed([str(v) for v in value])) or "(none)"
188
+ if isinstance(value, list) and all(isinstance(v, str) for v in value):
189
+ return ", ".join(value) or "(none)"
190
+ if value in (None, [], {}):
191
+ return "(none)"
192
+ if isinstance(value, bool):
193
+ return "on" if value else "off"
194
+ if isinstance(value, (str, int, float)):
195
+ return str(value)
196
+ text = json.dumps(value, sort_keys=True, default=str)
197
+ return text if len(text) <= 80 else text[:77] + "…"
198
+
199
+
200
+ def design_diff(a: PlotSpec, b: PlotSpec) -> list[DesignDifference]:
201
+ """Every design field where ``a`` and ``b`` differ, in table order.
202
+
203
+ Roles compare as a mapping, so the order the roles were assigned in is not
204
+ a difference. Everything else compares by its JSON form.
205
+ """
206
+ da, db = design_of(a), design_of(b)
207
+ out = []
208
+ for path in design_paths():
209
+ va, vb = da[path], db[path]
210
+ same = (
211
+ dict(va or {}) == dict(vb or {})
212
+ if path == "roles"
213
+ else json.dumps(va, sort_keys=True, default=str) == json.dumps(vb, sort_keys=True, default=str)
214
+ )
215
+ if not same:
216
+ out.append(DesignDifference(path, LABELS.get(path, path), _summary(path, va), _summary(path, vb)))
217
+ Log.debug(
218
+ "design diff: %d field(s) differ%s",
219
+ len(out),
220
+ f" ({', '.join(d.path for d in out)})" if out else "",
221
+ layer=LAYER,
222
+ )
223
+ return out
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class SyncNote:
228
+ """A display setting that pointed at a factor the new design dropped."""
229
+
230
+ path: str
231
+ message: str
232
+
233
+ def to_dict(self) -> dict[str, str]:
234
+ return {"path": self.path, "message": self.message}
235
+
236
+
237
+ def copy_design(source: PlotSpec, target: PlotSpec) -> tuple[PlotSpec, list[SyncNote]]:
238
+ """``target`` with every design field taken from ``source`` (one way,
239
+ wholesale — no merging). Display settings stay ``target``'s.
240
+
241
+ A display setting that names a factor no longer in the right role is
242
+ cleared, with a note: the colour must be a grouping layer, and
243
+ "show sample" and its colour must name collapsed keys.
244
+ """
245
+ changes: dict[str, Any] = {}
246
+ for path in design_paths():
247
+ head, _, tail = path.partition(".")
248
+ if tail:
249
+ block = changes.get(head, getattr(target, head))
250
+ changes[head] = replace(block, **{tail: getattr(getattr(source, head), tail)})
251
+ else:
252
+ changes[head] = getattr(source, head)
253
+ result = replace(target, **changes)
254
+ notes: list[SyncNote] = []
255
+ grouped = {name for name, role in result.roles.items() if role is Role.GROUP}
256
+ collapsed = {name for name, role in result.roles.items() if role is Role.COLLAPSE}
257
+ if result.color and result.color not in grouped:
258
+ notes.append(SyncNote("color", f"colour by {result.color!r} cleared: it is no longer a grouping layer"))
259
+ result = replace(result, color=None)
260
+ kept = [name for name in result.show_sample if name in collapsed]
261
+ if kept != list(result.show_sample):
262
+ dropped = [n for n in result.show_sample if n not in collapsed]
263
+ notes.append(SyncNote("show_sample", f"show sample for {dropped!r} cleared: no longer collapsed"))
264
+ result = replace(result, show_sample=kept)
265
+ if result.sample_color and result.sample_color not in result.roles:
266
+ notes.append(SyncNote("sample_color", f"sample colour {result.sample_color!r} cleared"))
267
+ result = replace(result, sample_color=None)
268
+ Log.info(
269
+ "design sync: %d design field(s) copied, %d display note(s)%s",
270
+ len(design_paths()),
271
+ len(notes),
272
+ "".join(f"; {n.message}" for n in notes),
273
+ layer=LAYER,
274
+ )
275
+ return result, notes
@@ -44,6 +44,8 @@ LAYER = "scistackplot"
44
44
 
45
45
  #: The label a new bar carries.
46
46
  DEFAULT_LABEL = "*"
47
+ #: ``DifferenceBar.origin`` of a bar the user placed.
48
+ MANUAL = "manual"
47
49
 
48
50
 
49
51
  @dataclass(frozen=True)
@@ -55,12 +57,17 @@ class DifferenceBar:
55
57
  - ``a``, ``b``: the two ends, ``{x layer: level text}`` over EVERY x
56
58
  layer (a nested axis names each layer). Unordered.
57
59
  - ``label``: drawn over the bar's middle.
60
+ - ``origin``: who made it. ``"manual"`` (clicked in Plot Studio, stored
61
+ on the spec), or another tag for a bar derived at resolve time by a
62
+ layer above (``"test"``: a statistics result; never stored). The plot
63
+ layer draws every origin the same way and never asks what made a bar.
58
64
  """
59
65
 
60
66
  match: dict[str, str] = field(default_factory=dict)
61
67
  a: dict[str, str] = field(default_factory=dict)
62
68
  b: dict[str, str] = field(default_factory=dict)
63
69
  label: str = DEFAULT_LABEL
70
+ origin: str = MANUAL
64
71
 
65
72
  def problem(self) -> str | None:
66
73
  """Why this bar can never be drawn anywhere, or None."""
@@ -97,12 +104,15 @@ class DifferenceBar:
97
104
  )
98
105
 
99
106
  def to_dict(self) -> dict:
100
- return {
107
+ out = {
101
108
  "match": dict(self.match),
102
109
  "a": dict(self.a),
103
110
  "b": dict(self.b),
104
111
  "label": self.label,
105
112
  }
113
+ if self.origin != MANUAL:
114
+ out["origin"] = self.origin
115
+ return out
106
116
 
107
117
  @classmethod
108
118
  def from_dict(cls, raw: dict) -> "DifferenceBar":
@@ -112,6 +122,7 @@ class DifferenceBar:
112
122
  a=_text_dict(raw.get("a")),
113
123
  b=_text_dict(raw.get("b")),
114
124
  label=DEFAULT_LABEL if label is None else str(label),
125
+ origin=str(raw.get("origin") or MANUAL),
115
126
  )
116
127
 
117
128
  @classmethod
@@ -1385,3 +1396,35 @@ def difference_meta(
1385
1396
  # decisions) or the preview's estimate.
1386
1397
  "estimated": estimated,
1387
1398
  }
1399
+
1400
+
1401
+ # ---------------------------------------------------------------------------
1402
+ # Derived bars (a layer above hands bars in at resolve time)
1403
+ # ---------------------------------------------------------------------------
1404
+
1405
+
1406
+ def with_derived_bars(spec: "PlotSpec", derived: list[DifferenceBar]) -> "PlotSpec":
1407
+ """``spec`` drawing ``derived`` bars as well as its own, for one resolve.
1408
+
1409
+ The one place derived bars join a figure. A derived bar is intent
1410
+ computed from something else (a statistics result), so it is never
1411
+ stored: callers resolve the returned spec and keep saving the original.
1412
+ Derived bars go FIRST and the spec's manual bars after them, so on a
1413
+ duplicate pair the manual bar's label wins (``bars_for_panel`` keeps the
1414
+ last entry), and the duplicate derived bar is dropped here with a log
1415
+ line rather than reaching the WARN.
1416
+ """
1417
+ from dataclasses import replace
1418
+
1419
+ manual = list(spec.difference_bars)
1420
+ kept = [bar for bar in derived if not any(bar.same_pair(m) for m in manual)]
1421
+ dropped = len(derived) - len(kept)
1422
+ Log.info(
1423
+ "difference bars: %d derived (%s) + %d manual%s",
1424
+ len(kept),
1425
+ ", ".join(sorted({bar.origin for bar in kept})) or "none",
1426
+ len(manual),
1427
+ f"; {dropped} derived bar(s) dropped (a manual bar has that pair)" if dropped else "",
1428
+ layer=LAYER,
1429
+ )
1430
+ return replace(spec, difference_bars=[*kept, *manual])
@@ -608,3 +608,65 @@ def _describe_chain(options: DataExportOptions) -> str:
608
608
  if options.pooled:
609
609
  return " x ".join(options.chain) + " (pooled sample)"
610
610
  return " -> ".join([*options.chain[:-1], f"{options.chain[-1]} (sample)"])
611
+
612
+
613
+ # ---------------------------------------------------------------------------
614
+ # 1-D sample curves (statistics: SPM, plan stage 13)
615
+ # ---------------------------------------------------------------------------
616
+
617
+
618
+ def sample_curves(spec: PlotSpec, table: LongTable) -> pd.DataFrame:
619
+ """One row per SAMPLE per cell, its 1-D curve kept as an ndarray in the
620
+ measure column: the rows a statistical parametric mapping (SPM) test sees.
621
+
622
+ The same plan and collapse chain a 1-D band figure runs: ``reduce._plan``
623
+ and the numpy reducer's ``reducer._chain`` (per-position means over the
624
+ collapsed keys, deepest first). So the per-position mean across these
625
+ rows is the band's centre (``tests/test_sample_curves.py``). Every figure
626
+ of the fan-out goes into one frame, the figure keys as columns.
627
+
628
+ ``spec`` must draw the measure as 1-D (a line or band kind). A scalar kind
629
+ collapses each cell first and has no curves, so it raises ``ValueError``
630
+ saying so. Curves of unequal length are kept, with a WARN naming the
631
+ rows: an SPM test needs equal lengths and refuses them itself.
632
+ """
633
+ from collections import Counter
634
+
635
+ from .reduce import _plan
636
+ from .reducer import _chain
637
+ from .series_stats import cell_arrays
638
+
639
+ plan = _plan(spec, table)
640
+ if plan.shape is not Shape.SERIES_1D:
641
+ raise ValueError(
642
+ f"{spec.y_measure} is drawn as {plan.shape} for kind {spec.kind}; sample curves "
643
+ f"need a 1-D measure at a line or band kind"
644
+ )
645
+ steps = collapse_steps(plan.spec, plan.roles, plan.table)
646
+ y = plan.spec.y_measure
647
+ pieces: list[pd.DataFrame] = []
648
+ for figure_key, group in plan.groups:
649
+ frame = group.reset_index(drop=True)
650
+ arrays = cell_arrays(frame[y])
651
+ kept, arrays = _chain(frame, arrays, list(steps.pre), plan.table)
652
+ kept = kept.copy()
653
+ for name, value in figure_key.items():
654
+ if name not in kept.columns:
655
+ kept[name] = value
656
+ kept[y] = list(arrays)
657
+ pieces.append(kept)
658
+ out = pd.concat(pieces, ignore_index=True) if pieces else pd.DataFrame(columns=[y])
659
+ lengths = Counter(len(a) for a in out[y])
660
+ Log.info(
661
+ "[sample-curves] %s: %d figure(s), %d curve(s), lengths %s, %s averaged first",
662
+ y, len(plan.groups), len(out), dict(lengths), list(steps.pre) or "nothing", layer=LAYER,
663
+ )
664
+ if len(lengths) > 1:
665
+ common = lengths.most_common(1)[0][0]
666
+ odd = [i for i, a in enumerate(out[y]) if len(a) != common]
667
+ Log.warn(
668
+ "[sample-curves] %s: curves of unequal length (%s); rows %s differ from the common %d. "
669
+ "An SPM test refuses them: time-normalise first",
670
+ y, dict(lengths), odd[:10], common, layer=LAYER,
671
+ )
672
+ return out
@@ -38,6 +38,7 @@ from typing import Any
38
38
  import numpy as np
39
39
 
40
40
  from .spec import ErrorBand, Statistic
41
+ from .spread import SD_DDOF, ci95_spread, sem_spread
41
42
 
42
43
 
43
44
  def cell_array(value: Any) -> np.ndarray:
@@ -187,18 +188,19 @@ def position_stats(
187
188
  low[start:stop] = c
188
189
  high[start:stop] = c
189
190
  elif error is ErrorBand.IQR:
191
+ # numpy's default interpolation is linear, csv-stats'
192
+ # QUANTILE_METHOD (pinned by tests/test_ci95.py).
190
193
  low[start:stop] = np.nanpercentile(padded, 25, axis=0)
191
194
  high[start:stop] = np.nanpercentile(padded, 75, axis=0)
192
195
  else:
193
- sd = np.nanstd(padded, axis=0, ddof=1)
196
+ sd = np.nanstd(padded, axis=0, ddof=SD_DDOF)
194
197
  sd = np.where(n >= 2, sd, 0.0) # std(ddof=1).fillna(0.0)
195
- denominator = np.where(n > 0, n, 1) # count.where(count > 0, 1)
196
198
  if error is ErrorBand.SD:
197
199
  spread = sd
198
200
  elif error is ErrorBand.SEM:
199
- spread = sd / np.sqrt(denominator)
200
- else: # CI95
201
- spread = 1.96 * sd / np.sqrt(denominator)
201
+ spread = sem_spread(sd, n)
202
+ else: # CI95 — t-based, csvstats.descriptive via spread
203
+ spread = ci95_spread(sd, n)
202
204
  low[start:stop] = c - spread
203
205
  high[start:stop] = c + spread
204
206
  return centre, low, high, count
@@ -0,0 +1,68 @@
1
+ """How a plot DRAWS a 95% confidence interval. The formula is not defined here.
2
+
3
+ The formula belongs to ``csvstats.descriptive``, the one owner of every
4
+ descriptive statistic in scistack (user decision 2026-10-17). It is the
5
+ t-based interval of the mean, ``centre ± t(0.975, n - 1) · SD / √n``.
6
+ StatStudio's tables and csv-stats' test results read the same functions, so a
7
+ figure's error bar and a table's CI cannot disagree.
8
+
9
+ This module adds only the drawing policy. csv-stats returns NaN when there
10
+ are fewer than two values, and a plot draws that as a zero-width bar,
11
+ matching the ``std(ddof=1).fillna(0.0)`` its SD bars already use.
12
+
13
+ Consumers: ``ylimits.spread_bounds`` / ``ylimits._summary_bounds`` (pandas;
14
+ what ``reduce._summarize`` draws), ``series_stats.position_stats`` (the numpy
15
+ twin), and ``codegen``, whose exported code calls ``csvstats.descriptive.
16
+ ci_interval`` as seaborn's ``errorbar=``.
17
+
18
+ Before 2026-10-17 the preview drew ``1.96 · SEM`` and the export drew
19
+ seaborn's bootstrap ``("ci", 95)``: two answers for one spec.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ import numpy as np
27
+ import pandas as pd
28
+ from csvstats.descriptive import QUANTILE_METHOD, SD_DDOF, ci_half_width, sem
29
+ from scistacklog import Log
30
+
31
+ LAYER = "scistackplot"
32
+
33
+ #: SD_DDOF / QUANTILE_METHOD are csv-stats' conventions, re-exported for the plot's
34
+ #: own groupby/nan reductions so they name the choice instead of restating it.
35
+ __all__ = ["SD_DDOF", "QUANTILE_METHOD", "ci95_spread", "sem_spread"]
36
+
37
+
38
+ def _zero_where_undefined(half: Any) -> Any:
39
+ """NaN (no interval below two values) drawn as a zero-width bar."""
40
+ if isinstance(half, pd.Series):
41
+ return half.fillna(0.0)
42
+ if np.ndim(half) == 0:
43
+ return 0.0 if np.isnan(half) else float(half)
44
+ return np.nan_to_num(half, nan=0.0)
45
+
46
+
47
+ def sem_spread(sd: Any, count: Any) -> Any:
48
+ """Half-width of the SEM bar: csv-stats' SEM, 0 where it has none."""
49
+ return _zero_where_undefined(sem(sd, count))
50
+
51
+
52
+ def ci95_spread(sd: Any, count: Any) -> Any:
53
+ """Half-width of the CI95 bar: csv-stats' value, 0 where it has none.
54
+
55
+ Takes and returns a scalar, numpy array or pandas Series (index kept).
56
+ """
57
+ half = ci_half_width(sd, count)
58
+ counts = np.asarray(count, dtype=float)
59
+ if counts.size:
60
+ valid = counts[counts >= 2]
61
+ if valid.size:
62
+ Log.debug(
63
+ "spread ci95: t-based (csvstats.descriptive), n %d..%d",
64
+ int(valid.min()),
65
+ int(valid.max()),
66
+ layer=LAYER,
67
+ )
68
+ return _zero_where_undefined(half)
@@ -47,6 +47,7 @@ from .numeric import coerce_numeric
47
47
  from .panels import PanelOverride
48
48
  from .shape import Shape
49
49
  from .spec import ErrorBand, PlotKind, PlotSpec, Role, Statistic
50
+ from .spread import QUANTILE_METHOD, SD_DDOF, ci95_spread, sem_spread
50
51
  from .table import LongTable
51
52
 
52
53
  LAYER = "scistackplot"
@@ -730,15 +731,18 @@ def spread_bounds(grouped, centre, spec: PlotSpec):
730
731
  if error is ErrorBand.NONE:
731
732
  return centre, centre
732
733
  if error is ErrorBand.IQR:
733
- return grouped.quantile(0.25), grouped.quantile(0.75)
734
- sd = grouped.std(ddof=1).fillna(0.0)
734
+ return (
735
+ grouped.quantile(0.25, interpolation=QUANTILE_METHOD),
736
+ grouped.quantile(0.75, interpolation=QUANTILE_METHOD),
737
+ )
738
+ sd = grouped.std(ddof=SD_DDOF).fillna(0.0)
735
739
  count = grouped.count()
736
740
  if error is ErrorBand.SD:
737
741
  spread = sd
738
742
  elif error is ErrorBand.SEM:
739
- spread = sd / np.sqrt(count.where(count > 0, 1))
740
- else: # CI95
741
- spread = 1.96 * sd / np.sqrt(count.where(count > 0, 1))
743
+ spread = sem_spread(sd, count)
744
+ else: # CI95 — t-based, csvstats.descriptive via spread
745
+ spread = ci95_spread(sd, count)
742
746
  return centre - spread, centre + spread
743
747
 
744
748
 
@@ -751,16 +755,20 @@ def _summary_bounds(values: pd.Series, spec: PlotSpec):
751
755
  if error is ErrorBand.NONE:
752
756
  return centre, centre, centre
753
757
  if error is ErrorBand.IQR:
754
- return centre, values.quantile(0.25), values.quantile(0.75)
755
- sd = values.std(ddof=1)
758
+ return (
759
+ centre,
760
+ values.quantile(0.25, interpolation=QUANTILE_METHOD),
761
+ values.quantile(0.75, interpolation=QUANTILE_METHOD),
762
+ )
763
+ sd = values.std(ddof=SD_DDOF)
756
764
  sd = 0.0 if pd.isna(sd) else sd
757
765
  count = max(len(values), 1)
758
766
  if error is ErrorBand.SD:
759
767
  spread = sd
760
768
  elif error is ErrorBand.SEM:
761
- spread = sd / np.sqrt(count)
762
- else:
763
- spread = 1.96 * sd / np.sqrt(count)
769
+ spread = sem_spread(sd, count)
770
+ else: # CI95 — t-based, csvstats.descriptive via spread
771
+ spread = ci95_spread(sd, count)
764
772
  return centre, centre - spread, centre + spread
765
773
 
766
774
 
File without changes