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.
- {scistackplot-0.1.40 → scistackplot-0.1.42}/.gitignore +3 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/PKG-INFO +3 -2
- {scistackplot-0.1.40 → scistackplot-0.1.42}/pyproject.toml +3 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/codegen.py +33 -2
- scistackplot-0.1.42/src/scistackplot/design.py +275 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/diffbars.py +44 -1
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/export.py +62 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/series_stats.py +7 -5
- scistackplot-0.1.42/src/scistackplot/spread.py +68 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/ylimits.py +18 -10
- {scistackplot-0.1.40 → scistackplot-0.1.42}/README.md +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/__init__.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/aliases.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/autosize.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/capability.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/cell.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/colors.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/compare.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/custom.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/dedup.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/figsize.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/figure_file.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/framesize.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/groups.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/numeric.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/panels.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/paper.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/presets.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/reduce.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/reducer.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/render/__init__.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/render/base.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/render/mpl.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/render/plotly_.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/resolved.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/restore.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/roles.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/shape.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/sources/__init__.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/sources/base.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/sources/csv.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/sources/frame.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/spaghetti.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/spec.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/table.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/textsize.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/ticklabels.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/variants.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/weights.py +0 -0
- {scistackplot-0.1.40 → scistackplot-0.1.42}/src/scistackplot/xaxis.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: scistackplot
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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=
|
|
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
|
|
200
|
-
else: # CI95
|
|
201
|
-
spread =
|
|
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
|
|
734
|
-
|
|
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
|
|
740
|
-
else: # CI95
|
|
741
|
-
spread =
|
|
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
|
|
755
|
-
|
|
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
|
|
762
|
-
else:
|
|
763
|
-
spread =
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|