scistackplot 0.1.54__tar.gz → 0.1.57__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {scistackplot-0.1.54 → scistackplot-0.1.57}/PKG-INFO +71 -4
  2. {scistackplot-0.1.54 → scistackplot-0.1.57}/README.md +67 -1
  3. {scistackplot-0.1.54 → scistackplot-0.1.57}/pyproject.toml +6 -2
  4. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/__init__.py +29 -0
  5. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/aliases.py +16 -0
  6. scistackplot-0.1.57/src/scistackplot/bins.py +367 -0
  7. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/capability.py +111 -17
  8. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/codegen.py +427 -52
  9. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/compare.py +36 -2
  10. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/design.py +7 -0
  11. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/diffbars.py +21 -2
  12. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/export.py +138 -5
  13. scistackplot-0.1.57/src/scistackplot/fits.py +218 -0
  14. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/presets.py +13 -1
  15. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/reduce.py +242 -6
  16. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/render/base.py +49 -3
  17. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/render/mpl.py +429 -26
  18. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/render/plotly_.py +405 -17
  19. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/resolved.py +30 -0
  20. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/roles.py +119 -6
  21. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/shape.py +6 -0
  22. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/spec.py +227 -4
  23. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/table.py +77 -12
  24. scistackplot-0.1.57/src/scistackplot/temporal.py +641 -0
  25. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/ticklabels.py +13 -0
  26. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/ylimits.py +7 -7
  27. {scistackplot-0.1.54 → scistackplot-0.1.57}/.gitignore +0 -0
  28. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/autosize.py +0 -0
  29. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/cell.py +0 -0
  30. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/colors.py +0 -0
  31. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/custom.py +0 -0
  32. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/dedup.py +0 -0
  33. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/figsize.py +0 -0
  34. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/figure_file.py +0 -0
  35. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/framesize.py +0 -0
  36. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/groups.py +0 -0
  37. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/numeric.py +0 -0
  38. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/panels.py +0 -0
  39. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/paper.py +0 -0
  40. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/reducer.py +0 -0
  41. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/render/__init__.py +0 -0
  42. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/restore.py +0 -0
  43. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/series_stats.py +0 -0
  44. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/sources/__init__.py +0 -0
  45. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/sources/base.py +0 -0
  46. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/sources/csv.py +0 -0
  47. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/sources/frame.py +0 -0
  48. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/spaghetti.py +0 -0
  49. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/spread.py +0 -0
  50. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/textsize.py +0 -0
  51. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/variants.py +0 -0
  52. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/weights.py +0 -0
  53. {scistackplot-0.1.54 → scistackplot-0.1.57}/src/scistackplot/xaxis.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scistackplot
3
- Version: 0.1.54
3
+ Version: 0.1.57
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.4.2
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: scistacklog==0.1.54
23
+ Requires-Dist: scicanonicalhash==0.1.57
24
+ Requires-Dist: scistacklog==0.1.57
24
25
  Provides-Extra: dev
25
26
  Requires-Dist: matplotlib>=3.6; extra == 'dev'
26
27
  Requires-Dist: plotly>=5.0; extra == 'dev'
@@ -100,7 +101,7 @@ shape, through one pure function:
100
101
  ```python
101
102
  from scistackplot import available_plots, default_plot, Shape
102
103
 
103
- available_plots(Shape.SCALAR, {"session": Role.GROUP}) # scatter, strip, bar
104
+ available_plots(Shape.SCALAR, {"session": Role.GROUP}) # scatter, strip, bar, hbar
104
105
  available_plots(Shape.SCALAR, {"session": Role.GROUP, "trial": Role.COLLAPSE}) # + box, violin
105
106
  ```
106
107
 
