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.
Files changed (56) hide show
  1. processbehavior/__init__.py +133 -0
  2. processbehavior/analysis.py +2944 -0
  3. processbehavior/analysis_dataset.py +361 -0
  4. processbehavior/analysis_result.py +1276 -0
  5. processbehavior/calibration.py +70 -0
  6. processbehavior/capability.py +720 -0
  7. processbehavior/data_preparation.py +792 -0
  8. processbehavior/datasets/__init__.py +37 -0
  9. processbehavior/datasets/data/coffee_shop_demo_long.csv +385 -0
  10. processbehavior/datasets/loaders.py +66 -0
  11. processbehavior/datasets/synthetic.py +1711 -0
  12. processbehavior/derivations.py +602 -0
  13. processbehavior/effects_calculator.py +603 -0
  14. processbehavior/exceptions.py +216 -0
  15. processbehavior/exporters/__init__.py +5 -0
  16. processbehavior/exporters/excel_exporter.py +708 -0
  17. processbehavior/focused_result.py +329 -0
  18. processbehavior/formulation_spec.py +144 -0
  19. processbehavior/loss_function.py +517 -0
  20. processbehavior/maximum_information.py +285 -0
  21. processbehavior/plotting/__init__.py +46 -0
  22. processbehavior/plotting/_math.py +121 -0
  23. processbehavior/plotting/capability_chart.py +858 -0
  24. processbehavior/plotting/contracts.py +176 -0
  25. processbehavior/plotting/control_chart.py +206 -0
  26. processbehavior/plotting/effects_charts.py +640 -0
  27. processbehavior/plotting/lane_boundaries.py +78 -0
  28. processbehavior/plotting/limits.py +157 -0
  29. processbehavior/plotting/maximum_information_chart.py +395 -0
  30. processbehavior/plotting/plotter.py +1469 -0
  31. processbehavior/plotting/renderers.py +660 -0
  32. processbehavior/plotting/report.py +211 -0
  33. processbehavior/plotting/residuals.py +282 -0
  34. processbehavior/plotting/run_rules_viz.py +150 -0
  35. processbehavior/plotting/stats_box.py +183 -0
  36. processbehavior/plotting/themes.py +544 -0
  37. processbehavior/plotting/x_axis_layout.py +474 -0
  38. processbehavior/plotting/zones.py +127 -0
  39. processbehavior/process_behavior.py +1290 -0
  40. processbehavior/py.typed +0 -0
  41. processbehavior/residual_calculator.py +572 -0
  42. processbehavior/result_signals.py +149 -0
  43. processbehavior/result_tabular.py +296 -0
  44. processbehavior/sds_detector.py +1432 -0
  45. processbehavior/signals/__init__.py +14 -0
  46. processbehavior/signals/config.py +435 -0
  47. processbehavior/signals/detector.py +328 -0
  48. processbehavior/signals/detectors.py +368 -0
  49. processbehavior/signals/result.py +232 -0
  50. processbehavior/spc_constants.py +692 -0
  51. processbehavior/study.py +2733 -0
  52. processbehavior/types.py +154 -0
  53. processbehavior-0.2.0.dist-info/METADATA +279 -0
  54. processbehavior-0.2.0.dist-info/RECORD +56 -0
  55. processbehavior-0.2.0.dist-info/WHEEL +4 -0
  56. 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)