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,660 @@
1
+ """Shared rendering pipeline for control charts and histograms.
2
+
3
+ Provides one code path used by both single and faceted chart layouts.
4
+ All rendering goes through ``render_control_chart()`` or
5
+ ``render_histogram()``, eliminating the duplication that previously
6
+ existed between ``_plot_single_chart`` and ``_plot_faceted``.
7
+
8
+ The key insight: Plotly's ``add_hline()`` doesn't support subplot
9
+ targeting (``row``/``col``), so we use ``add_shape(type='line')`` +
10
+ ``add_annotation()`` everywhere. This lets the same function handle
11
+ both single and faceted charts.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ import numpy as np
19
+ import plotly.graph_objects as go
20
+
21
+ from ..types import ChartPayload
22
+ from .contracts import RenderContext
23
+ from .lane_boundaries import add_lane_boundaries
24
+ from .limits import add_stepped_limit_line, format_limit_label
25
+ from .run_rules_viz import add_run_rules_visualization
26
+ from .stats_box import add_stats_box
27
+ from .zones import add_zone_shading
28
+
29
+ if TYPE_CHECKING:
30
+ import pandas as pd
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Hover construction
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ def _build_hover(
39
+ data: pd.DataFrame,
40
+ ctx: RenderContext,
41
+ value_col: str,
42
+ ) -> tuple[list | None, str]:
43
+ """Build (customdata, hovertemplate) for the main data trace.
44
+
45
+ When the x-axis falls back to integer position because the time
46
+ variable is non-unique (the dense multi-cell X-chart case), the
47
+ default hover ``%{x}<br>%{y:.3f}`` shows just the row index — useless
48
+ once production_time is no longer a tick label. So we attach
49
+ customdata carrying [cell, time, obs_id] when those columns exist
50
+ and surface them in the template.
51
+
52
+ Returns (None, default_template) for charts where the x-axis already
53
+ carries enough context (categorical x_col), preserving existing
54
+ behavior for Xbar/S charts and other paths.
55
+ """
56
+ spec = ctx.spec
57
+ x_col = spec.x_col
58
+ cell_col = 'rsg' if 'rsg' in data.columns else None
59
+ time_var: str | None = None
60
+ for cand in ('PRODUCTION TIME', 'time', 'production_time', 'PRODUCTION_TIME'):
61
+ if cand in data.columns and cand != x_col and cand != value_col:
62
+ time_var = cand
63
+ break
64
+ has_obs_id = 'obs_id' in data.columns
65
+
66
+ # Only enrich when x falls back to integer position AND we have
67
+ # something useful to add. Categorical-x charts (Xbar by factor) and
68
+ # single-cell charts get the existing default template.
69
+ if x_col is not None or (cell_col is None and time_var is None and not has_obs_id):
70
+ return None, '%{x}<br>%{y:.3f}<extra></extra>'
71
+
72
+ parts = [f'<b>{ctx.chart_name}</b> = %{{y:.3f}}']
73
+ custom_cols: list[str] = []
74
+ template_pieces: list[str] = []
75
+ if cell_col:
76
+ custom_cols.append(cell_col)
77
+ template_pieces.append(f'Cell: %{{customdata[{len(custom_cols) - 1}]}}')
78
+ if time_var and time_var in data.columns:
79
+ custom_cols.append(time_var)
80
+ template_pieces.append(f'Time: %{{customdata[{len(custom_cols) - 1}]}}')
81
+ if has_obs_id:
82
+ custom_cols.append('obs_id')
83
+ template_pieces.append(f'Obs: %{{customdata[{len(custom_cols) - 1}]}}')
84
+
85
+ if not custom_cols:
86
+ return None, '%{x}<br>%{y:.3f}<extra></extra>'
87
+
88
+ customdata = data[custom_cols].astype(object).values.tolist()
89
+ hovertemplate = '<br>'.join(parts + template_pieces) + '<extra></extra>'
90
+ return customdata, hovertemplate
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Control chart rendering pipeline
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ def render_control_chart(
99
+ fig: go.Figure,
100
+ ctx: RenderContext,
101
+ row: int | None = None,
102
+ col: int | None = None,
103
+ nrows: int | None = None,
104
+ ncols: int | None = None,
105
+ ) -> None:
106
+ """Render one control chart panel onto *fig*.
107
+
108
+ This is the single rendering pipeline used by both single-chart and
109
+ faceted layouts. The ``row``/``col`` parameters determine subplot
110
+ targeting; when both are ``None`` the chart is drawn on a plain
111
+ ``go.Figure()``.
112
+
113
+ Parameters
114
+ ----------
115
+ fig : go.Figure
116
+ Target figure (may be a plain figure or a ``make_subplots`` grid).
117
+ ctx : RenderContext
118
+ Immutable rendering context carrying data, stats, theme and options.
119
+ row, col : int | None
120
+ 1-indexed subplot coordinates, or ``None`` for single-chart mode.
121
+ nrows, ncols : int | None
122
+ Grid dimensions (needed by ``add_stats_box`` for position math).
123
+ """
124
+ data = ctx.data
125
+ stats = ctx.stats
126
+ theme = ctx.theme
127
+ spec = ctx.spec
128
+ value_col = spec.value_col
129
+ x_col = spec.x_col
130
+
131
+ x_data = data[x_col] if x_col and x_col in data.columns else data.index
132
+
133
+ # 1. Zone shading (behind everything)
134
+ if ctx.show_zones and theme.zone_opacity > 0:
135
+ add_zone_shading(fig, stats, theme, row=row, col=col, ncols=ncols)
136
+
137
+ # 2. Main data trace
138
+ customdata, hovertemplate = _build_hover(data, ctx, value_col)
139
+ trace_kw = dict(
140
+ x=x_data,
141
+ y=data[value_col],
142
+ mode='lines+markers',
143
+ name=ctx.chart_name,
144
+ marker=dict(size=ctx.marker_size, color=theme.data_color),
145
+ line=dict(color=theme.data_color, width=ctx.line_width),
146
+ opacity=theme.data_opacity,
147
+ hovertemplate=hovertemplate,
148
+ )
149
+ if customdata is not None:
150
+ trace_kw['customdata'] = customdata
151
+ if ctx.is_faceted:
152
+ trace_kw['showlegend'] = False
153
+ _add_trace(fig, go.Scatter(**trace_kw), row, col)
154
+
155
+ # 3. Control limits and centerline
156
+ if ctx.show_limits:
157
+ _add_limits(fig, ctx, x_data, row, col, ncols)
158
+
159
+ # 4. Signal highlighting (Rule 1 — beyond limits)
160
+ if ctx.highlight_signals and 'beyond_limits' in data.columns:
161
+ _add_signals(fig, ctx, x_data, row, col)
162
+
163
+ # 5. Run rules (Rules 2-8)
164
+ if ctx.show_rules and spec.run_rules_applicable:
165
+ add_run_rules_visualization(
166
+ fig,
167
+ data,
168
+ stats,
169
+ ctx.chart_name,
170
+ value_col,
171
+ x_col,
172
+ theme,
173
+ result=ctx.result,
174
+ row=row,
175
+ col=col,
176
+ )
177
+
178
+ # 6. Stats box
179
+ if ctx.show_stats:
180
+ add_stats_box(
181
+ fig,
182
+ stats,
183
+ data,
184
+ theme,
185
+ row=row,
186
+ col=col,
187
+ nrows=nrows,
188
+ ncols=ncols,
189
+ )
190
+
191
+ # 7. Lane boundaries
192
+ lane_boundaries = spec.lane_boundaries
193
+ if lane_boundaries:
194
+ # Handle dict-keyed boundaries (per-stratum in faceted)
195
+ if isinstance(lane_boundaries, dict):
196
+ lane_boundaries = lane_boundaries.get(ctx.chart_name)
197
+ if lane_boundaries:
198
+ y_min = data[value_col].min()
199
+ y_max = data[value_col].max()
200
+ y_pad = (y_max - y_min) * 0.05
201
+ y_range = (y_min - y_pad, y_max + y_pad)
202
+ # Single-chart mode: cell labels are rendered as a band below the
203
+ # axis title by Plotter._apply_time_tick_labels, so suppress the
204
+ # top-of-plot lane labels here. Faceted mode keeps top labels
205
+ # per panel.
206
+ add_lane_boundaries(
207
+ fig,
208
+ lane_boundaries,
209
+ y_range,
210
+ theme,
211
+ row=row,
212
+ col=col,
213
+ show_labels=ctx.is_faceted,
214
+ )
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Histogram rendering pipeline
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ def render_histogram(
223
+ fig: go.Figure,
224
+ chart_info: ChartPayload,
225
+ theme,
226
+ *,
227
+ show_stats: bool = True,
228
+ row: int | None = None,
229
+ col: int | None = None,
230
+ shared_bin_edges: np.ndarray | None = None,
231
+ is_faceted: bool = False,
232
+ histnorm: str = '',
233
+ ) -> None:
234
+ """Render one histogram panel onto *fig*.
235
+
236
+ Handles both standalone and faceted histograms with optional
237
+ shared bin edges and mean/std overlay lines.
238
+
239
+ Parameters
240
+ ----------
241
+ fig : go.Figure
242
+ Target figure.
243
+ chart_info : dict
244
+ Chart info with 'data', 'statistics', and 'metadata'.
245
+ theme : ChartTheme
246
+ Visual theme.
247
+ show_stats : bool
248
+ Whether to overlay mean/std deviation lines.
249
+ row, col : int | None
250
+ Subplot coordinates (None for standalone).
251
+ shared_bin_edges : np.ndarray | None
252
+ Pre-computed bin edges for consistent cross-facet binning.
253
+ is_faceted : bool
254
+ Whether this is part of a faceted layout.
255
+ histnorm : str, default ""
256
+ Histogram normalization mode (``""`` for counts,
257
+ ``"percent"`` for percentages). Also checked in
258
+ ``chart_info['metadata']['histnorm']`` as fallback.
259
+ """
260
+ metadata = chart_info.get('metadata', {})
261
+ stats = chart_info.get('statistics', {})
262
+ data = chart_info['data']
263
+
264
+ value_col = metadata.get('value_col')
265
+ bins = metadata.get('bins')
266
+ chart_name = metadata.get('chart_type', 'Histogram')
267
+
268
+ # Resolve histnorm: explicit parameter wins, then metadata fallback
269
+ effective_histnorm = histnorm or metadata.get('histnorm', '')
270
+
271
+ # Histogram trace
272
+ if shared_bin_edges is not None:
273
+ bin_width = shared_bin_edges[1] - shared_bin_edges[0]
274
+ hist_trace = go.Histogram(
275
+ x=data[value_col],
276
+ xbins=dict(
277
+ start=shared_bin_edges[0],
278
+ end=shared_bin_edges[-1],
279
+ size=bin_width,
280
+ ),
281
+ name=chart_name,
282
+ marker_color=theme.data_color,
283
+ opacity=0.75,
284
+ showlegend=False,
285
+ histnorm=effective_histnorm if effective_histnorm else None,
286
+ )
287
+ else:
288
+ hist_trace = go.Histogram(
289
+ x=data[value_col],
290
+ nbinsx=bins,
291
+ name=value_col if not is_faceted else chart_name,
292
+ marker_color=theme.data_color,
293
+ opacity=0.75,
294
+ showlegend=is_faceted is False, # standalone shows legend default
295
+ histnorm=effective_histnorm if effective_histnorm else None,
296
+ )
297
+ _add_trace(fig, hist_trace, row, col)
298
+
299
+ # Stats overlay lines
300
+ if show_stats:
301
+ _add_histogram_stats(fig, stats, theme, row, col, is_faceted)
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Internal helpers
306
+ # ---------------------------------------------------------------------------
307
+
308
+
309
+ def _subplot_kwargs(row: int | None, col: int | None) -> dict:
310
+ """Build ``**subplot_kwargs`` for Plotly calls that accept row/col."""
311
+ if row is not None and col is not None:
312
+ return {'row': row, 'col': col}
313
+ return {}
314
+
315
+
316
+ def _add_trace(
317
+ fig: go.Figure,
318
+ trace: go.Scatter | go.Histogram,
319
+ row: int | None,
320
+ col: int | None,
321
+ ) -> None:
322
+ """Add a trace to fig, targeting subplot if row/col given."""
323
+ if row is not None and col is not None:
324
+ fig.add_trace(trace, row=row, col=col)
325
+ else:
326
+ fig.add_trace(trace)
327
+
328
+
329
+ def _add_fixed_limit(
330
+ fig: go.Figure,
331
+ y: float,
332
+ label: str,
333
+ color: str,
334
+ dash: str,
335
+ width: float,
336
+ font_size: int,
337
+ row: int | None,
338
+ col: int | None,
339
+ ncols: int | None = None,
340
+ ) -> None:
341
+ """Draw a fixed horizontal limit line using add_shape + add_annotation.
342
+
343
+ Uses domain-relative x-coordinates (0–1) so the line always spans the
344
+ full subplot width regardless of x-data type. This matches the pattern
345
+ used by ``add_zone_shading``.
346
+ """
347
+ subplot_kw = _subplot_kwargs(row, col)
348
+
349
+ # Build domain-relative xref (same logic as zones.py)
350
+ if row is not None and col is not None:
351
+ subplot_idx = (row - 1) * (ncols or 1) + col
352
+ xref = 'x domain' if subplot_idx == 1 else f'x{subplot_idx} domain'
353
+ else:
354
+ xref = 'x domain'
355
+
356
+ fig.add_shape(
357
+ type='line',
358
+ x0=0,
359
+ x1=1,
360
+ xref=xref,
361
+ y0=y,
362
+ y1=y,
363
+ line=dict(color=color, dash=dash, width=width),
364
+ **subplot_kw,
365
+ )
366
+
367
+
368
+ def _add_limit_summary_annotation(
369
+ fig: go.Figure,
370
+ ctx: RenderContext,
371
+ row: int | None,
372
+ col: int | None,
373
+ ncols: int | None = None,
374
+ ) -> None:
375
+ """Place a single summary annotation above a subplot with UPL/CL/LPL values.
376
+
377
+ Replaces the per-line right-side annotations with a compact pipe-delimited
378
+ string like ``UPL = 238.6 | CL = 237.4 | LPL = 236.2``.
379
+ """
380
+ stats = ctx.stats
381
+ spec = ctx.spec
382
+ theme = ctx.theme
383
+
384
+ parts: list[str] = []
385
+
386
+ # UPL
387
+ if stats.get('upl') is not None:
388
+ parts.append(format_limit_label('UPL', stats['upl'], True))
389
+
390
+ # CL
391
+ center_key = spec.center_key
392
+ if center_key and stats.get(center_key) is not None:
393
+ parts.append(format_limit_label('CL', stats[center_key], True))
394
+
395
+ # LPL
396
+ if stats.get('lpl') is not None:
397
+ parts.append(format_limit_label('LPL', stats['lpl'], True))
398
+
399
+ if not parts:
400
+ return
401
+
402
+ text = ' | '.join(parts)
403
+
404
+ # Build domain-relative refs.
405
+ # For single-chart mode, place the annotation in paper coords just
406
+ # below the title so it never collides with in-plot decorations
407
+ # (lane labels, signal markers, run-rule annotations). For faceted
408
+ # mode keep the per-subplot domain placement.
409
+ subplot_kw = _subplot_kwargs(row, col)
410
+ if row is not None and col is not None:
411
+ subplot_idx = (row - 1) * (ncols or 1) + col
412
+ xref = 'x domain' if subplot_idx == 1 else f'x{subplot_idx} domain'
413
+ yref = 'y domain' if subplot_idx == 1 else f'y{subplot_idx} domain'
414
+ x_pos, y_pos, yanchor = 1.0, 1.0, 'top'
415
+ else:
416
+ xref = 'paper'
417
+ yref = 'paper'
418
+ x_pos, y_pos, yanchor = 1.0, 1.04, 'bottom'
419
+
420
+ fig.add_annotation(
421
+ x=x_pos,
422
+ xref=xref,
423
+ y=y_pos,
424
+ yref=yref,
425
+ yanchor=yanchor,
426
+ xanchor='right',
427
+ text=text,
428
+ showarrow=False,
429
+ font=dict(
430
+ size=theme.annotation_font_size,
431
+ color=theme.limit_summary_color,
432
+ ),
433
+ bgcolor=theme.limit_summary_bgcolor,
434
+ **subplot_kw,
435
+ )
436
+
437
+
438
+ def _add_limits(
439
+ fig: go.Figure,
440
+ ctx: RenderContext,
441
+ x_data,
442
+ row: int | None,
443
+ col: int | None,
444
+ ncols: int | None = None,
445
+ ) -> None:
446
+ """Add UPL, LPL, centerline (fixed or stepped) and vary annotation."""
447
+ stats = ctx.stats
448
+ theme = ctx.theme
449
+ spec = ctx.spec
450
+ data = ctx.data
451
+ x_col = spec.x_col
452
+ font_size = theme.annotation_font_size
453
+
454
+ # ---- UPL ----
455
+ if 'upl' in stats:
456
+ if stats['upl'] is not None:
457
+ label = format_limit_label('UPL', stats['upl'], ctx.show_limit_values)
458
+ _add_fixed_limit(
459
+ fig,
460
+ stats['upl'],
461
+ label,
462
+ theme.ucl_color,
463
+ theme.limit_line_dash,
464
+ theme.limit_line_width,
465
+ font_size,
466
+ row,
467
+ col,
468
+ ncols,
469
+ )
470
+ elif 'upl' in data.columns:
471
+ add_stepped_limit_line(
472
+ fig,
473
+ data,
474
+ x_col,
475
+ 'upl',
476
+ theme.ucl_color,
477
+ theme.limit_line_dash,
478
+ theme.limit_line_width,
479
+ 'UPL',
480
+ theme,
481
+ row=row,
482
+ col=col,
483
+ )
484
+
485
+ # ---- LPL ----
486
+ if 'lpl' in stats:
487
+ if stats['lpl'] is not None:
488
+ label = format_limit_label('LPL', stats['lpl'], ctx.show_limit_values)
489
+ _add_fixed_limit(
490
+ fig,
491
+ stats['lpl'],
492
+ label,
493
+ theme.lcl_color,
494
+ theme.limit_line_dash,
495
+ theme.limit_line_width,
496
+ font_size,
497
+ row,
498
+ col,
499
+ ncols,
500
+ )
501
+ elif 'lpl' in data.columns:
502
+ add_stepped_limit_line(
503
+ fig,
504
+ data,
505
+ x_col,
506
+ 'lpl',
507
+ theme.lcl_color,
508
+ theme.limit_line_dash,
509
+ theme.limit_line_width,
510
+ 'LPL',
511
+ theme,
512
+ row=row,
513
+ col=col,
514
+ )
515
+
516
+ # ---- "Limits vary" annotation (single chart only) ----
517
+ if spec.limits_vary and not ctx.is_faceted:
518
+ vary_text = 'Process limits computed per phase' if spec.phased else 'Process limits vary by subgroup size (n)'
519
+ fig.add_annotation(
520
+ text=vary_text,
521
+ xref='paper',
522
+ yref='paper',
523
+ x=0.02,
524
+ y=0.98,
525
+ showarrow=False,
526
+ font=dict(size=10, color='gray'),
527
+ bgcolor='rgba(255,255,255,0.8)',
528
+ borderpad=3,
529
+ )
530
+
531
+ # ---- Centerline ----
532
+ center_key = spec.center_key
533
+ if center_key and center_key in stats:
534
+ if stats[center_key] is None:
535
+ if 'center' in data.columns:
536
+ add_stepped_limit_line(
537
+ fig,
538
+ data,
539
+ x_col,
540
+ 'center',
541
+ theme.center_color,
542
+ 'solid',
543
+ theme.center_line_width,
544
+ 'CL',
545
+ theme,
546
+ row=row,
547
+ col=col,
548
+ )
549
+ else:
550
+ label = format_limit_label('CL', stats[center_key], ctx.show_limit_values)
551
+ _add_fixed_limit(
552
+ fig,
553
+ stats[center_key],
554
+ label,
555
+ theme.center_color,
556
+ 'solid',
557
+ theme.center_line_width,
558
+ font_size,
559
+ row,
560
+ col,
561
+ ncols,
562
+ )
563
+
564
+ # ---- Limit summary annotation ----
565
+ if ctx.show_limit_values:
566
+ _add_limit_summary_annotation(fig, ctx, row, col, ncols)
567
+
568
+
569
+ def _add_signals(
570
+ fig: go.Figure,
571
+ ctx: RenderContext,
572
+ x_data,
573
+ row: int | None,
574
+ col: int | None,
575
+ ) -> None:
576
+ """Add signal markers (Rule 1 — beyond limits)."""
577
+ data = ctx.data
578
+ theme = ctx.theme
579
+ value_col = ctx.spec.value_col
580
+ x_col = ctx.spec.x_col
581
+
582
+ signals = data[data['beyond_limits'] != 0]
583
+ if signals.empty:
584
+ return
585
+
586
+ sig_x = signals[x_col] if x_col and x_col in data.columns else signals.index
587
+
588
+ marker = dict(
589
+ size=ctx.marker_size,
590
+ color=theme.signal_color,
591
+ symbol=theme.signal_marker_symbol,
592
+ )
593
+ # Single charts get the marker-line detail
594
+ if not ctx.is_faceted:
595
+ marker['line'] = dict(
596
+ width=theme.signal_marker_line_width,
597
+ color=theme.signal_marker_line_color,
598
+ )
599
+
600
+ hover_label = 'Signal' if ctx.is_faceted else 'Beyond Limits'
601
+ trace = go.Scatter(
602
+ x=sig_x,
603
+ y=signals[value_col],
604
+ mode='markers',
605
+ name=hover_label,
606
+ marker=marker,
607
+ showlegend=False,
608
+ hovertemplate=f'{hover_label}<br>%{{x}}<br>%{{y:.3f}}<extra></extra>',
609
+ )
610
+ _add_trace(fig, trace, row, col)
611
+
612
+
613
+ def _add_histogram_stats(
614
+ fig: go.Figure,
615
+ stats: dict,
616
+ theme,
617
+ row: int | None,
618
+ col: int | None,
619
+ is_faceted: bool,
620
+ ) -> None:
621
+ """Add mean/std overlay lines to a histogram."""
622
+ mean = stats.get('mean')
623
+ std = stats.get('std')
624
+ n = stats.get('n', 0)
625
+ subplot_kw = _subplot_kwargs(row, col)
626
+
627
+ if mean is None or not np.isfinite(mean):
628
+ return
629
+
630
+ # Mean line
631
+ vline_kw = dict(
632
+ x=mean,
633
+ line_dash='solid',
634
+ line_color=theme.center_color,
635
+ line_width=2,
636
+ )
637
+ if not is_faceted:
638
+ vline_kw['annotation_text'] = f'Mean: {mean:.3f}'
639
+ vline_kw['annotation_position'] = 'top'
640
+ vline_kw['annotation_font_size'] = theme.annotation_font_size
641
+ fig.add_vline(**vline_kw, **subplot_kw)
642
+
643
+ # Std deviation lines (±1σ, ±2σ, ±3σ)
644
+ if std is None or n < 2 or not np.isfinite(std) or std <= 0:
645
+ return
646
+
647
+ for mult in [1, 2, 3]:
648
+ for sign in [1, -1]:
649
+ vline_kw = dict(
650
+ x=mean + sign * mult * std,
651
+ line_dash='dash',
652
+ line_color='orange',
653
+ line_width=1,
654
+ )
655
+ if not is_faceted:
656
+ label = f'+{mult}σ' if sign > 0 else f'-{mult}σ'
657
+ vline_kw['annotation_text'] = label
658
+ vline_kw['annotation_position'] = 'top'
659
+ vline_kw['annotation_font_size'] = theme.annotation_font_size
660
+ fig.add_vline(**vline_kw, **subplot_kw)