@@ -159,6 +160,72 @@ 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
+
212
+ ## Horizontal bars
213
+
214
+ `PlotKind.HBAR` is the bar plot turned: the same bars, error bars, colours,
215
+ grouping, Show sample, comparison and limits, with the categories down the
216
+ left (first level at the top) and the values along the bottom.
217
+
218
+ ```python
219
+ spec = PlotSpec(measures=["StepLength"], roles=..., groups=["limb", "session"],
220
+ kind=PlotKind.HBAR)
221
+ ```
222
+
223
+ Nothing upstream of drawing changes — "x" is still the category axis and
224
+ "y" the value axis, so `y_axis`, `style.y_label` and `style.log_y` apply to
225
+ the bottom axis. Outer grouping layers become brackets at the left, and a
226
+ faceted panel's name is the title under it. Difference bars are not drawn on
227
+ it. See `docs/claude/horizontal-bar.md`.
228
+
162
229
  ## Save the data behind a figure
163
230
 
164
231
  The rows a plot is drawn from are available as a long table, so the
@@ -60,7 +60,7 @@ shape, through one pure function:
60
60
  ```python
61
61
  from scistackplot import available_plots, default_plot, Shape
62
62
 
63
- available_plots(Shape.SCALAR, {"session": Role.GROUP}) # scatter, strip, bar
63
+ available_plots(Shape.SCALAR, {"session": Role.GROUP}) # scatter, strip, bar, hbar
64
64
  available_plots(Shape.SCALAR, {"session": Role.GROUP, "trial": Role.COLLAPSE}) # + box, violin
65
65
  ```
66
66
 
@@ -119,6 +119,72 @@ 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
+
171
+ ## Horizontal bars
172
+
173
+ `PlotKind.HBAR` is the bar plot turned: the same bars, error bars, colours,
174
+ grouping, Show sample, comparison and limits, with the categories down the
175
+ left (first level at the top) and the values along the bottom.
176
+
177
+ ```python
178
+ spec = PlotSpec(measures=["StepLength"], roles=..., groups=["limb", "session"],
179
+ kind=PlotKind.HBAR)
180
+ ```
181
+
182
+ Nothing upstream of drawing changes — "x" is still the category axis and
183
+ "y" the value axis, so `y_axis`, `style.y_label` and `style.log_y` apply to
184
+ the bottom axis. Outer grouping layers become brackets at the left, and a
185
+ faceted panel's name is the title under it. Difference bars are not drawn on
186
+ it. See `docs/claude/horizontal-bar.md`.
187
+
122
188
  ## Save the data behind a figure
123
189
 
124
190
  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
- "csv-stats>=0.4.2",
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,
@@ -129,6 +137,8 @@ from .spec import (
129
137
  PlotKind,
130
138
  PlotSpec,
131
139
  Role,
140
+ BAR_KINDS,
141
+ HORIZONTAL_KINDS,
132
142
  OVERLAY_KINDS,
133
143
  SCALAR_KINDS,
134
144
  Statistic,
@@ -140,7 +150,10 @@ from .spec import (
140
150
  VariantSet,
141
151
  YAxis,
142
152
  grid_shape_for,
153
+ is_horizontal,
143
154
  )
155
+ from .bins import BinEdges, BinError, bin_edges
156
+ from .fits import PanelFit, annotation_text
144
157
  from .textsize import (
145
158
  BANDS,
146
159
  DEFAULT_TEXT_RATIOS,
@@ -228,6 +241,19 @@ __all__ = [
228
241
  "FieldClass",
229
242
  "FacetOptions",
230
243
  "YAxis",
244
+ "BinMode",
245
+ "CONTINUOUS_X_KINDS",
246
+ "FitBy",
247
+ "HistMultiple",
248
+ "HistStat",
249
+ "HistogramOptions",
250
+ "RegressionOptions",
251
+ "StatsCorner",
252
+ "BinEdges",
253
+ "BinError",
254
+ "bin_edges",
255
+ "PanelFit",
256
+ "annotation_text",
231
257
  # per-panel overrides (docs/claude/per-panel-overrides.md)
232
258
  "PanelOverride",
233
259
  "override_for",
@@ -292,6 +318,9 @@ __all__ = [
292
318
  "VariantSet",
293
319
  "SCALAR_KINDS",
294
320
  "OVERLAY_KINDS",
321
+ "BAR_KINDS",
322
+ "HORIZONTAL_KINDS",
323
+ "is_horizontal",
295
324
  # 1-D -> scalar collapse
296
325
  "cell_collapses",
297
326
  "effective_shape",
@@ -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)