processbehavior 0.2.0__py3-none-any.whl
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.
- processbehavior/__init__.py +133 -0
- processbehavior/analysis.py +2944 -0
- processbehavior/analysis_dataset.py +361 -0
- processbehavior/analysis_result.py +1276 -0
- processbehavior/calibration.py +70 -0
- processbehavior/capability.py +720 -0
- processbehavior/data_preparation.py +792 -0
- processbehavior/datasets/__init__.py +37 -0
- processbehavior/datasets/data/coffee_shop_demo_long.csv +385 -0
- processbehavior/datasets/loaders.py +66 -0
- processbehavior/datasets/synthetic.py +1711 -0
- processbehavior/derivations.py +602 -0
- processbehavior/effects_calculator.py +603 -0
- processbehavior/exceptions.py +216 -0
- processbehavior/exporters/__init__.py +5 -0
- processbehavior/exporters/excel_exporter.py +708 -0
- processbehavior/focused_result.py +329 -0
- processbehavior/formulation_spec.py +144 -0
- processbehavior/loss_function.py +517 -0
- processbehavior/maximum_information.py +285 -0
- processbehavior/plotting/__init__.py +46 -0
- processbehavior/plotting/_math.py +121 -0
- processbehavior/plotting/capability_chart.py +858 -0
- processbehavior/plotting/contracts.py +176 -0
- processbehavior/plotting/control_chart.py +206 -0
- processbehavior/plotting/effects_charts.py +640 -0
- processbehavior/plotting/lane_boundaries.py +78 -0
- processbehavior/plotting/limits.py +157 -0
- processbehavior/plotting/maximum_information_chart.py +395 -0
- processbehavior/plotting/plotter.py +1469 -0
- processbehavior/plotting/renderers.py +660 -0
- processbehavior/plotting/report.py +211 -0
- processbehavior/plotting/residuals.py +282 -0
- processbehavior/plotting/run_rules_viz.py +150 -0
- processbehavior/plotting/stats_box.py +183 -0
- processbehavior/plotting/themes.py +544 -0
- processbehavior/plotting/x_axis_layout.py +474 -0
- processbehavior/plotting/zones.py +127 -0
- processbehavior/process_behavior.py +1290 -0
- processbehavior/py.typed +0 -0
- processbehavior/residual_calculator.py +572 -0
- processbehavior/result_signals.py +149 -0
- processbehavior/result_tabular.py +296 -0
- processbehavior/sds_detector.py +1432 -0
- processbehavior/signals/__init__.py +14 -0
- processbehavior/signals/config.py +435 -0
- processbehavior/signals/detector.py +328 -0
- processbehavior/signals/detectors.py +368 -0
- processbehavior/signals/result.py +232 -0
- processbehavior/spc_constants.py +692 -0
- processbehavior/study.py +2733 -0
- processbehavior/types.py +154 -0
- processbehavior-0.2.0.dist-info/METADATA +279 -0
- processbehavior-0.2.0.dist-info/RECORD +56 -0
- processbehavior-0.2.0.dist-info/WHEEL +4 -0
- processbehavior-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""X-axis tick and cell-band layout for control chart panels.
|
|
2
|
+
|
|
3
|
+
Owns one cohesive subsystem: given a chart's data and (optional) lane
|
|
4
|
+
boundaries, decide where tick labels go, whether a secondary cell-label
|
|
5
|
+
band is needed, where the axis title sits, and how much bottom margin
|
|
6
|
+
to reserve — all in one place, computed purely, applied to a Plotly
|
|
7
|
+
figure as one isolated side effect.
|
|
8
|
+
|
|
9
|
+
Replaces the prior `_apply_time_tick_labels` and its 5 sibling helpers
|
|
10
|
+
on `Plotter`.
|
|
11
|
+
|
|
12
|
+
Design notes
|
|
13
|
+
------------
|
|
14
|
+
- `lane_boundaries` metadata can arrive in two shapes (legacy):
|
|
15
|
+
`list[dict]` (flat — single chart's positions) or
|
|
16
|
+
`dict[stratum, list[dict]]` (stratified). Callers must unpack via
|
|
17
|
+
`parse_lane_boundaries(...)` before consumption. `FlatBoundaries`
|
|
18
|
+
and `StratifiedBoundaries` are the typed wrappers; the layout
|
|
19
|
+
function accepts only flat (or None).
|
|
20
|
+
- Title and band positions are chosen together so the title always sits
|
|
21
|
+
above the band, by construction. No more silent overlap.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import pandas as pd
|
|
30
|
+
import plotly.graph_objects as go
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Typed boundary objects
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class FlatBoundaries:
|
|
39
|
+
"""Boundary positions within one chart's data."""
|
|
40
|
+
|
|
41
|
+
positions: tuple[int, ...]
|
|
42
|
+
|
|
43
|
+
def block_edges(self, n: int) -> tuple[int, ...]:
|
|
44
|
+
"""Sorted edges `(0, b1, ..., n)` with positions clipped to `(0, n)`."""
|
|
45
|
+
valid = [p for p in self.positions if 0 < p < n]
|
|
46
|
+
return tuple(sorted({0, *valid, n}))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class StratifiedBoundaries:
|
|
51
|
+
"""Per-stratum boundaries. Caller must unpack via `for_stratum()`."""
|
|
52
|
+
|
|
53
|
+
per_stratum: dict[str, FlatBoundaries]
|
|
54
|
+
|
|
55
|
+
def for_stratum(self, stratum: str) -> FlatBoundaries:
|
|
56
|
+
return self.per_stratum.get(str(stratum), FlatBoundaries(()))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_lane_boundaries(raw: Any) -> FlatBoundaries | StratifiedBoundaries | None:
|
|
60
|
+
"""Normalize legacy raw metadata into a typed boundary object.
|
|
61
|
+
|
|
62
|
+
Accepts ``list[dict]`` (flat), ``dict[stratum, list[dict]]`` (stratified),
|
|
63
|
+
or ``None``. Returns the corresponding typed wrapper, or ``None`` for
|
|
64
|
+
missing/empty input.
|
|
65
|
+
|
|
66
|
+
Each boundary dict must have at minimum a ``position`` key (int row
|
|
67
|
+
index). Other keys (``label``, etc.) are ignored here — cell labels for
|
|
68
|
+
the band are computed from the chart's `rsg` column at consumption time.
|
|
69
|
+
"""
|
|
70
|
+
if not raw:
|
|
71
|
+
return None
|
|
72
|
+
if isinstance(raw, list):
|
|
73
|
+
return FlatBoundaries(tuple(int(b["position"]) for b in raw))
|
|
74
|
+
if isinstance(raw, dict):
|
|
75
|
+
per_stratum = {
|
|
76
|
+
str(k): FlatBoundaries(tuple(int(b["position"]) for b in v))
|
|
77
|
+
for k, v in raw.items()
|
|
78
|
+
}
|
|
79
|
+
return StratifiedBoundaries(per_stratum)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Layout data structures
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class TickRow:
|
|
90
|
+
"""Primary x-axis tick row.
|
|
91
|
+
|
|
92
|
+
`positions` and `labels` are parallel. When `positions is None`, the
|
|
93
|
+
caller leaves Plotly's default tick generation in place (e.g. small
|
|
94
|
+
categorical axes).
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
positions: tuple[int, ...] | None
|
|
98
|
+
labels: tuple[str, ...] | None
|
|
99
|
+
angle: int # 0 (horizontal) or -45 (rotated)
|
|
100
|
+
is_categorical: bool # if True, positions are category values not row indices
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class CellBand:
|
|
105
|
+
"""Secondary tier: one label per inter-boundary block.
|
|
106
|
+
|
|
107
|
+
`y_paper` is in paper coords for single charts, y-domain coords for
|
|
108
|
+
faceted panels — the apply step picks the right yref accordingly.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
midpoints: tuple[int, ...]
|
|
112
|
+
labels: tuple[str, ...]
|
|
113
|
+
stride: int # show every Nth label (crowd-thinning for >25 cells)
|
|
114
|
+
y_paper: float # paper-coord (single) or y-domain (faceted) y position
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class AxisTitle:
|
|
119
|
+
"""X-axis title text and explicit standoff.
|
|
120
|
+
|
|
121
|
+
`standoff is None` means defer to Plotly's default. An explicit value
|
|
122
|
+
pins the title close to the tick row so the cell band fits below.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
text: str
|
|
126
|
+
standoff: int | None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class XAxisLayout:
|
|
131
|
+
"""Complete x-axis description for one chart panel.
|
|
132
|
+
|
|
133
|
+
Invariants (enforced by `compute_x_axis_layout`):
|
|
134
|
+
|
|
135
|
+
- When `ticks.positions` is not None, every position is in `[0, n)`.
|
|
136
|
+
- `cell_band` is present only when blocks exist AND x is integer-position.
|
|
137
|
+
- When both `cell_band` and an explicit `title.standoff` are set, the
|
|
138
|
+
band's y position sits below the title's pixel baseline.
|
|
139
|
+
- When `cell_band` is present, `bottom_margin` covers title + band.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
ticks: TickRow
|
|
143
|
+
cell_band: CellBand | None
|
|
144
|
+
title: AxisTitle
|
|
145
|
+
bottom_margin: int # pixels
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def has_cell_band(self) -> bool:
|
|
149
|
+
return self.cell_band is not None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Internal helpers (pure)
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _per_block_positions(
|
|
158
|
+
block_edges: tuple[int, ...], per_block_ticks: int
|
|
159
|
+
) -> list[int]:
|
|
160
|
+
"""Pick `per_block_ticks` evenly-spaced positions within each block.
|
|
161
|
+
|
|
162
|
+
Each block contributes first, last, and `per_block_ticks - 2` interior
|
|
163
|
+
indices. Blocks smaller than `per_block_ticks` contribute every position.
|
|
164
|
+
"""
|
|
165
|
+
positions: list[int] = []
|
|
166
|
+
for i in range(len(block_edges) - 1):
|
|
167
|
+
start = block_edges[i]
|
|
168
|
+
end = block_edges[i + 1]
|
|
169
|
+
block_n = end - start
|
|
170
|
+
if block_n <= 0:
|
|
171
|
+
continue
|
|
172
|
+
if block_n <= per_block_ticks:
|
|
173
|
+
block_positions = list(range(start, end))
|
|
174
|
+
else:
|
|
175
|
+
step = (block_n - 1) / (per_block_ticks - 1)
|
|
176
|
+
block_positions = [
|
|
177
|
+
int(round(start + step * k)) for k in range(per_block_ticks)
|
|
178
|
+
]
|
|
179
|
+
block_positions[-1] = end - 1 # snap last to block end
|
|
180
|
+
block_positions = sorted(set(block_positions))
|
|
181
|
+
positions.extend(block_positions)
|
|
182
|
+
return positions
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _thinned_positions(n: int, max_ticks: int) -> list[int]:
|
|
186
|
+
"""Evenly-spaced tick positions across `[0, n)` with at most `max_ticks`."""
|
|
187
|
+
if n <= max_ticks:
|
|
188
|
+
return list(range(n))
|
|
189
|
+
step = max(2, (n - 1) // (max_ticks - 1))
|
|
190
|
+
return sorted({0, n - 1} | set(range(0, n, step)))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _thinned_per_block(
|
|
194
|
+
block_edges: tuple[int, ...], n: int, max_ticks: int
|
|
195
|
+
) -> list[int]:
|
|
196
|
+
"""Distribute tick budget proportionally across blocks (min 2/block)."""
|
|
197
|
+
n_blocks = len(block_edges) - 1
|
|
198
|
+
if n_blocks <= 0:
|
|
199
|
+
return _thinned_positions(n, max_ticks)
|
|
200
|
+
ticks_per_block = max(2, max_ticks // n_blocks)
|
|
201
|
+
positions: set[int] = set()
|
|
202
|
+
for i in range(n_blocks):
|
|
203
|
+
start = block_edges[i]
|
|
204
|
+
end = block_edges[i + 1]
|
|
205
|
+
block_n = end - start
|
|
206
|
+
if block_n <= 0:
|
|
207
|
+
continue
|
|
208
|
+
if block_n <= ticks_per_block:
|
|
209
|
+
positions.update(range(start, end))
|
|
210
|
+
else:
|
|
211
|
+
step = max(2, (block_n - 1) // (ticks_per_block - 1))
|
|
212
|
+
block_positions = list(range(start, end, step))
|
|
213
|
+
if end - 1 not in block_positions:
|
|
214
|
+
block_positions.append(end - 1)
|
|
215
|
+
positions.update(block_positions)
|
|
216
|
+
return sorted(positions)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _adaptive_angle(n_ticks: int, max_label_len: int, threshold: int) -> int:
|
|
220
|
+
"""0 (horizontal) or -45 (rotated) based on total label footprint."""
|
|
221
|
+
return -45 if (n_ticks * max_label_len) > threshold else 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _cell_labels_from_data(
|
|
225
|
+
data: pd.DataFrame, block_edges: tuple[int, ...]
|
|
226
|
+
) -> tuple[str, ...]:
|
|
227
|
+
"""Read one label per block from the `rsg` column at block start.
|
|
228
|
+
|
|
229
|
+
Falls back to `cell N` when the column is missing.
|
|
230
|
+
"""
|
|
231
|
+
col = "rsg" if "rsg" in data.columns else None
|
|
232
|
+
labels: list[str] = []
|
|
233
|
+
for i in range(len(block_edges) - 1):
|
|
234
|
+
start = block_edges[i]
|
|
235
|
+
if col is not None and start < len(data):
|
|
236
|
+
labels.append(str(data.iloc[start][col]))
|
|
237
|
+
else:
|
|
238
|
+
labels.append(f"cell {i + 1}")
|
|
239
|
+
return tuple(labels)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _choose_band_geometry(is_faceted: bool) -> tuple[int, float, int]:
|
|
243
|
+
"""Pick `(title_standoff_px, band_y, bottom_margin_px)` for a band layout.
|
|
244
|
+
|
|
245
|
+
Title standoff pins the title near the tick row so the band has room
|
|
246
|
+
below it. Band y is in paper coords (single) or y-domain (faceted).
|
|
247
|
+
"""
|
|
248
|
+
if is_faceted:
|
|
249
|
+
return (5, -0.30, 160)
|
|
250
|
+
return (8, -0.28, 140)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _band_stride(n_labels: int, max_visible: int = 25) -> int:
|
|
254
|
+
"""Show every Nth label when there are too many blocks."""
|
|
255
|
+
if n_labels <= max_visible:
|
|
256
|
+
return 1
|
|
257
|
+
return max(1, n_labels // max_visible + (1 if n_labels % max_visible else 0))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Public: pure layout computation
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def compute_x_axis_layout(
|
|
266
|
+
*,
|
|
267
|
+
data: pd.DataFrame,
|
|
268
|
+
time_var: str | None,
|
|
269
|
+
x_col: str | None,
|
|
270
|
+
boundaries: FlatBoundaries | None,
|
|
271
|
+
title_text: str,
|
|
272
|
+
is_faceted: bool = False,
|
|
273
|
+
max_ticks: int = 20,
|
|
274
|
+
per_block_ticks: int = 4,
|
|
275
|
+
) -> XAxisLayout:
|
|
276
|
+
"""Decide ticks + (optional) cell band + axis title for one chart panel.
|
|
277
|
+
|
|
278
|
+
Pure: no Figure mutation. Returns an `XAxisLayout` describing everything
|
|
279
|
+
the caller needs to apply to the figure.
|
|
280
|
+
|
|
281
|
+
Parameters
|
|
282
|
+
----------
|
|
283
|
+
data : DataFrame
|
|
284
|
+
The chart's data slice (already filtered to the panel/stratum).
|
|
285
|
+
time_var : str | None
|
|
286
|
+
Name of the time column to source tick labels from. If None or
|
|
287
|
+
absent from `data`, ticks fall back to Plotly defaults.
|
|
288
|
+
x_col : str | None
|
|
289
|
+
The column the trace's x-axis uses, if any. `None` indicates an
|
|
290
|
+
integer-position axis (time repeats across the data). When set,
|
|
291
|
+
the axis is categorical and tick positions are category values.
|
|
292
|
+
boundaries : FlatBoundaries | None
|
|
293
|
+
Per-block positions for this chart. Must be flat (unpacked from
|
|
294
|
+
the stratified dict by the caller).
|
|
295
|
+
title_text : str
|
|
296
|
+
X-axis title text. Always rendered; standoff is chosen here.
|
|
297
|
+
is_faceted : bool
|
|
298
|
+
Whether this layout is for a panel inside a faceted figure.
|
|
299
|
+
"""
|
|
300
|
+
n = len(data)
|
|
301
|
+
is_categorical = x_col is not None
|
|
302
|
+
|
|
303
|
+
# Default title (no explicit standoff) when no cell band is needed.
|
|
304
|
+
default_title = AxisTitle(text=title_text, standoff=None)
|
|
305
|
+
default_margin = 100 if is_faceted else 80
|
|
306
|
+
|
|
307
|
+
# Mode 1: time_var missing or empty data → Plotly defaults.
|
|
308
|
+
if not time_var or time_var not in data.columns or n == 0:
|
|
309
|
+
return XAxisLayout(
|
|
310
|
+
ticks=TickRow(None, None, 0, is_categorical),
|
|
311
|
+
cell_band=None,
|
|
312
|
+
title=default_title,
|
|
313
|
+
bottom_margin=default_margin,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Mode 2: small categorical → defer to Plotly's tick generation.
|
|
317
|
+
if is_categorical and n <= max_ticks:
|
|
318
|
+
return XAxisLayout(
|
|
319
|
+
ticks=TickRow(None, None, 0, True),
|
|
320
|
+
cell_band=None,
|
|
321
|
+
title=default_title,
|
|
322
|
+
bottom_margin=default_margin,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
block_edges = boundaries.block_edges(n) if boundaries else ()
|
|
326
|
+
has_blocks = len(block_edges) > 2 # need at least 2 blocks for a band
|
|
327
|
+
|
|
328
|
+
# Mode 3: integer-position axis with internal block structure →
|
|
329
|
+
# two-tier (per-block ticks + cell band).
|
|
330
|
+
if has_blocks and not is_categorical:
|
|
331
|
+
raw_positions = _per_block_positions(block_edges, per_block_ticks)
|
|
332
|
+
positions = tuple(p for p in raw_positions if 0 <= p < n)
|
|
333
|
+
labels = tuple(data[time_var].iloc[list(positions)].astype(str).tolist())
|
|
334
|
+
max_label_len = max((len(lbl) for lbl in labels), default=1)
|
|
335
|
+
angle = _adaptive_angle(len(positions), max_label_len, threshold=100)
|
|
336
|
+
|
|
337
|
+
cell_labels = _cell_labels_from_data(data, block_edges)
|
|
338
|
+
midpoints = tuple(
|
|
339
|
+
(block_edges[i] + block_edges[i + 1]) // 2
|
|
340
|
+
for i in range(len(block_edges) - 1)
|
|
341
|
+
)
|
|
342
|
+
stride = _band_stride(len(midpoints))
|
|
343
|
+
|
|
344
|
+
standoff_px, band_y, bottom_margin = _choose_band_geometry(is_faceted)
|
|
345
|
+
|
|
346
|
+
return XAxisLayout(
|
|
347
|
+
ticks=TickRow(positions, labels, angle, is_categorical=False),
|
|
348
|
+
cell_band=CellBand(
|
|
349
|
+
midpoints=midpoints,
|
|
350
|
+
labels=cell_labels,
|
|
351
|
+
stride=stride,
|
|
352
|
+
y_paper=band_y,
|
|
353
|
+
),
|
|
354
|
+
title=AxisTitle(text=title_text, standoff=standoff_px),
|
|
355
|
+
bottom_margin=bottom_margin,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Mode 4: thinned single-tier (categorical-large or integer-no-blocks).
|
|
359
|
+
if has_blocks and not is_categorical:
|
|
360
|
+
# (Unreachable in current logic — Mode 3 covered this. Kept for
|
|
361
|
+
# clarity that the per-block thinning path is intentionally fused
|
|
362
|
+
# with the two-tier path; the standalone fallback is below.)
|
|
363
|
+
raw_positions = _thinned_per_block(block_edges, n, max_ticks)
|
|
364
|
+
else:
|
|
365
|
+
raw_positions = _thinned_positions(n, max_ticks)
|
|
366
|
+
|
|
367
|
+
positions = tuple(p for p in raw_positions if 0 <= p < n)
|
|
368
|
+
|
|
369
|
+
if is_categorical and x_col is not None and x_col in data.columns:
|
|
370
|
+
# Categorical axis: tickvals are the actual category values
|
|
371
|
+
# (not row indices), labels parallel them.
|
|
372
|
+
tick_values = tuple(data[x_col].iloc[list(positions)].tolist())
|
|
373
|
+
labels = tuple(data[time_var].iloc[list(positions)].astype(str).tolist())
|
|
374
|
+
max_label_len = max((len(lbl) for lbl in labels), default=1)
|
|
375
|
+
angle = _adaptive_angle(len(positions), max_label_len, threshold=80)
|
|
376
|
+
# Store category values in `positions` slot for categorical mode.
|
|
377
|
+
return XAxisLayout(
|
|
378
|
+
ticks=TickRow(
|
|
379
|
+
positions=tick_values, # type: ignore[arg-type]
|
|
380
|
+
labels=labels,
|
|
381
|
+
angle=angle,
|
|
382
|
+
is_categorical=True,
|
|
383
|
+
),
|
|
384
|
+
cell_band=None,
|
|
385
|
+
title=default_title,
|
|
386
|
+
bottom_margin=default_margin,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Integer-position axis, no band.
|
|
390
|
+
labels = tuple(data[time_var].iloc[list(positions)].astype(str).tolist())
|
|
391
|
+
max_label_len = max((len(lbl) for lbl in labels), default=1)
|
|
392
|
+
angle = _adaptive_angle(len(positions), max_label_len, threshold=80)
|
|
393
|
+
return XAxisLayout(
|
|
394
|
+
ticks=TickRow(positions, labels, angle, is_categorical=False),
|
|
395
|
+
cell_band=None,
|
|
396
|
+
title=default_title,
|
|
397
|
+
bottom_margin=default_margin,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# Public: side-effect application
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def apply_x_axis_layout(
|
|
407
|
+
fig: go.Figure,
|
|
408
|
+
layout: XAxisLayout,
|
|
409
|
+
*,
|
|
410
|
+
row: int | None = None,
|
|
411
|
+
col: int | None = None,
|
|
412
|
+
ncols: int | None = None,
|
|
413
|
+
font_size: int = 12,
|
|
414
|
+
show_title: bool = True,
|
|
415
|
+
) -> None:
|
|
416
|
+
"""Write a computed layout onto the figure. The only place this module
|
|
417
|
+
touches a Plotly object.
|
|
418
|
+
|
|
419
|
+
`show_title=False` suppresses the axis title (used by faceted panels that
|
|
420
|
+
aren't on the bottom row and have no cell band). The cell band still
|
|
421
|
+
renders if present.
|
|
422
|
+
"""
|
|
423
|
+
subplot_kwargs: dict = {}
|
|
424
|
+
if row is not None and col is not None:
|
|
425
|
+
subplot_kwargs = {"row": row, "col": col}
|
|
426
|
+
|
|
427
|
+
# 1. Ticks + axis title in one call.
|
|
428
|
+
update_kwargs: dict[str, Any] = {"automargin": True}
|
|
429
|
+
if layout.ticks.positions is not None and layout.ticks.labels is not None:
|
|
430
|
+
update_kwargs["tickvals"] = list(layout.ticks.positions)
|
|
431
|
+
update_kwargs["ticktext"] = list(layout.ticks.labels)
|
|
432
|
+
update_kwargs["tickangle"] = layout.ticks.angle
|
|
433
|
+
|
|
434
|
+
if show_title:
|
|
435
|
+
title_kwargs: dict[str, Any] = {"text": layout.title.text}
|
|
436
|
+
if layout.title.standoff is not None:
|
|
437
|
+
title_kwargs["standoff"] = layout.title.standoff
|
|
438
|
+
update_kwargs["title"] = title_kwargs
|
|
439
|
+
else:
|
|
440
|
+
update_kwargs["title"] = {"text": ""}
|
|
441
|
+
|
|
442
|
+
fig.update_xaxes(**update_kwargs, **subplot_kwargs)
|
|
443
|
+
|
|
444
|
+
# 2. Cell-band annotations.
|
|
445
|
+
if layout.cell_band is not None:
|
|
446
|
+
band = layout.cell_band
|
|
447
|
+
if row is not None and col is not None:
|
|
448
|
+
subplot_idx = (row - 1) * (ncols or 1) + col
|
|
449
|
+
xref = "x" if subplot_idx == 1 else f"x{subplot_idx}"
|
|
450
|
+
yref = "y domain" if subplot_idx == 1 else f"y{subplot_idx} domain"
|
|
451
|
+
else:
|
|
452
|
+
xref = "x"
|
|
453
|
+
yref = "paper"
|
|
454
|
+
|
|
455
|
+
for idx, (mid, lbl) in enumerate(zip(band.midpoints, band.labels, strict=False)):
|
|
456
|
+
if idx % band.stride != 0:
|
|
457
|
+
continue
|
|
458
|
+
fig.add_annotation(
|
|
459
|
+
x=mid,
|
|
460
|
+
y=band.y_paper,
|
|
461
|
+
xref=xref,
|
|
462
|
+
yref=yref,
|
|
463
|
+
text=f"<b>{lbl}</b>",
|
|
464
|
+
showarrow=False,
|
|
465
|
+
xanchor="center",
|
|
466
|
+
yanchor="top",
|
|
467
|
+
font=dict(size=font_size),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# 3. Reserve enough bottom margin.
|
|
471
|
+
current = fig.layout.margin
|
|
472
|
+
current_b = current.b if current and current.b else 0
|
|
473
|
+
if current_b < layout.bottom_margin:
|
|
474
|
+
fig.update_layout(margin=dict(b=layout.bottom_margin))
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Zone shading for control charts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import plotly.graph_objects as go
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .themes import ChartTheme
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def calculate_zone_boundaries(stats: dict, theme: ChartTheme) -> list[tuple[float, float, str]] | None:
|
|
17
|
+
"""
|
|
18
|
+
Calculate zone boundaries for Western Electric rules visualization.
|
|
19
|
+
|
|
20
|
+
Returns zone definitions as (y0, y1, color) tuples, or None if
|
|
21
|
+
zones cannot be calculated (e.g., limits vary or are missing).
|
|
22
|
+
|
|
23
|
+
Zones are:
|
|
24
|
+
- Zone C: 0-sigma to +/-1-sigma (green - normal variation)
|
|
25
|
+
- Zone B: +/-1-sigma to +/-2-sigma (yellow - watch)
|
|
26
|
+
- Zone A: +/-2-sigma to +/-3-sigma (red - warning)
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
stats : dict
|
|
31
|
+
Chart statistics with 'center', 'upl', 'lpl' keys
|
|
32
|
+
theme : ChartTheme
|
|
33
|
+
Theme with zone colors
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
list of tuple or None
|
|
38
|
+
List of (y0, y1, color) tuples defining zone rectangles,
|
|
39
|
+
or None if zones cannot be calculated
|
|
40
|
+
"""
|
|
41
|
+
if stats.get('limits_vary'):
|
|
42
|
+
logger.debug('Zone shading skipped: limits vary')
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
center = stats.get('center')
|
|
46
|
+
ucl = stats.get('upl')
|
|
47
|
+
lcl = stats.get('lpl')
|
|
48
|
+
|
|
49
|
+
if center is None or ucl is None or lcl is None:
|
|
50
|
+
logger.debug('Zone shading skipped: missing center/upl/lpl in stats')
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
sigma = (ucl - center) / 3
|
|
54
|
+
|
|
55
|
+
return [
|
|
56
|
+
(center - sigma, center + sigma, theme.zone_c_color),
|
|
57
|
+
(center + sigma, center + 2 * sigma, theme.zone_b_color),
|
|
58
|
+
(center - 2 * sigma, center - sigma, theme.zone_b_color),
|
|
59
|
+
(center + 2 * sigma, ucl, theme.zone_a_color),
|
|
60
|
+
(lcl, center - 2 * sigma, theme.zone_a_color),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def add_zone_shading(
|
|
65
|
+
fig: go.Figure,
|
|
66
|
+
stats: dict,
|
|
67
|
+
theme: ChartTheme,
|
|
68
|
+
row: int | None = None,
|
|
69
|
+
col: int | None = None,
|
|
70
|
+
ncols: int | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Add zone shading to a chart figure.
|
|
74
|
+
|
|
75
|
+
Unified function for both single charts and faceted subplots.
|
|
76
|
+
When row/col are None, adds zones spanning the full figure.
|
|
77
|
+
When provided, targets the specific subplot.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
fig : go.Figure
|
|
82
|
+
Plotly figure to add shapes to
|
|
83
|
+
stats : dict
|
|
84
|
+
Chart statistics with 'center', 'upl', 'lpl' keys
|
|
85
|
+
theme : ChartTheme
|
|
86
|
+
Theme with zone colors and opacity
|
|
87
|
+
row : int, optional
|
|
88
|
+
Row number of subplot (1-indexed). None for single charts.
|
|
89
|
+
col : int, optional
|
|
90
|
+
Column number of subplot (1-indexed). None for single charts.
|
|
91
|
+
ncols : int, optional
|
|
92
|
+
Number of columns in the faceted layout. Required when row/col are set.
|
|
93
|
+
"""
|
|
94
|
+
zones = calculate_zone_boundaries(stats, theme)
|
|
95
|
+
if zones is None:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if row is not None and col is not None:
|
|
99
|
+
# Faceted: use add_shape with explicit axis references
|
|
100
|
+
subplot_idx = (row - 1) * ncols + col
|
|
101
|
+
if subplot_idx == 1:
|
|
102
|
+
xref = 'x'
|
|
103
|
+
yref = 'y'
|
|
104
|
+
else:
|
|
105
|
+
xref = f'x{subplot_idx}'
|
|
106
|
+
yref = f'y{subplot_idx}'
|
|
107
|
+
|
|
108
|
+
for y0, y1, color in zones:
|
|
109
|
+
fig.add_shape(
|
|
110
|
+
type='rect',
|
|
111
|
+
x0=0,
|
|
112
|
+
x1=1,
|
|
113
|
+
y0=y0,
|
|
114
|
+
y1=y1,
|
|
115
|
+
xref=f'{xref} domain',
|
|
116
|
+
yref=yref,
|
|
117
|
+
fillcolor=color,
|
|
118
|
+
opacity=theme.zone_opacity,
|
|
119
|
+
layer='below',
|
|
120
|
+
line_width=0,
|
|
121
|
+
row=row,
|
|
122
|
+
col=col,
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
# Single chart: use add_hrect
|
|
126
|
+
for y0, y1, color in zones:
|
|
127
|
+
fig.add_hrect(y0=y0, y1=y1, fillcolor=color, opacity=theme.zone_opacity, layer='below', line_width=0)
|