scistackplot 0.1.54__tar.gz → 0.1.56__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.54 → scistackplot-0.1.56}/PKG-INFO +53 -3
- {scistackplot-0.1.54 → scistackplot-0.1.56}/README.md +49 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/pyproject.toml +6 -2
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/__init__.py +23 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/aliases.py +16 -0
- scistackplot-0.1.56/src/scistackplot/bins.py +367 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/capability.py +107 -15
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/codegen.py +337 -16
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/compare.py +36 -2
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/design.py +7 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/diffbars.py +10 -2
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/export.py +138 -5
- scistackplot-0.1.56/src/scistackplot/fits.py +218 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/presets.py +13 -1
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/reduce.py +236 -1
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/render/base.py +14 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/render/mpl.py +94 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/render/plotly_.py +158 -1
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/resolved.py +30 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/roles.py +119 -6
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/shape.py +6 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/spec.py +197 -4
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/table.py +77 -12
- scistackplot-0.1.56/src/scistackplot/temporal.py +641 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/ylimits.py +4 -4
- {scistackplot-0.1.54 → scistackplot-0.1.56}/.gitignore +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/autosize.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/cell.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/colors.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/custom.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/dedup.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/figsize.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/figure_file.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/framesize.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/groups.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/numeric.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/panels.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/paper.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/reducer.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/render/__init__.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/restore.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/series_stats.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/sources/__init__.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/sources/base.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/sources/csv.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/sources/frame.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/spaghetti.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/spread.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/textsize.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/ticklabels.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/variants.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/src/scistackplot/weights.py +0 -0
- {scistackplot-0.1.54 → scistackplot-0.1.56}/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.56
|
|
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,10 +17,11 @@ 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.
|
|
20
|
+
Requires-Dist: csv-stats>=0.5.0
|
|
21
21
|
Requires-Dist: numpy>=1.21
|
|
22
22
|
Requires-Dist: pandas>=1.5
|
|
23
|
-
Requires-Dist:
|
|
23
|
+
Requires-Dist: scicanonicalhash==0.1.56
|
|
24
|
+
Requires-Dist: scistacklog==0.1.56
|
|
24
25
|
Provides-Extra: dev
|
|
25
26
|
Requires-Dist: matplotlib>=3.6; extra == 'dev'
|
|
26
27
|
Requires-Dist: plotly>=5.0; extra == 'dev'
|
|
@@ -159,6 +160,55 @@ longer groups) is inert: the raw values are drawn and the reason is
|
|
|
159
160
|
reported. The exported code and "Save data" compare the same way, and the saved value column is named after what it holds (`M, % change from session 1`). See
|
|
160
161
|
`docs/claude/compare-to-reference.md`.
|
|
161
162
|
|
|
163
|
+
## Datetimes
|
|
164
|
+
|
|
165
|
+
A datetime column is plotted by the same rules as a number. As the measure,
|
|
166
|
+
it sits on a time axis; as a factor, its levels read `2026-07-01 14:00 CEST`
|
|
167
|
+
and are ordered by time. `PlotSpec.time_zone` decides how values recorded
|
|
168
|
+
in different zones share the figure:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
spec = PlotSpec(measures=["SessionStart"], ..., time_zone="local") # each value's own clock (default)
|
|
172
|
+
spec = PlotSpec(measures=["SessionStart"], ..., time_zone="UTC") # every aware value converted
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
A datetime measure offers no bar, since a bar would start at 1970. A log axis
|
|
176
|
+
is ignored with a note. A difference comparison is drawn in hours, and a %
|
|
177
|
+
change does not apply. Save data writes ISO times and names the zone in the
|
|
178
|
+
header, and the exported code draws the same date ticks. See
|
|
179
|
+
`docs/claude/plot-datetimes.md`.
|
|
180
|
+
|
|
181
|
+
## Histograms and regressions
|
|
182
|
+
|
|
183
|
+
Two kinds put a number line on x. A **histogram** bins the measure itself:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from scistackplot import BinMode, HistogramOptions, HistStat
|
|
187
|
+
|
|
188
|
+
spec = PlotSpec(
|
|
189
|
+
measures=["StepLength"],
|
|
190
|
+
roles={"session": Role.GROUP, "trial": Role.COLLAPSE}, # it counts the sample
|
|
191
|
+
groups=["session"], # one layer, its colour
|
|
192
|
+
kind=PlotKind.HISTOGRAM,
|
|
193
|
+
histogram=HistogramOptions(bins=BinMode.WIDTH, bin_width=0.05, stat=HistStat.PERCENT),
|
|
194
|
+
)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
One bin mode is active at a time: `auto` (numpy's estimator), `count`, or
|
|
198
|
+
`width` (bins start at a multiple of the width). A datetime measure bins
|
|
199
|
+
along a date axis with a duration width (`"1h"`, `"7D"`). The edges are
|
|
200
|
+
decided once per figure, so every panel compares bin for bin. Percent and
|
|
201
|
+
density are per colour level, and colour levels overlay, stack or dodge.
|
|
202
|
+
|
|
203
|
+
A **regression** plots `x_measure` against the measure and draws a
|
|
204
|
+
least-squares line per colour level with `R² = …, p = …, n = …` on the
|
|
205
|
+
panel (`RegressionOptions`: the line, the text, an optional 95% band, one
|
|
206
|
+
line per colour or per panel, the corner). The fit is
|
|
207
|
+
`csvstats.regression.linear_fit`, and the exported code calls it too. A
|
|
208
|
+
datetime `x_measure` is drawn on a date axis. Log axes are ignored on both
|
|
209
|
+
kinds. "Save data" adds the bars or the fits as a table. See
|
|
210
|
+
`docs/claude/histogram-and-regression.md`.
|
|
211
|
+
|
|
162
212
|
## Save the data behind a figure
|
|
163
213
|
|
|
164
214
|
The rows a plot is drawn from are available as a long table, so the
|
|
@@ -119,6 +119,55 @@ longer groups) is inert: the raw values are drawn and the reason is
|
|
|
119
119
|
reported. The exported code and "Save data" compare the same way, and the saved value column is named after what it holds (`M, % change from session 1`). See
|
|
120
120
|
`docs/claude/compare-to-reference.md`.
|
|
121
121
|
|
|
122
|
+
## Datetimes
|
|
123
|
+
|
|
124
|
+
A datetime column is plotted by the same rules as a number. As the measure,
|
|
125
|
+
it sits on a time axis; as a factor, its levels read `2026-07-01 14:00 CEST`
|
|
126
|
+
and are ordered by time. `PlotSpec.time_zone` decides how values recorded
|
|
127
|
+
in different zones share the figure:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
spec = PlotSpec(measures=["SessionStart"], ..., time_zone="local") # each value's own clock (default)
|
|
131
|
+
spec = PlotSpec(measures=["SessionStart"], ..., time_zone="UTC") # every aware value converted
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A datetime measure offers no bar, since a bar would start at 1970. A log axis
|
|
135
|
+
is ignored with a note. A difference comparison is drawn in hours, and a %
|
|
136
|
+
change does not apply. Save data writes ISO times and names the zone in the
|
|
137
|
+
header, and the exported code draws the same date ticks. See
|
|
138
|
+
`docs/claude/plot-datetimes.md`.
|
|
139
|
+
|
|
140
|
+
## Histograms and regressions
|
|
141
|
+
|
|
142
|
+
Two kinds put a number line on x. A **histogram** bins the measure itself:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from scistackplot import BinMode, HistogramOptions, HistStat
|
|
146
|
+
|
|
147
|
+
spec = PlotSpec(
|
|
148
|
+
measures=["StepLength"],
|
|
149
|
+
roles={"session": Role.GROUP, "trial": Role.COLLAPSE}, # it counts the sample
|
|
150
|
+
groups=["session"], # one layer, its colour
|
|
151
|
+
kind=PlotKind.HISTOGRAM,
|
|
152
|
+
histogram=HistogramOptions(bins=BinMode.WIDTH, bin_width=0.05, stat=HistStat.PERCENT),
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
One bin mode is active at a time: `auto` (numpy's estimator), `count`, or
|
|
157
|
+
`width` (bins start at a multiple of the width). A datetime measure bins
|
|
158
|
+
along a date axis with a duration width (`"1h"`, `"7D"`). The edges are
|
|
159
|
+
decided once per figure, so every panel compares bin for bin. Percent and
|
|
160
|
+
density are per colour level, and colour levels overlay, stack or dodge.
|
|
161
|
+
|
|
162
|
+
A **regression** plots `x_measure` against the measure and draws a
|
|
163
|
+
least-squares line per colour level with `R² = …, p = …, n = …` on the
|
|
164
|
+
panel (`RegressionOptions`: the line, the text, an optional 95% band, one
|
|
165
|
+
line per colour or per panel, the corner). The fit is
|
|
166
|
+
`csvstats.regression.linear_fit`, and the exported code calls it too. A
|
|
167
|
+
datetime `x_measure` is drawn on a date axis. Log axes are ignored on both
|
|
168
|
+
kinds. "Save data" adds the bars or the fits as a table. See
|
|
169
|
+
`docs/claude/histogram-and-regression.md`.
|
|
170
|
+
|
|
122
171
|
## Save the data behind a figure
|
|
123
172
|
|
|
124
173
|
The rows a plot is drawn from are available as a long table, so the
|
|
@@ -12,7 +12,7 @@ raw-options = { search_parent_directories = true, local_scheme = "no-local-versi
|
|
|
12
12
|
# dependencies must be named here: CI's build job fails a wheel whose internal
|
|
13
13
|
# dependency is not "==<its own version>".
|
|
14
14
|
[tool.hatch.metadata.hooks.dependency-coversion]
|
|
15
|
-
override-versions-of = ["scistacklog"]
|
|
15
|
+
override-versions-of = ["scistacklog", "scicanonicalhash"]
|
|
16
16
|
|
|
17
17
|
[project]
|
|
18
18
|
name = "scistackplot"
|
|
@@ -51,8 +51,12 @@ dependencies = [
|
|
|
51
51
|
# Core only: numpy, pandas, scipy. Not a scistack sibling, so not coversioned.
|
|
52
52
|
# 0.4.2: scipy imported lazily, keeping it out of `import scistackplot`
|
|
53
53
|
# (2 s of every first Plot Studio open; tests/test_import_cost.py).
|
|
54
|
-
|
|
54
|
+
# 0.5.0: csvstats.regression, the regression kind's fit (fits.py).
|
|
55
|
+
"csv-stats>=0.5.0",
|
|
55
56
|
"scistacklog",
|
|
57
|
+
# Datetime zone labels (temporal.py): the same vocabulary the storage and
|
|
58
|
+
# the record hash use. Zero dependencies of its own.
|
|
59
|
+
"scicanonicalhash",
|
|
56
60
|
]
|
|
57
61
|
|
|
58
62
|
[project.optional-dependencies]
|
|
@@ -115,6 +115,14 @@ from .shape import Shape, classify_column, classify_value, is_plottable
|
|
|
115
115
|
from .sources import BaseSource, CsvSource, DataFrameSource, DataSource, UnknownMeasureError
|
|
116
116
|
from .spec import (
|
|
117
117
|
Aggregation,
|
|
118
|
+
BinMode,
|
|
119
|
+
CONTINUOUS_X_KINDS,
|
|
120
|
+
FitBy,
|
|
121
|
+
HistMultiple,
|
|
122
|
+
HistStat,
|
|
123
|
+
HistogramOptions,
|
|
124
|
+
RegressionOptions,
|
|
125
|
+
StatsCorner,
|
|
118
126
|
LegacySpecError,
|
|
119
127
|
ErrorBand,
|
|
120
128
|
FacetOptions,
|
|
@@ -141,6 +149,8 @@ from .spec import (
|
|
|
141
149
|
YAxis,
|
|
142
150
|
grid_shape_for,
|
|
143
151
|
)
|
|
152
|
+
from .bins import BinEdges, BinError, bin_edges
|
|
153
|
+
from .fits import PanelFit, annotation_text
|
|
144
154
|
from .textsize import (
|
|
145
155
|
BANDS,
|
|
146
156
|
DEFAULT_TEXT_RATIOS,
|
|
@@ -228,6 +238,19 @@ __all__ = [
|
|
|
228
238
|
"FieldClass",
|
|
229
239
|
"FacetOptions",
|
|
230
240
|
"YAxis",
|
|
241
|
+
"BinMode",
|
|
242
|
+
"CONTINUOUS_X_KINDS",
|
|
243
|
+
"FitBy",
|
|
244
|
+
"HistMultiple",
|
|
245
|
+
"HistStat",
|
|
246
|
+
"HistogramOptions",
|
|
247
|
+
"RegressionOptions",
|
|
248
|
+
"StatsCorner",
|
|
249
|
+
"BinEdges",
|
|
250
|
+
"BinError",
|
|
251
|
+
"bin_edges",
|
|
252
|
+
"PanelFit",
|
|
253
|
+
"annotation_text",
|
|
231
254
|
# per-panel overrides (docs/claude/per-panel-overrides.md)
|
|
232
255
|
"PanelOverride",
|
|
233
256
|
"override_for",
|
|
@@ -38,12 +38,17 @@ from scistacklog import Log
|
|
|
38
38
|
from .roles import RoleError
|
|
39
39
|
from .spec import Alias, PlotSpec
|
|
40
40
|
from .table import LongTable
|
|
41
|
+
from .temporal import display_levels
|
|
41
42
|
|
|
42
43
|
LAYER = "scistackplot"
|
|
43
44
|
|
|
44
45
|
#: Where an alias came from — in logs and in the clash message.
|
|
45
46
|
PLOT = "plot"
|
|
46
47
|
PROJECT = "project"
|
|
48
|
+
#: A datetime level read in the plot's "Convert to <zone>" time zone mode
|
|
49
|
+
#: (temporal.display_levels): the lowest layer, under the project's and the
|
|
50
|
+
#: plot's own aliases.
|
|
51
|
+
TIME_ZONE = "time_zone"
|
|
47
52
|
RAW = "raw"
|
|
48
53
|
|
|
49
54
|
#: How dash ids are composed (``reduce.SERIES_SEPARATOR``). Restated rather
|
|
@@ -234,6 +239,17 @@ def display_text(spec: PlotSpec, table: LongTable) -> DisplayText:
|
|
|
234
239
|
the project config reaches the next resolve with nothing rebuilt.
|
|
235
240
|
"""
|
|
236
241
|
names, levels = merge(table.project_aliases(), spec.aliases)
|
|
242
|
+
for info in table.factors:
|
|
243
|
+
if not info.temporal:
|
|
244
|
+
continue
|
|
245
|
+
try:
|
|
246
|
+
shown = display_levels(info.temporal_values, info.temporal_format, spec.time_zone)
|
|
247
|
+
except ValueError: # an unknown zone: `validate` says so; read as stored
|
|
248
|
+
shown = {}
|
|
249
|
+
if shown:
|
|
250
|
+
mine = levels.setdefault(info.name, {})
|
|
251
|
+
for level, text in shown.items():
|
|
252
|
+
mine.setdefault(level, (text, TIME_ZONE))
|
|
237
253
|
return DisplayText(names=names, levels=levels, keys=lookup_keys(spec))
|
|
238
254
|
|
|
239
255
|
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Histogram bins — the one owner (docs/claude/histogram-and-regression.md).
|
|
2
|
+
|
|
3
|
+
Three things a histogram decides, each once:
|
|
4
|
+
|
|
5
|
+
- **The edges** (:func:`bin_edges`). ONE source, :data:`EDGES_SOURCE`: this
|
|
6
|
+
module executes it and ``codegen`` emits it verbatim, so the exported
|
|
7
|
+
figure bins exactly where the preview does (the ``MPL_DATE_AXIS_SOURCE``
|
|
8
|
+
bargain). Pure numpy; no scistackplot import.
|
|
9
|
+
|
|
10
|
+
* ``auto``: numpy's ``"auto"`` estimator (seaborn's default too).
|
|
11
|
+
* ``count``: that many equal bins over the range.
|
|
12
|
+
* ``width``: bins that wide, the first edge at the multiple of the width at
|
|
13
|
+
or below the range's start — so a 1-day width starts at midnight wall
|
|
14
|
+
clock, and a width of 5 starts at a multiple of 5. A datetime width is a
|
|
15
|
+
fixed duration (``"1h"``, ``"7D"``, ``"30min"``; no months, which have no
|
|
16
|
+
fixed length — user, 2026-10-22). 7-day bins therefore start on
|
|
17
|
+
Thursdays (1970-01-01 was one).
|
|
18
|
+
|
|
19
|
+
The range is the values' own extent unless ``range_min`` / ``range_max``
|
|
20
|
+
pin an end. Edges are decided once per FIGURE, over every panel's values,
|
|
21
|
+
so the panels of a figure compare bin for bin. A figure of a fan-out
|
|
22
|
+
decides its own, as the exported endpoint (which sees one figure's rows)
|
|
23
|
+
must.
|
|
24
|
+
|
|
25
|
+
- **The heights** (:func:`bin_table`): count, percent or density, normalised
|
|
26
|
+
per colour level within a panel (seaborn ``common_norm=False``; user,
|
|
27
|
+
2026-10-22) so two groups of different sizes compare by shape.
|
|
28
|
+
|
|
29
|
+
- **How colour levels share a panel**: layered (overlaid), stacked (in
|
|
30
|
+
REVERSED colour order, the last level at the bottom, as seaborn stacks)
|
|
31
|
+
or dodged (each bin split into one slot per colour level, in colour order).
|
|
32
|
+
|
|
33
|
+
Values are axis numbers: a datetime measure arrives as wall-clock ms
|
|
34
|
+
(``temporal.apply_temporal``), so a datetime histogram is a numeric one with
|
|
35
|
+
a date axis and a width in ms.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import math
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
import numpy as np
|
|
45
|
+
import pandas as pd
|
|
46
|
+
from scistacklog import Log
|
|
47
|
+
|
|
48
|
+
from .spec import DEFAULT_BIN_COUNT, BinMode, HistMultiple, HistogramOptions, HistStat
|
|
49
|
+
|
|
50
|
+
LAYER = "scistackplot"
|
|
51
|
+
|
|
52
|
+
#: The most bins a figure may have. A width too small for the range would
|
|
53
|
+
#: otherwise ask for millions of bars and stall the panel.
|
|
54
|
+
MAX_BINS = 1000
|
|
55
|
+
|
|
56
|
+
#: The fill opacity of a histogram bar (``render.base.fill_alpha``): layered
|
|
57
|
+
#: histograms overlap, so they must show through each other.
|
|
58
|
+
FILL_ALPHA = 0.6
|
|
59
|
+
#: The bar outline: stated, because seaborn's and plotly's defaults differ.
|
|
60
|
+
EDGE_COLOR = "#ffffff"
|
|
61
|
+
EDGE_WIDTH = 0.5
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class BinError(ValueError):
|
|
65
|
+
"""Bin settings that cannot be applied as written; the message names the fix."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# The edges. ONE source: bin_edges executes it, codegen emits it verbatim.
|
|
69
|
+
EDGES_SOURCE = '''
|
|
70
|
+
def _scistack_bin_edges(values, mode, count, width, lo, hi, max_bins):
|
|
71
|
+
"""Histogram bin edges: the scistackplot.bins rule."""
|
|
72
|
+
import math
|
|
73
|
+
|
|
74
|
+
import numpy as np
|
|
75
|
+
|
|
76
|
+
values = np.asarray(values, dtype=float)
|
|
77
|
+
values = values[np.isfinite(values)]
|
|
78
|
+
if lo is None:
|
|
79
|
+
lo = float(values.min()) if values.size else None
|
|
80
|
+
if hi is None:
|
|
81
|
+
hi = float(values.max()) if values.size else None
|
|
82
|
+
if lo is None or hi is None:
|
|
83
|
+
return np.array([], dtype=float)
|
|
84
|
+
if hi < lo:
|
|
85
|
+
lo, hi = hi, lo
|
|
86
|
+
if mode == "width":
|
|
87
|
+
start = math.floor(lo / width) * width
|
|
88
|
+
n = max(1, int(math.ceil((hi - start) / width - 1e-9)))
|
|
89
|
+
if n > max_bins:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"a bin width of {width:g} makes {n} bins; at most {max_bins} fit"
|
|
92
|
+
)
|
|
93
|
+
edges = start + width * np.arange(n + 1, dtype=float)
|
|
94
|
+
edges[-1] = max(edges[-1], hi)
|
|
95
|
+
return edges
|
|
96
|
+
inside = values[(values >= lo) & (values <= hi)]
|
|
97
|
+
bins = int(count) if mode == "count" else "auto"
|
|
98
|
+
edges = np.histogram_bin_edges(inside, bins=bins, range=(lo, hi))
|
|
99
|
+
if len(edges) - 1 > max_bins:
|
|
100
|
+
edges = np.histogram_bin_edges(inside, bins=max_bins, range=(lo, hi))
|
|
101
|
+
return edges
|
|
102
|
+
'''
|
|
103
|
+
|
|
104
|
+
_NAMESPACE: dict = {}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _edges_function():
|
|
108
|
+
if "_scistack_bin_edges" not in _NAMESPACE:
|
|
109
|
+
exec(EDGES_SOURCE, _NAMESPACE) # noqa: S102 - our own constant
|
|
110
|
+
return _NAMESPACE["_scistack_bin_edges"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class BinEdges:
|
|
115
|
+
"""One figure's bins."""
|
|
116
|
+
|
|
117
|
+
edges: tuple[float, ...]
|
|
118
|
+
mode: BinMode
|
|
119
|
+
#: The width when every bin has it (count / auto / width), else None.
|
|
120
|
+
width: float | None
|
|
121
|
+
#: How many finite values decided them.
|
|
122
|
+
n_values: int = 0
|
|
123
|
+
notes: tuple[str, ...] = ()
|
|
124
|
+
#: The arguments the edges came from, as the generated code passes them.
|
|
125
|
+
call: tuple = ()
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def n_bins(self) -> int:
|
|
129
|
+
return max(len(self.edges) - 1, 0)
|
|
130
|
+
|
|
131
|
+
def describe(self) -> str:
|
|
132
|
+
if not self.edges:
|
|
133
|
+
return f"{self.mode}: no values to bin"
|
|
134
|
+
width = f", width {self.width:g}" if self.width is not None else ""
|
|
135
|
+
return (
|
|
136
|
+
f"{self.mode}: {self.n_bins} bin(s) over [{self.edges[0]:g}, "
|
|
137
|
+
f"{self.edges[-1]:g}]{width}, from {self.n_values} value(s)"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def to_dict(self) -> dict:
|
|
141
|
+
return {
|
|
142
|
+
"mode": str(self.mode),
|
|
143
|
+
"edges": list(self.edges),
|
|
144
|
+
"width": self.width,
|
|
145
|
+
"n_bins": self.n_bins,
|
|
146
|
+
"n_values": self.n_values,
|
|
147
|
+
"notes": list(self.notes),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_width(value: Any, *, temporal: bool) -> float | None:
|
|
152
|
+
"""A bin width as an axis number (ms on a datetime axis), or None.
|
|
153
|
+
|
|
154
|
+
Raises :class:`BinError` naming the fix for a width that is not one.
|
|
155
|
+
"""
|
|
156
|
+
if value is None or value == "":
|
|
157
|
+
return None
|
|
158
|
+
if temporal:
|
|
159
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
160
|
+
raise BinError(
|
|
161
|
+
f"bin width {value!r}: on a datetime axis type a duration such as "
|
|
162
|
+
f"'30min', '1h', '1D' or '7D'."
|
|
163
|
+
)
|
|
164
|
+
text = str(value).strip()
|
|
165
|
+
try:
|
|
166
|
+
delta = pd.Timedelta(text)
|
|
167
|
+
except (TypeError, ValueError) as exc:
|
|
168
|
+
raise BinError(
|
|
169
|
+
f"bin width {text!r} is not a duration. Use a fixed length such as "
|
|
170
|
+
f"'30min', '1h', '1D' or '7D' (months and years have no fixed length)."
|
|
171
|
+
) from exc
|
|
172
|
+
width = delta.total_seconds() * 1000.0
|
|
173
|
+
else:
|
|
174
|
+
try:
|
|
175
|
+
width = float(value)
|
|
176
|
+
except (TypeError, ValueError) as exc:
|
|
177
|
+
raise BinError(f"bin width {value!r} is not a number.") from exc
|
|
178
|
+
if not width > 0 or not math.isfinite(width):
|
|
179
|
+
raise BinError(f"bin width {value!r} must be greater than zero.")
|
|
180
|
+
return width
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def parse_bound(value: Any, *, temporal: bool) -> float | None:
|
|
184
|
+
"""A range end as an axis number: ISO date text on a datetime axis (a
|
|
185
|
+
wall clock in the axis's zone, ``temporal.parse_limit``), a number
|
|
186
|
+
otherwise. Raises :class:`BinError` for text that is neither."""
|
|
187
|
+
if value is None or value == "":
|
|
188
|
+
return None
|
|
189
|
+
if temporal:
|
|
190
|
+
from .temporal import parse_limit
|
|
191
|
+
|
|
192
|
+
parsed = parse_limit(value)
|
|
193
|
+
if parsed is None:
|
|
194
|
+
raise BinError(f"range end {value!r} is not a date (try '2026-07-01 12:00').")
|
|
195
|
+
return parsed
|
|
196
|
+
try:
|
|
197
|
+
return float(value)
|
|
198
|
+
except (TypeError, ValueError) as exc:
|
|
199
|
+
raise BinError(f"range end {value!r} is not a number.") from exc
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def histogram_problem(options: HistogramOptions, *, temporal: bool) -> str | None:
|
|
203
|
+
"""Why these settings cannot bin, or None. ``roles.validate`` refuses the
|
|
204
|
+
spec with it and the capability report shows it beside the controls."""
|
|
205
|
+
try:
|
|
206
|
+
_arguments(options, temporal=temporal)
|
|
207
|
+
except BinError as exc:
|
|
208
|
+
return str(exc)
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def edge_arguments(options: HistogramOptions, *, temporal: bool) -> tuple:
|
|
213
|
+
"""``(mode, count, width, lo, hi, max_bins)`` exactly as
|
|
214
|
+
``_scistack_bin_edges`` takes them — what the generated code passes,
|
|
215
|
+
so it bins over its own figure's values by the preview's settings."""
|
|
216
|
+
mode, count, width, lo, hi, _notes = _arguments(options, temporal=temporal)
|
|
217
|
+
return (str(mode), count, width, lo, hi, MAX_BINS)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _arguments(options: HistogramOptions, *, temporal: bool) -> tuple:
|
|
221
|
+
"""``(mode, count, width, lo, hi, notes)``: the options as the edge rule
|
|
222
|
+
takes them. A width mode with no width falls back to auto, with a note
|
|
223
|
+
(a preset carries the mode but never the variable's own width)."""
|
|
224
|
+
notes: list[str] = []
|
|
225
|
+
mode = options.bins
|
|
226
|
+
count = options.bin_count if options.bin_count is not None else DEFAULT_BIN_COUNT
|
|
227
|
+
if mode is BinMode.COUNT and not 1 <= int(count) <= MAX_BINS:
|
|
228
|
+
raise BinError(f"bin count {count} must be between 1 and {MAX_BINS}.")
|
|
229
|
+
width = parse_width(options.bin_width, temporal=temporal) if mode is BinMode.WIDTH else None
|
|
230
|
+
if mode is BinMode.WIDTH and width is None:
|
|
231
|
+
notes.append("No bin width is set, so the bins are chosen automatically.")
|
|
232
|
+
mode = BinMode.AUTO
|
|
233
|
+
lo = parse_bound(options.range_min, temporal=temporal)
|
|
234
|
+
hi = parse_bound(options.range_max, temporal=temporal)
|
|
235
|
+
if lo is not None and hi is not None and hi < lo:
|
|
236
|
+
raise BinError(f"range {options.range_min!r} .. {options.range_max!r} ends before it starts.")
|
|
237
|
+
return mode, int(count), width, lo, hi, notes
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def bin_edges(values: Any, options: HistogramOptions, *, temporal: bool = False) -> BinEdges:
|
|
241
|
+
"""The figure's bin edges over ``values`` (every panel's, axis numbers)."""
|
|
242
|
+
mode, count, width, lo, hi, notes = _arguments(options, temporal=temporal)
|
|
243
|
+
array = np.asarray(values, dtype=float)
|
|
244
|
+
finite = int(np.isfinite(array).sum())
|
|
245
|
+
try:
|
|
246
|
+
edges = _edges_function()(array, str(mode), count, width, lo, hi, MAX_BINS)
|
|
247
|
+
except ValueError as exc:
|
|
248
|
+
raise BinError(f"{exc}. Use a wider bin, or fewer bins.") from exc
|
|
249
|
+
if mode is BinMode.AUTO and len(edges) - 1 == MAX_BINS:
|
|
250
|
+
notes.append(f"Automatic bins capped at {MAX_BINS}.")
|
|
251
|
+
widths = np.diff(edges)
|
|
252
|
+
same = widths.size and np.allclose(widths, widths[0], rtol=1e-9, atol=0.0)
|
|
253
|
+
result = BinEdges(
|
|
254
|
+
edges=tuple(float(e) for e in edges),
|
|
255
|
+
mode=mode,
|
|
256
|
+
width=float(widths[0]) if same else None,
|
|
257
|
+
n_values=finite,
|
|
258
|
+
notes=tuple(notes),
|
|
259
|
+
call=(str(mode), count, width, lo, hi, MAX_BINS),
|
|
260
|
+
)
|
|
261
|
+
Log.info("histogram bins: %s", result.describe(), layer=LAYER)
|
|
262
|
+
for note in notes:
|
|
263
|
+
Log.info("histogram bins: %s", note, layer=LAYER)
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _heights(counts: np.ndarray, edges: np.ndarray, stat: HistStat) -> np.ndarray:
|
|
268
|
+
"""Bar heights from counts, normalised within ONE colour level's counts."""
|
|
269
|
+
counts = counts.astype(float)
|
|
270
|
+
total = counts.sum()
|
|
271
|
+
if stat is HistStat.COUNT:
|
|
272
|
+
return counts
|
|
273
|
+
if total <= 0:
|
|
274
|
+
return np.zeros_like(counts)
|
|
275
|
+
if stat is HistStat.PERCENT:
|
|
276
|
+
return counts / total * 100.0
|
|
277
|
+
return counts / (total * np.diff(edges))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def y_title(stat: HistStat) -> str:
|
|
281
|
+
"""The y axis title a histogram draws unless the user typed one."""
|
|
282
|
+
return {HistStat.COUNT: "Count", HistStat.PERCENT: "Percent", HistStat.DENSITY: "Density"}[stat]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def bin_table(
|
|
286
|
+
frame: pd.DataFrame,
|
|
287
|
+
edges: BinEdges,
|
|
288
|
+
options: HistogramOptions,
|
|
289
|
+
*,
|
|
290
|
+
value: str,
|
|
291
|
+
color: str | None,
|
|
292
|
+
color_order: list[Any],
|
|
293
|
+
) -> pd.DataFrame:
|
|
294
|
+
"""One panel's bars: a row per (colour level, bin).
|
|
295
|
+
|
|
296
|
+
``frame`` is the panel frame the scatter kinds would draw (``value`` the
|
|
297
|
+
column holding the binned numbers, ``color`` the colour column or None).
|
|
298
|
+
Columns: ``X`` (bar centre), ``X_LOW`` / ``X_HIGH`` (bar edges after any
|
|
299
|
+
dodge), ``Y_BASE`` (0 unless stacked), ``Y`` (bar top), ``COUNT`` and the
|
|
300
|
+
colour column. Empty bins are kept, so a stacked level sits on the right
|
|
301
|
+
base everywhere.
|
|
302
|
+
"""
|
|
303
|
+
from .resolved import COUNT, X, X_HIGH, X_LOW, Y, Y_BASE
|
|
304
|
+
|
|
305
|
+
columns = [X, X_LOW, X_HIGH, Y_BASE, Y, COUNT, *([color] if color else [])]
|
|
306
|
+
if not edges.edges:
|
|
307
|
+
return pd.DataFrame(columns=columns)
|
|
308
|
+
edge_array = np.asarray(edges.edges, dtype=float)
|
|
309
|
+
lefts, rights = edge_array[:-1], edge_array[1:]
|
|
310
|
+
|
|
311
|
+
if color and color in frame.columns:
|
|
312
|
+
present = {str(v) for v in frame[color].dropna().unique()}
|
|
313
|
+
levels = [lvl for lvl in color_order if str(lvl) in present]
|
|
314
|
+
levels += [lvl for lvl in frame[color].dropna().unique() if str(lvl) not in {str(x) for x in levels}]
|
|
315
|
+
groups = [(lvl, frame[frame[color].astype(str) == str(lvl)]) for lvl in levels]
|
|
316
|
+
slots = [str(lvl) for lvl in color_order] or [str(lvl) for lvl in levels]
|
|
317
|
+
else:
|
|
318
|
+
groups = [(None, frame)]
|
|
319
|
+
slots = []
|
|
320
|
+
|
|
321
|
+
heights: dict[str, np.ndarray] = {}
|
|
322
|
+
counts: dict[str, np.ndarray] = {}
|
|
323
|
+
for level, rows in groups:
|
|
324
|
+
values = pd.to_numeric(rows[value], errors="coerce").to_numpy(dtype=float)
|
|
325
|
+
values = values[np.isfinite(values)]
|
|
326
|
+
found, _ = np.histogram(values, bins=edge_array)
|
|
327
|
+
counts[str(level)] = found
|
|
328
|
+
heights[str(level)] = _heights(found, edge_array, options.stat)
|
|
329
|
+
|
|
330
|
+
bases = {key: np.zeros(len(lefts)) for key in heights}
|
|
331
|
+
if options.multiple is HistMultiple.STACK and len(groups) > 1:
|
|
332
|
+
running = np.zeros(len(lefts))
|
|
333
|
+
for level, _ in reversed(groups):
|
|
334
|
+
bases[str(level)] = running.copy()
|
|
335
|
+
running = running + heights[str(level)]
|
|
336
|
+
|
|
337
|
+
parts = []
|
|
338
|
+
for level, _rows in groups:
|
|
339
|
+
key = str(level)
|
|
340
|
+
lo, hi = lefts, rights
|
|
341
|
+
if options.multiple is HistMultiple.DODGE and level is not None and len(slots) > 1:
|
|
342
|
+
index = slots.index(key) if key in slots else len(slots) - 1
|
|
343
|
+
slot = (rights - lefts) / len(slots)
|
|
344
|
+
lo, hi = lefts + index * slot, lefts + (index + 1) * slot
|
|
345
|
+
part = pd.DataFrame(
|
|
346
|
+
{
|
|
347
|
+
X: (lo + hi) / 2.0,
|
|
348
|
+
X_LOW: lo,
|
|
349
|
+
X_HIGH: hi,
|
|
350
|
+
Y_BASE: bases[key],
|
|
351
|
+
Y: bases[key] + heights[key],
|
|
352
|
+
COUNT: counts[key],
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
if color:
|
|
356
|
+
part[color] = level
|
|
357
|
+
parts.append(part)
|
|
358
|
+
return pd.concat(parts, ignore_index=True)[columns]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def histogram_limits(tops: list[float]) -> tuple[float, float]:
|
|
362
|
+
"""``(0, top)`` for bars whose tallest top is ``max(tops)``: matplotlib's
|
|
363
|
+
own autoscale for bars (a sticky zero, a 5% margin above), so the export
|
|
364
|
+
and the generated code, which autoscale, draw the preview's range."""
|
|
365
|
+
finite = [t for t in tops if t == t]
|
|
366
|
+
top = max(finite) if finite else 0.0
|
|
367
|
+
return (0.0, top * 1.05 if top > 0 else 1.0)
|