scistackplot 0.1.39__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.39 → scistackplot-0.1.42}/.gitignore +3 -0
  2. {scistackplot-0.1.39 → scistackplot-0.1.42}/PKG-INFO +3 -2
  3. {scistackplot-0.1.39 → scistackplot-0.1.42}/pyproject.toml +3 -0
  4. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/codegen.py +38 -6
  5. scistackplot-0.1.42/src/scistackplot/design.py +275 -0
  6. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/diffbars.py +137 -17
  7. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/export.py +62 -0
  8. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/render/mpl.py +6 -5
  9. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/render/plotly_.py +2 -2
  10. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/series_stats.py +7 -5
  11. scistackplot-0.1.42/src/scistackplot/spread.py +68 -0
  12. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/weights.py +17 -0
  13. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/ylimits.py +18 -10
  14. {scistackplot-0.1.39 → scistackplot-0.1.42}/README.md +0 -0
  15. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/__init__.py +0 -0
  16. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/aliases.py +0 -0
  17. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/autosize.py +0 -0
  18. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/capability.py +0 -0
  19. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/cell.py +0 -0
  20. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/colors.py +0 -0
  21. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/compare.py +0 -0
  22. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/custom.py +0 -0
  23. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/dedup.py +0 -0
  24. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/figsize.py +0 -0
  25. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/figure_file.py +0 -0
  26. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/framesize.py +0 -0
  27. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/groups.py +0 -0
  28. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/numeric.py +0 -0
  29. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/panels.py +0 -0
  30. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/paper.py +0 -0
  31. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/presets.py +0 -0
  32. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/reduce.py +0 -0
  33. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/reducer.py +0 -0
  34. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/render/__init__.py +0 -0
  35. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/render/base.py +0 -0
  36. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/resolved.py +0 -0
  37. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/restore.py +0 -0
  38. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/roles.py +0 -0
  39. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/shape.py +0 -0
  40. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/sources/__init__.py +0 -0
  41. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/sources/base.py +0 -0
  42. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/sources/csv.py +0 -0
  43. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/sources/frame.py +0 -0
  44. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/spaghetti.py +0 -0
  45. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/spec.py +0 -0
  46. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/table.py +0 -0
  47. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/textsize.py +0 -0
  48. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/ticklabels.py +0 -0
  49. {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/variants.py +0 -0
  50. {scistackplot-0.1.39 → 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.39
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.39
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
 
@@ -64,7 +64,7 @@ from .spec import (
64
64
  from .table import MISSING_LEVEL, LongTable
65
65
  from .paper import PAPER, figure_rc_params, paper_axes_code, seaborn_err_kws
66
66
  from .textsize import ResolvedSizes, resolve_sizes
67
- from .weights import sample_weight, spaghetti_weight
67
+ from .weights import SAMPLE_EDGE_WIDTH, sample_weight, spaghetti_weight
68
68
  from .ylimits import GLOBAL_KEY, pinned_ends
69
69
  from .variants import (
70
70
  LATEST,
@@ -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):
@@ -1309,7 +1313,7 @@ def _sample_draw_lines(
1309
1313
  ),
1310
1314
  f" _ax.scatter(_part[{_X_POSITION!r}], _part[{y!r}], "
1311
1315
  f"s={weight.marker_area:.3f}, color=_point_colors, alpha={SAMPLE_ALPHA}, "
1312
- f"edgecolors={SAMPLE_EDGE_COLOR!r}, linewidths=0.5, "
1316
+ f"edgecolors={SAMPLE_EDGE_COLOR!r}, linewidths={SAMPLE_EDGE_WIDTH:g}, "
1313
1317
  + (
1314
1318
  # Over the line, and marked as part of its run
1315
1319
  # (render.base.SAMPLE_LINE_POINTS_GID), as the preview.
@@ -1325,7 +1329,8 @@ def _sample_draw_lines(
1325
1329
  " _ax.plot(",
1326
1330
  f" _part[{_X_POSITION!r}], _part[{y!r}],",
1327
1331
  f" linestyle={linestyle!r}, marker='o', markersize={weight.marker_pt:.3f},",
1328
- f" markeredgecolor={SAMPLE_EDGE_COLOR!r}, markeredgewidth=0.5,",
1332
+ f" markeredgecolor={SAMPLE_EDGE_COLOR!r}, "
1333
+ f"markeredgewidth={SAMPLE_EDGE_WIDTH:g},",
1329
1334
  f" color={paint}, alpha={SAMPLE_ALPHA}, "
1330
1335
  f"linewidth={weight.line_pt:.3f}, zorder=3,",
1331
1336
  " )",
@@ -1363,7 +1368,7 @@ def _sample_legend_lines(
1363
1368
  " for _lvl in _sample_levels:",
1364
1369
  " _entries[_lvl] = Line2D([], [], color=_sample_palette[_lvl], "
1365
1370
  f"linestyle={linestyle!r}, marker='o', markeredgecolor={SAMPLE_EDGE_COLOR!r}, "
1366
- "markeredgewidth=0.5)",
1371
+ f"markeredgewidth={SAMPLE_EDGE_WIDTH:g})",
1367
1372
  f" g.add_legend(legend_data=_entries, title={title!r})",
1368
1373
  ]
1369
1374
 
@@ -1694,6 +1699,32 @@ def _alias_relabel_lines(aliases: _ExportAliases, x_categorical: bool) -> list[s
1694
1699
  return lines
1695
1700
 
1696
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
+
1697
1728
  def _plot_call(
1698
1729
  spec, table, roles, shape, y_plan: _YLimitPlan, base: LongTable | None = None
1699
1730
  ) -> list[str]:
@@ -1864,6 +1895,7 @@ def _plot_call(
1864
1895
  aliases = _export_aliases(spec, table, roles, shape)
1865
1896
  lines = [
1866
1897
  *order_lines,
1898
+ *_ci95_lines(spec, shape),
1867
1899
  *palette_lines,
1868
1900
  *_alias_definition_lines(aliases),
1869
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
@@ -428,8 +439,15 @@ def log_figure(resolved: "ResolvedPlot") -> None:
428
439
  # bars need depends on ``u``, which depends on the top, so the top is found
429
440
  # by iterating to a fixed point (:func:`place_group`).
430
441
 
431
- #: Clearance between whatever is below and a bar's line (or a leg's foot).
442
+ #: Clearance between whatever is below and a bar's line.
432
443
  GAP_PT = 3.0
444
+ #: Clearance between the data under an end and that leg's foot (2026-10-16:
445
+ #: at GAP_PT the leg read as touching the data). Larger than GAP_PT: the line
446
+ #: only spans, a leg points AT a mark.
447
+ FOOT_GAP_PT = 6.0
448
+ #: The shortest a leg is drawn, from its foot to the line (2026-10-16:
449
+ #: before, the leg over the taller end was half a line width — no leg).
450
+ MIN_LEG_PT = 5.0
433
451
  #: A bar's line width.
434
452
  LINE_PT = 1.0
435
453
  #: Between the line and the bottom of its label's box.
@@ -560,6 +578,8 @@ class BarSizes:
560
578
  #: The label's font size (``TextSizes.differences``).
561
579
  label_font_pt: float
562
580
  gap_pt: float = GAP_PT
581
+ foot_gap_pt: float = FOOT_GAP_PT
582
+ min_leg_pt: float = MIN_LEG_PT
563
583
  line_pt: float = LINE_PT
564
584
  label_gap_pt: float = LABEL_GAP_PT
565
585
  pad_pt: float = PAD_PT
@@ -703,19 +723,31 @@ def _place_once(
703
723
  floors.append(under_label - above_line)
704
724
  floor = max(floors) if floors else bottom
705
725
  y = floor + (sizes.gap_pt + half_line) / per_y
726
+
727
+ # Each leg stops ``foot_gap_pt`` above what is under its end, so it
728
+ # never reads as touching the data. The line rises until every leg is
729
+ # at least ``min_leg_pt`` long (the end under the span's highest mark,
730
+ # or a lower bar's level, would otherwise get no visible leg). Both in
731
+ # points, so neither changes with the y range.
732
+ min_leg = sizes.min_leg_pt / per_y
733
+ foot_gap = sizes.foot_gap_pt / per_y
734
+ # A leg is a line: what it must clear is under its whole width, not
735
+ # its centre (a marker 3.01 pt off a leg of half-width 0.5 pt with a
736
+ # 3.5 pt reach was missed at the centre and touched, 2026-10-16).
737
+ half_leg = half_line / per_x
738
+ unders = [height(x - half_leg, x + half_leg) for x in (left, right)]
739
+ raised = [under + foot_gap + min_leg for under in unders if under is not None]
740
+ if raised:
741
+ y = max(y, max(raised))
742
+ feet = [
743
+ under + foot_gap
744
+ if under is not None
745
+ else max(bottom, y - max(2 * sizes.gap_pt / per_y, min_leg))
746
+ for under in unders
747
+ ]
706
748
  label_bottom = y + above_line
707
749
  label_top = label_bottom + height_pt / per_y
708
750
 
709
- feet = []
710
- for x in (left, right):
711
- under = height(x, x)
712
- foot = (
713
- min(y, under + sizes.gap_pt / per_y)
714
- if under is not None
715
- else max(bottom, y - 2 * sizes.gap_pt / per_y)
716
- )
717
- feet.append(foot)
718
-
719
751
  if pinned and label_top > top:
720
752
  unfit.append(item)
721
753
  continue
@@ -842,6 +874,59 @@ def place_group(
842
874
  )
843
875
 
844
876
 
877
+ def leg_lengths_pt(
878
+ result: GroupPlacement, frames: list[PanelFrame], log: bool = False
879
+ ) -> list[float]:
880
+ """Every placed leg's length in points on the final axis — what
881
+ ``MIN_LEG_PT`` guarantees (unless a leg reaches the axis bottom)."""
882
+ axis = (lambda v: math.log10(v)) if log else (lambda v: v)
883
+ bottom, top = axis(result.limits[0]), axis(result.limits[1])
884
+ lengths = []
885
+ for placed, frame in zip(result.placed, frames):
886
+ per_y = frame.height_pt / (top - bottom) if top > bottom else 0.0
887
+ for bar in placed:
888
+ for foot in (bar.left_foot, bar.right_foot):
889
+ lengths.append((axis(bar.y) - axis(foot)) * per_y)
890
+ return lengths
891
+
892
+
893
+ def foot_clearances_pt(
894
+ result: GroupPlacement, problems: list[PanelProblem], log: bool = False
895
+ ) -> list[float]:
896
+ """Every placed foot's height above the data under it, in points on the
897
+ final axis — what ``FOOT_GAP_PT`` guarantees. *problems* hold the data's
898
+ obstacles only (no bars), so a foot resting over a lower bar reports
899
+ its height above the data, which is more than the gap. A foot with no
900
+ data under it is left out."""
901
+ axis = (lambda v: math.log10(v)) if log else (lambda v: v)
902
+ bottom, top = axis(result.limits[0]), axis(result.limits[1])
903
+ clearances = []
904
+ for placed, problem in zip(result.placed, problems):
905
+ if top <= bottom:
906
+ continue
907
+ per_y = problem.frame.height_pt / (top - bottom)
908
+ per_x = problem.frame.per_unit_x
909
+ obstacles = problem.obstacles.to_axis(log)
910
+ half_leg = (LINE_PT / 2.0) / per_x
911
+ for bar in placed:
912
+ for x, foot in ((bar.left, bar.left_foot), (bar.right, bar.right_foot)):
913
+ under = obstacles.top_over(
914
+ x - half_leg, x + half_leg, per_unit_y=per_y, per_unit_x=per_x
915
+ )
916
+ if under is not None:
917
+ clearances.append((axis(foot) - under) * per_y)
918
+ return clearances
919
+
920
+
921
+ def _legs_note(result: GroupPlacement, problems: list[PanelProblem], log: bool) -> str:
922
+ lengths = leg_lengths_pt(result, [p.frame for p in problems], log)
923
+ clearances = foot_clearances_pt(result, problems, log)
924
+ note = f", shortest leg {min(lengths):.1f} pt" if lengths else ""
925
+ if clearances:
926
+ note += f", lowest foot {min(clearances):.1f} pt above the data"
927
+ return note
928
+
929
+
845
930
  # ---------------------------------------------------------------------------
846
931
  # A resolved figure -> problems
847
932
 
@@ -859,9 +944,10 @@ def panel_obstacles(resolved: "ResolvedPlot", panel: "Panel") -> Obstacles:
859
944
  ``spanmode: "hard"`` stop at the data); a box's flier adds its radius;
860
945
  - strip: every point, jittered ``±STRIP_JITTER``, plus the marker radius;
861
946
  - scatter: every point plus the marker radius;
862
- - spaghetti: every point at its line's offset plus the marker radius,
947
+ - spaghetti: every point at its line's offset plus the marker's reach
948
+ (radius + half its edge stroke, ``weights.ResolvedWeight.marker_reach_pt``),
863
949
  and every segment of every run plus half the line width;
864
- - overlay: every point at ``sample_positions`` plus its radius, and the
950
+ - overlay: every point at ``sample_positions`` plus its reach, and the
865
951
  joining segments when joined.
866
952
  """
867
953
  from .render.base import (
@@ -918,7 +1004,7 @@ def panel_obstacles(resolved: "ResolvedPlot", panel: "Panel") -> Obstacles:
918
1004
  xs, _ = x_positions(run[encoding.x], resolved)
919
1005
  xs = xs + offsets.get(str(series_id), 0.0)
920
1006
  ys = pd.to_numeric(run[encoding.y], errors="coerce").to_numpy(dtype=float)
921
- rows.extend(_polyline(xs, ys, weight.marker_pt / 2.0, weight.line_pt / 2.0, True))
1007
+ rows.extend(_polyline(xs, ys, weight.marker_reach_pt, weight.line_pt / 2.0, True))
922
1008
  else: # scatter, and anything else drawn as points
923
1009
  rows.extend((p, p, v, v, marker_radius) for p, v in zip(positions, values))
924
1010
 
@@ -929,7 +1015,7 @@ def panel_obstacles(resolved: "ResolvedPlot", panel: "Panel") -> Obstacles:
929
1015
  xs = sample_positions(run.rows, resolved, run.identity)
930
1016
  ys = pd.to_numeric(run.rows[encoding.y], errors="coerce").to_numpy(dtype=float)
931
1017
  joined = resolved.sample_join and len(run.rows) > 1
932
- rows.extend(_polyline(xs, ys, weight.marker_pt / 2.0, weight.line_pt / 2.0, joined))
1018
+ rows.extend(_polyline(xs, ys, weight.marker_reach_pt, weight.line_pt / 2.0, joined))
933
1019
  return Obstacles.build(rows)
934
1020
 
935
1021
 
@@ -1106,7 +1192,9 @@ def place_figure(
1106
1192
  notes.append(
1107
1193
  f"{names}: {sum(len(p) for p in result.placed)} placed, {n_unfit} unfit, "
1108
1194
  f"top {shared[1]:.6g} -> {result.limits[1]:.6g} "
1109
- f"({result.iterations} iteration(s), bars {result.bar_fraction:.0%} of the height)"
1195
+ f"({result.iterations} iteration(s), bars {result.bar_fraction:.0%} of the height"
1196
+ + _legs_note(result, problems, log)
1197
+ + ")"
1110
1198
  + (f", raised to at least {top_floor:.6g} for other figures" if top_floor is not None else "")
1111
1199
  )
1112
1200
  Log.debug(
@@ -1308,3 +1396,35 @@ def difference_meta(
1308
1396
  # decisions) or the preview's estimate.
1309
1397
  "estimated": estimated,
1310
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
@@ -25,7 +25,7 @@ from ..spec import PlotKind
25
25
  from ..table import natural_sort_key
26
26
  from ..paper import PAPER, apply_paper_axes, describe_paper, figure_rc_params
27
27
  from ..textsize import ResolvedSizes, sizes_for
28
- from ..weights import describe_weights, sample_weight, spaghetti_weight
28
+ from ..weights import SAMPLE_EDGE_WIDTH, describe_weights, sample_weight, spaghetti_weight
29
29
  from ..ticklabels import (
30
30
  BRACKET_POLICY,
31
31
  TICK_POLICY,
@@ -396,6 +396,7 @@ def _draw_spaghetti(ax, frame, resolved) -> None:
396
396
  # A diameter in pt (weights.spaghetti_weight): at 1x the same
397
397
  # visual size as the scatter kinds' `s` area.
398
398
  markersize=weight.marker_pt,
399
+ markeredgewidth=weight.marker_edge_pt,
399
400
  label=(
400
401
  resolved.text.color_level(level)
401
402
  if (level is not None and position == 0)
@@ -629,7 +630,7 @@ def _draw_sample(ax, panel, resolved: ResolvedPlot) -> tuple[int, int]:
629
630
  marker="o",
630
631
  markersize=weight.marker_pt,
631
632
  markeredgecolor=SAMPLE_EDGE_COLOR,
632
- markeredgewidth=0.5,
633
+ markeredgewidth=weight.marker_edge_pt,
633
634
  zorder=3,
634
635
  )
635
636
  continue
@@ -651,7 +652,7 @@ def _draw_sample(ax, panel, resolved: ResolvedPlot) -> tuple[int, int]:
651
652
  color=[run.point_colors[i] for i in order],
652
653
  alpha=SAMPLE_ALPHA,
653
654
  edgecolors=SAMPLE_EDGE_COLOR,
654
- linewidths=0.5,
655
+ linewidths=weight.marker_edge_pt,
655
656
  zorder=3.1,
656
657
  gid=SAMPLE_LINE_POINTS_GID,
657
658
  )
@@ -663,7 +664,7 @@ def _draw_sample(ax, panel, resolved: ResolvedPlot) -> tuple[int, int]:
663
664
  color=color,
664
665
  alpha=SAMPLE_ALPHA,
665
666
  edgecolors=SAMPLE_EDGE_COLOR,
666
- linewidths=0.5,
667
+ linewidths=weight.marker_edge_pt,
667
668
  zorder=3,
668
669
  )
669
670
  artists += len(lone)
@@ -687,7 +688,7 @@ def _sample_legend_handles(ax, resolved: ResolvedPlot) -> None:
687
688
  linestyle="-" if resolved.sample_join else "none",
688
689
  marker="o",
689
690
  markeredgecolor=SAMPLE_EDGE_COLOR,
690
- markeredgewidth=0.5,
691
+ markeredgewidth=SAMPLE_EDGE_WIDTH,
691
692
  label=resolved.text.sample_level(level),
692
693
  )
693
694
 
@@ -43,7 +43,7 @@ from ..diffbars import (
43
43
  sibling_floors,
44
44
  )
45
45
  from ..textsize import sizes_for
46
- from ..weights import mark_weights_meta, sample_weight, spaghetti_weight
46
+ from ..weights import SAMPLE_EDGE_WIDTH, mark_weights_meta, sample_weight, spaghetti_weight
47
47
  from ..xaxis import LEAF_SEPARATOR
48
48
  from .base import (
49
49
  MARK_OFFSET_GROUP,
@@ -758,7 +758,7 @@ def _sample_traces(
758
758
  if run.uniform
759
759
  else [run.point_colors[i] for i in order],
760
760
  "size": weight.marker_px,
761
- "line": {"color": SAMPLE_EDGE_COLOR, "width": 0.5},
761
+ "line": {"color": SAMPLE_EDGE_COLOR, "width": SAMPLE_EDGE_WIDTH},
762
762
  },
763
763
  "line": {"color": run.line_color, "width": weight.line_pt},
764
764
  "opacity": SAMPLE_ALPHA,
@@ -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)
@@ -29,6 +29,14 @@ SAMPLE_MARKER_FRACTION = 0.45
29
29
  SAMPLE_LINE_WIDTH = 1.0
30
30
  #: A spaghetti's own polylines at 1x.
31
31
  SPAGHETTI_LINE_WIDTH = 1.2
32
+ #: The edge stroke round a marker, in points, at every weight (it outlines;
33
+ #: it does not scale). It widens the marker by half its width on each side,
34
+ #: which ``diffbars.panel_obstacles`` must count (2026-10-16: a leg touched a
35
+ #: spaghetti marker the obstacle model thought it cleared).
36
+ SAMPLE_EDGE_WIDTH = 0.5
37
+ #: matplotlib's ``lines.markeredgewidth`` default, which the spaghetti drew
38
+ #: before it was stated here.
39
+ SPAGHETTI_EDGE_WIDTH = 1.0
32
40
  #: The plotly preview draws a marker of ``d`` pt at ``d x 4/3`` px — the
33
41
  #: 96/72 pt-to-px ratio. Pinned so 1x reproduces the preview as it was (8 px
34
42
  #: markers where matplotlib draws 6 pt). Lines are 1 pt = 1 px, as before.
@@ -54,6 +62,13 @@ class ResolvedWeight:
54
62
  marker_pt: float
55
63
  #: Line width in points (and px in the preview).
56
64
  line_pt: float
65
+ #: The marker's edge stroke in points (``markeredgewidth``).
66
+ marker_edge_pt: float
67
+
68
+ @property
69
+ def marker_reach_pt(self) -> float:
70
+ """How far a marker reaches from its centre, edge included."""
71
+ return self.marker_pt / 2.0 + self.marker_edge_pt / 2.0
57
72
 
58
73
  @property
59
74
  def marker_area(self) -> float:
@@ -83,6 +98,7 @@ def sample_weight(style: StyleOptions) -> ResolvedWeight:
83
98
  weight=w,
84
99
  marker_pt=math.sqrt(style.marker_size * SAMPLE_MARKER_FRACTION) * w,
85
100
  line_pt=SAMPLE_LINE_WIDTH * w,
101
+ marker_edge_pt=SAMPLE_EDGE_WIDTH,
86
102
  )
87
103
 
88
104
 
@@ -94,6 +110,7 @@ def spaghetti_weight(style: StyleOptions) -> ResolvedWeight:
94
110
  weight=w,
95
111
  marker_pt=math.sqrt(style.marker_size) * w,
96
112
  line_pt=SPAGHETTI_LINE_WIDTH * w,
113
+ marker_edge_pt=SPAGHETTI_EDGE_WIDTH,
97
114
  )
98
115
 
99
116
 
@@ -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