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.
- {scistackplot-0.1.39 → scistackplot-0.1.42}/.gitignore +3 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/PKG-INFO +3 -2
- {scistackplot-0.1.39 → scistackplot-0.1.42}/pyproject.toml +3 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/codegen.py +38 -6
- scistackplot-0.1.42/src/scistackplot/design.py +275 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/diffbars.py +137 -17
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/export.py +62 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/render/mpl.py +6 -5
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/render/plotly_.py +2 -2
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/series_stats.py +7 -5
- scistackplot-0.1.42/src/scistackplot/spread.py +68 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/weights.py +17 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/ylimits.py +18 -10
- {scistackplot-0.1.39 → scistackplot-0.1.42}/README.md +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/__init__.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/aliases.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/autosize.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/capability.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/cell.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/colors.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/compare.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/custom.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/dedup.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/figsize.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/figure_file.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/framesize.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/groups.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/numeric.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/panels.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/paper.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/presets.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/reduce.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/reducer.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/render/__init__.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/render/base.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/resolved.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/restore.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/roles.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/shape.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/sources/__init__.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/sources/base.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/sources/csv.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/sources/frame.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/spaghetti.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/spec.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/table.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/textsize.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/ticklabels.py +0 -0
- {scistackplot-0.1.39 → scistackplot-0.1.42}/src/scistackplot/variants.py +0 -0
- {scistackplot-0.1.39 → 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
|
|
|
@@ -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
|
-
|
|
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):
|
|
@@ -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=
|
|
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},
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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":
|
|
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=
|
|
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)
|
|
@@ -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
|
|
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
|