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,157 @@
|
|
|
1
|
+
"""Limit line rendering 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
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from .themes import ChartTheme
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def format_limit_label(limit_name: str, value: float, show_value: bool) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Format control limit annotation label.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
limit_name : str
|
|
25
|
+
Name of the limit ('UPL', 'LPL', 'CL')
|
|
26
|
+
value : float
|
|
27
|
+
Numeric value of the limit
|
|
28
|
+
show_value : bool
|
|
29
|
+
Whether to include the numeric value
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
str
|
|
34
|
+
Formatted label like "UPL = 52.34" or just "UPL"
|
|
35
|
+
"""
|
|
36
|
+
if show_value:
|
|
37
|
+
if abs(value) >= 100:
|
|
38
|
+
return f'{limit_name} = {value:.1f}'
|
|
39
|
+
elif abs(value) >= 10:
|
|
40
|
+
return f'{limit_name} = {value:.2f}'
|
|
41
|
+
else:
|
|
42
|
+
return f'{limit_name} = {value:.3f}'
|
|
43
|
+
return limit_name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_stepped_coordinates(x_vals: list, limit_vals: list) -> tuple[list, list]:
|
|
47
|
+
"""
|
|
48
|
+
Build stepped line coordinates from x values and limit values.
|
|
49
|
+
|
|
50
|
+
For each point, draws a horizontal line to the next point's x position,
|
|
51
|
+
then steps up/down to the next point's limit value.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
x_vals : list
|
|
56
|
+
X-axis values
|
|
57
|
+
limit_vals : list
|
|
58
|
+
Limit values at each x position
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
tuple[list, list]
|
|
63
|
+
(x_stepped, y_stepped) coordinate lists for the stepped line
|
|
64
|
+
"""
|
|
65
|
+
if len(x_vals) != len(limit_vals):
|
|
66
|
+
raise ValueError(f'x_vals and limit_vals must have equal length: {len(x_vals)} != {len(limit_vals)}')
|
|
67
|
+
|
|
68
|
+
x_stepped = []
|
|
69
|
+
y_stepped = []
|
|
70
|
+
|
|
71
|
+
for i in range(len(x_vals)):
|
|
72
|
+
x_stepped.append(x_vals[i])
|
|
73
|
+
y_stepped.append(limit_vals[i])
|
|
74
|
+
|
|
75
|
+
if i < len(x_vals) - 1:
|
|
76
|
+
x_stepped.append(x_vals[i + 1])
|
|
77
|
+
y_stepped.append(limit_vals[i])
|
|
78
|
+
|
|
79
|
+
return x_stepped, y_stepped
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def add_stepped_limit_line(
|
|
83
|
+
fig: go.Figure,
|
|
84
|
+
data: pd.DataFrame,
|
|
85
|
+
x_col: str,
|
|
86
|
+
limit_col: str,
|
|
87
|
+
line_color: str,
|
|
88
|
+
line_dash: str,
|
|
89
|
+
line_width: float,
|
|
90
|
+
limit_name: str,
|
|
91
|
+
theme: ChartTheme,
|
|
92
|
+
row: int | None = None,
|
|
93
|
+
col: int | None = None,
|
|
94
|
+
) -> go.Figure:
|
|
95
|
+
"""
|
|
96
|
+
Add a stepped limit line that follows varying per-row limits.
|
|
97
|
+
|
|
98
|
+
Unified function for both single charts and faceted subplots.
|
|
99
|
+
When row/col are None, adds to the main figure. When provided,
|
|
100
|
+
adds to the specified subplot.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
fig : go.Figure
|
|
105
|
+
Plotly figure to add the line to
|
|
106
|
+
data : pd.DataFrame
|
|
107
|
+
Chart data with limit column
|
|
108
|
+
x_col : str
|
|
109
|
+
Name of x-axis column
|
|
110
|
+
limit_col : str
|
|
111
|
+
Name of limit column ('upl' or 'lpl')
|
|
112
|
+
line_color : str
|
|
113
|
+
Color for the limit line
|
|
114
|
+
line_dash : str
|
|
115
|
+
Dash pattern for the line
|
|
116
|
+
line_width : float
|
|
117
|
+
Width of the line
|
|
118
|
+
limit_name : str
|
|
119
|
+
Name for hover label ('UPL' or 'LPL')
|
|
120
|
+
theme : ChartTheme
|
|
121
|
+
Chart theme for styling
|
|
122
|
+
row : int, optional
|
|
123
|
+
Subplot row (1-indexed). None for single charts.
|
|
124
|
+
col : int, optional
|
|
125
|
+
Subplot column (1-indexed). None for single charts.
|
|
126
|
+
|
|
127
|
+
Returns
|
|
128
|
+
-------
|
|
129
|
+
go.Figure
|
|
130
|
+
The figure, with the stepped limit line added (or unchanged if
|
|
131
|
+
``limit_col`` is not present in the data).
|
|
132
|
+
"""
|
|
133
|
+
if limit_col not in data.columns:
|
|
134
|
+
logger.debug("Limit column '%s' not found; skipping stepped limit line", limit_col)
|
|
135
|
+
return fig
|
|
136
|
+
|
|
137
|
+
x_vals = data[x_col].tolist() if x_col in data.columns else data.index.tolist()
|
|
138
|
+
limit_vals = data[limit_col].tolist()
|
|
139
|
+
|
|
140
|
+
x_stepped, y_stepped = build_stepped_coordinates(x_vals, limit_vals)
|
|
141
|
+
|
|
142
|
+
scatter = go.Scatter(
|
|
143
|
+
x=x_stepped,
|
|
144
|
+
y=y_stepped,
|
|
145
|
+
mode='lines',
|
|
146
|
+
name=f'{limit_name} (varies)',
|
|
147
|
+
line=dict(color=line_color, dash=line_dash, width=line_width),
|
|
148
|
+
hovertemplate=f'{limit_name}: %{{y:.3f}}<extra></extra>',
|
|
149
|
+
showlegend=False,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if row is not None and col is not None:
|
|
153
|
+
fig.add_trace(scatter, row=row, col=col)
|
|
154
|
+
else:
|
|
155
|
+
fig.add_trace(scatter)
|
|
156
|
+
|
|
157
|
+
return fig
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Maximum Information Analysis visualization.
|
|
3
|
+
|
|
4
|
+
Combined XmR + percentage histogram layout for R2 residuals,
|
|
5
|
+
following the ``capability_chart.py`` pattern.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import plotly.graph_objects as go
|
|
13
|
+
from plotly.subplots import make_subplots
|
|
14
|
+
|
|
15
|
+
from ..exceptions import ValidationError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ..maximum_information import MaximumInformationResult
|
|
19
|
+
from .themes import ChartTheme
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_maximum_information_chart(
|
|
23
|
+
result: MaximumInformationResult,
|
|
24
|
+
*,
|
|
25
|
+
view: str = 'combined',
|
|
26
|
+
bins: int = 10,
|
|
27
|
+
theme: ChartTheme | None = None,
|
|
28
|
+
width: int = 900,
|
|
29
|
+
height: int = 700,
|
|
30
|
+
title: str | None = None,
|
|
31
|
+
) -> go.Figure:
|
|
32
|
+
"""
|
|
33
|
+
Create a Maximum Information Analysis chart.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
result : MaximumInformationResult
|
|
38
|
+
Result from ``study.maximum_information()``.
|
|
39
|
+
view : str, default ``'combined'``
|
|
40
|
+
``'combined'`` — Individuals chart + percentage histogram.
|
|
41
|
+
``'xmr'`` — Individuals chart of R2 only.
|
|
42
|
+
``'histogram'`` — Percentage histogram only.
|
|
43
|
+
bins : int, default 10
|
|
44
|
+
Number of histogram bins.
|
|
45
|
+
theme : ChartTheme, optional
|
|
46
|
+
Visual theme. Defaults to the ``processbehavior`` theme.
|
|
47
|
+
width, height : int
|
|
48
|
+
Figure dimensions in pixels.
|
|
49
|
+
title : str, optional
|
|
50
|
+
Override chart title.
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
go.Figure
|
|
55
|
+
"""
|
|
56
|
+
if view not in ('combined', 'xmr', 'histogram'):
|
|
57
|
+
raise ValidationError(f"view must be 'combined', 'xmr', or 'histogram', got {view!r}")
|
|
58
|
+
|
|
59
|
+
if theme is None or isinstance(theme, str):
|
|
60
|
+
from .themes import get_theme
|
|
61
|
+
|
|
62
|
+
theme = get_theme(theme if isinstance(theme, str) else 'processbehavior')
|
|
63
|
+
|
|
64
|
+
default_title = 'Maximum Information Analysis of R2 Residuals'
|
|
65
|
+
chart_title = title if title is not None else default_title
|
|
66
|
+
|
|
67
|
+
if view == 'combined':
|
|
68
|
+
return _render_combined(result, theme, chart_title, width, height, bins)
|
|
69
|
+
elif view == 'xmr':
|
|
70
|
+
return _render_xmr(result, theme, chart_title, width, height)
|
|
71
|
+
else:
|
|
72
|
+
return _render_histogram(result, theme, chart_title, width, height, bins)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Combined: XmR (top two rows) + Histogram (bottom)
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _render_combined(
|
|
81
|
+
result: MaximumInformationResult,
|
|
82
|
+
theme: ChartTheme,
|
|
83
|
+
title: str,
|
|
84
|
+
width: int,
|
|
85
|
+
height: int,
|
|
86
|
+
bins: int = 10,
|
|
87
|
+
) -> go.Figure:
|
|
88
|
+
fig = make_subplots(
|
|
89
|
+
rows=2,
|
|
90
|
+
cols=1,
|
|
91
|
+
row_heights=[0.5, 0.5],
|
|
92
|
+
subplot_titles=['XmR', 'Distribution'],
|
|
93
|
+
vertical_spacing=0.14,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
_add_individuals_panel(fig, result, theme, row=1, col=1)
|
|
97
|
+
_add_histogram_panel(fig, result, theme, row=2, col=1, bins=bins)
|
|
98
|
+
|
|
99
|
+
fig.update_layout(**theme.to_layout_dict())
|
|
100
|
+
fig.update_layout(
|
|
101
|
+
title=title,
|
|
102
|
+
width=width,
|
|
103
|
+
height=height,
|
|
104
|
+
showlegend=False,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return fig
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# XmR only (individuals chart)
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _render_xmr(
|
|
116
|
+
result: MaximumInformationResult,
|
|
117
|
+
theme: ChartTheme,
|
|
118
|
+
title: str,
|
|
119
|
+
width: int,
|
|
120
|
+
height: int,
|
|
121
|
+
) -> go.Figure:
|
|
122
|
+
fig = go.Figure()
|
|
123
|
+
|
|
124
|
+
_add_individuals_panel(fig, result, theme)
|
|
125
|
+
|
|
126
|
+
fig.update_layout(**theme.to_layout_dict())
|
|
127
|
+
fig.update_layout(
|
|
128
|
+
title=title,
|
|
129
|
+
width=width,
|
|
130
|
+
height=height,
|
|
131
|
+
showlegend=False,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return fig
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Histogram only
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _render_histogram(
|
|
143
|
+
result: MaximumInformationResult,
|
|
144
|
+
theme: ChartTheme,
|
|
145
|
+
title: str,
|
|
146
|
+
width: int,
|
|
147
|
+
height: int,
|
|
148
|
+
bins: int = 10,
|
|
149
|
+
) -> go.Figure:
|
|
150
|
+
fig = go.Figure()
|
|
151
|
+
|
|
152
|
+
_add_histogram_panel(fig, result, theme, bins=bins)
|
|
153
|
+
|
|
154
|
+
fig.update_layout(**theme.to_layout_dict())
|
|
155
|
+
fig.update_layout(
|
|
156
|
+
title=title,
|
|
157
|
+
width=width,
|
|
158
|
+
height=height,
|
|
159
|
+
showlegend=False,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return fig
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Panel renderers
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _add_individuals_panel(
|
|
171
|
+
fig: go.Figure,
|
|
172
|
+
result: MaximumInformationResult,
|
|
173
|
+
theme: ChartTheme,
|
|
174
|
+
row: int | None = None,
|
|
175
|
+
col: int | None = None,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Render the X (individuals) panel of the XmR chart."""
|
|
178
|
+
info = result._xmr_chart_info
|
|
179
|
+
data = info['data']
|
|
180
|
+
stats = info['statistics']
|
|
181
|
+
x = data['obs']
|
|
182
|
+
y = data['R2']
|
|
183
|
+
|
|
184
|
+
subplot_kw = {}
|
|
185
|
+
if row is not None and col is not None:
|
|
186
|
+
subplot_kw = {'row': row, 'col': col}
|
|
187
|
+
|
|
188
|
+
# Data trace
|
|
189
|
+
fig.add_trace(
|
|
190
|
+
go.Scatter(
|
|
191
|
+
x=x,
|
|
192
|
+
y=y,
|
|
193
|
+
mode='lines+markers',
|
|
194
|
+
name='R2',
|
|
195
|
+
marker=dict(size=5, color=theme.data_color),
|
|
196
|
+
line=dict(color=theme.data_color, width=1),
|
|
197
|
+
hovertemplate='%{x}<br>%{y:.4f}<extra></extra>',
|
|
198
|
+
showlegend=False,
|
|
199
|
+
),
|
|
200
|
+
**subplot_kw,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Signal markers
|
|
204
|
+
signals = data[data['beyond_limits'] != 0]
|
|
205
|
+
if not signals.empty:
|
|
206
|
+
fig.add_trace(
|
|
207
|
+
go.Scatter(
|
|
208
|
+
x=signals['obs'],
|
|
209
|
+
y=signals['R2'],
|
|
210
|
+
mode='markers',
|
|
211
|
+
name='Signal',
|
|
212
|
+
marker=dict(
|
|
213
|
+
size=theme.signal_marker_size,
|
|
214
|
+
color=theme.signal_color,
|
|
215
|
+
symbol=theme.signal_marker_symbol,
|
|
216
|
+
line=dict(
|
|
217
|
+
width=theme.signal_marker_line_width,
|
|
218
|
+
color=theme.signal_marker_line_color,
|
|
219
|
+
),
|
|
220
|
+
),
|
|
221
|
+
showlegend=False,
|
|
222
|
+
hovertemplate='Signal<br>%{x}<br>%{y:.4f}<extra></extra>',
|
|
223
|
+
),
|
|
224
|
+
**subplot_kw,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if row is not None and col is not None:
|
|
228
|
+
# Limit lines (subplot)
|
|
229
|
+
_add_hline(fig, stats['x_mean'], theme.center_color, 'solid', 2, row, col)
|
|
230
|
+
_add_hline(fig, stats['x_upl'], theme.ucl_color, 'dash', 1.5, row, col)
|
|
231
|
+
_add_hline(fig, stats['x_lpl'], theme.lcl_color, 'dash', 1.5, row, col)
|
|
232
|
+
|
|
233
|
+
# Limit annotations
|
|
234
|
+
r = result.round_to
|
|
235
|
+
_add_limit_annotation(fig, f'UPL={round(stats["x_upl"], r)}', stats['x_upl'], theme, row, col)
|
|
236
|
+
_add_limit_annotation(fig, f'CL={round(stats["x_mean"], r)}', stats['x_mean'], theme, row, col)
|
|
237
|
+
_add_limit_annotation(fig, f'LPL={round(stats["x_lpl"], r)}', stats['x_lpl'], theme, row, col)
|
|
238
|
+
|
|
239
|
+
fig.update_yaxes(title_text='R2', row=row, col=col)
|
|
240
|
+
else:
|
|
241
|
+
# Standalone figure
|
|
242
|
+
fig.add_hline(y=stats['x_mean'], line_color=theme.center_color, line_dash='solid', line_width=2)
|
|
243
|
+
fig.add_hline(y=stats['x_upl'], line_color=theme.ucl_color, line_dash='dash', line_width=1.5)
|
|
244
|
+
fig.add_hline(y=stats['x_lpl'], line_color=theme.lcl_color, line_dash='dash', line_width=1.5)
|
|
245
|
+
|
|
246
|
+
r = result.round_to
|
|
247
|
+
for text, yval in [
|
|
248
|
+
(f'UPL={round(stats["x_upl"], r)}', stats['x_upl']),
|
|
249
|
+
(f'CL={round(stats["x_mean"], r)}', stats['x_mean']),
|
|
250
|
+
(f'LPL={round(stats["x_lpl"], r)}', stats['x_lpl']),
|
|
251
|
+
]:
|
|
252
|
+
fig.add_annotation(
|
|
253
|
+
x=1.0,
|
|
254
|
+
xref='x domain',
|
|
255
|
+
y=yval,
|
|
256
|
+
yref='y',
|
|
257
|
+
text=text,
|
|
258
|
+
showarrow=False,
|
|
259
|
+
xanchor='left',
|
|
260
|
+
font=dict(size=theme.annotation_font_size, color='gray'),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
fig.update_yaxes(title_text='R2')
|
|
264
|
+
fig.update_xaxes(title_text='Observation')
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _add_histogram_panel(
|
|
268
|
+
fig: go.Figure,
|
|
269
|
+
result: MaximumInformationResult,
|
|
270
|
+
theme: ChartTheme,
|
|
271
|
+
row: int | None = None,
|
|
272
|
+
col: int | None = None,
|
|
273
|
+
bins: int = 10,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Render the percentage histogram of R2 with NPL x-axis extension."""
|
|
276
|
+
info = result._histogram_chart_info
|
|
277
|
+
data = info['data']
|
|
278
|
+
r2_values = data['R2'].to_numpy()
|
|
279
|
+
|
|
280
|
+
trace_kwargs = dict(
|
|
281
|
+
x=r2_values,
|
|
282
|
+
nbinsx=bins,
|
|
283
|
+
histnorm='percent',
|
|
284
|
+
marker_color=theme.data_color,
|
|
285
|
+
opacity=0.75,
|
|
286
|
+
name='R2',
|
|
287
|
+
showlegend=False,
|
|
288
|
+
hovertemplate='Range: %{x}<br>Percent: %{y:.1f}%<extra></extra>',
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if row is not None and col is not None:
|
|
292
|
+
fig.add_trace(go.Histogram(**trace_kwargs), row=row, col=col)
|
|
293
|
+
else:
|
|
294
|
+
fig.add_trace(go.Histogram(**trace_kwargs))
|
|
295
|
+
|
|
296
|
+
# Mean line
|
|
297
|
+
stats = result._xmr_chart_info['statistics']
|
|
298
|
+
r2_mean = stats['x_mean']
|
|
299
|
+
upl = stats['x_upl']
|
|
300
|
+
lpl = stats['x_lpl']
|
|
301
|
+
|
|
302
|
+
subplot_kw = {}
|
|
303
|
+
if row is not None and col is not None:
|
|
304
|
+
subplot_kw = {'row': row, 'col': col}
|
|
305
|
+
|
|
306
|
+
fig.add_vline(
|
|
307
|
+
x=r2_mean,
|
|
308
|
+
line_color=theme.center_color,
|
|
309
|
+
line_width=2,
|
|
310
|
+
line_dash='solid',
|
|
311
|
+
**subplot_kw,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# NPL lines on histogram
|
|
315
|
+
fig.add_vline(
|
|
316
|
+
x=upl,
|
|
317
|
+
line_color=theme.ucl_color,
|
|
318
|
+
line_width=1.5,
|
|
319
|
+
line_dash='dash',
|
|
320
|
+
**subplot_kw,
|
|
321
|
+
)
|
|
322
|
+
fig.add_vline(
|
|
323
|
+
x=lpl,
|
|
324
|
+
line_color=theme.lcl_color,
|
|
325
|
+
line_width=1.5,
|
|
326
|
+
line_dash='dash',
|
|
327
|
+
**subplot_kw,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Extend x-axis to NPL range
|
|
331
|
+
npl_range = upl - lpl
|
|
332
|
+
padding = npl_range * 0.05
|
|
333
|
+
x_min = lpl - padding
|
|
334
|
+
x_max = upl + padding
|
|
335
|
+
|
|
336
|
+
if row is not None and col is not None:
|
|
337
|
+
fig.update_xaxes(range=[x_min, x_max], row=row, col=col)
|
|
338
|
+
fig.update_yaxes(title_text='Percent', row=row, col=col)
|
|
339
|
+
fig.update_xaxes(title_text='R2', row=row, col=col)
|
|
340
|
+
else:
|
|
341
|
+
fig.update_xaxes(range=[x_min, x_max], title_text='R2')
|
|
342
|
+
fig.update_yaxes(title_text='Percent')
|
|
343
|
+
|
|
344
|
+
fig.update_layout(bargap=0.05)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
# Helpers
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _add_hline(
|
|
353
|
+
fig: go.Figure,
|
|
354
|
+
y: float,
|
|
355
|
+
color: str,
|
|
356
|
+
dash: str,
|
|
357
|
+
width: float,
|
|
358
|
+
row: int,
|
|
359
|
+
col: int,
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Add a horizontal line to a subplot."""
|
|
362
|
+
fig.add_hline(
|
|
363
|
+
y=y,
|
|
364
|
+
line_color=color,
|
|
365
|
+
line_dash=dash,
|
|
366
|
+
line_width=width,
|
|
367
|
+
row=row,
|
|
368
|
+
col=col,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _add_limit_annotation(
|
|
373
|
+
fig: go.Figure,
|
|
374
|
+
text: str,
|
|
375
|
+
y: float,
|
|
376
|
+
theme: ChartTheme,
|
|
377
|
+
row: int,
|
|
378
|
+
col: int,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Add a right-aligned limit annotation."""
|
|
381
|
+
# Determine axis refs for the subplot
|
|
382
|
+
subplot_idx = row # single-column layout: row index = subplot index
|
|
383
|
+
yref = 'y' if subplot_idx == 1 else f'y{subplot_idx}'
|
|
384
|
+
xref = 'x domain' if subplot_idx == 1 else f'x{subplot_idx} domain'
|
|
385
|
+
|
|
386
|
+
fig.add_annotation(
|
|
387
|
+
x=1.0,
|
|
388
|
+
xref=xref,
|
|
389
|
+
y=y,
|
|
390
|
+
yref=yref,
|
|
391
|
+
text=text,
|
|
392
|
+
showarrow=False,
|
|
393
|
+
xanchor='left',
|
|
394
|
+
font=dict(size=theme.annotation_font_size, color='gray'),
|
|
395
|
+
)
|