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,149 @@
|
|
|
1
|
+
"""Signal-detection orchestration for an `AnalysisResult`.
|
|
2
|
+
|
|
3
|
+
Wraps the `processbehavior.signals` machinery (`SignalDetector`,
|
|
4
|
+
`SignalConfig`, `RuleSet`) to produce SignalResults from an analysis
|
|
5
|
+
result's charts. Extracted from `AnalysisResult.detect_signals` so the
|
|
6
|
+
orchestration is unit-testable without instantiating the full result.
|
|
7
|
+
|
|
8
|
+
The class method on `AnalysisResult` remains as a thin delegate.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from .exceptions import ChartNotAvailableError, ProcessBehaviorError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .analysis_result import AnalysisResult
|
|
19
|
+
from .signals.result import SignalResult
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Public: detect_signals_for_result
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detect_signals_for_result(
|
|
28
|
+
result: AnalysisResult,
|
|
29
|
+
*,
|
|
30
|
+
chart: str | None = None,
|
|
31
|
+
rules: str | list[str] | Any | None = None,
|
|
32
|
+
config: Any | None = None,
|
|
33
|
+
**kwargs: Any,
|
|
34
|
+
) -> SignalResult | dict[str, SignalResult]:
|
|
35
|
+
"""Detect rule violations across one or all charts on `result`.
|
|
36
|
+
|
|
37
|
+
Parameters mirror `AnalysisResult.detect_signals` exactly. See that
|
|
38
|
+
docstring for the contract.
|
|
39
|
+
|
|
40
|
+
Returns one `SignalResult` when `chart` is specified, or a dict
|
|
41
|
+
keyed by chart name otherwise.
|
|
42
|
+
"""
|
|
43
|
+
from .signals import SignalDetector
|
|
44
|
+
|
|
45
|
+
config = _build_config(rules, config, kwargs)
|
|
46
|
+
detector = SignalDetector()
|
|
47
|
+
|
|
48
|
+
if chart:
|
|
49
|
+
chart = result._resolve_chart_name(chart)
|
|
50
|
+
if chart not in result.charts:
|
|
51
|
+
raise ChartNotAvailableError(
|
|
52
|
+
f"Chart '{chart}' not found.\nAvailable: {result.all_charts}",
|
|
53
|
+
chart=chart,
|
|
54
|
+
available=result.all_charts,
|
|
55
|
+
)
|
|
56
|
+
return _detect_for_chart(detector, result, chart, config)
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
name: _detect_for_chart(detector, result, name, config)
|
|
60
|
+
for name in result.charts
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Public: chart-name → base-type helper
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Base chart types whose name prefix maps to the same chart_type label.
|
|
70
|
+
_KNOWN_CHART_TYPES = ("Xbar", "S", "X", "mR")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def extract_chart_type(chart_name: str) -> str:
|
|
74
|
+
"""Base chart type from a chart name.
|
|
75
|
+
|
|
76
|
+
Handles stratified chart names like 'X_lane_1' → 'X'. Falls back to
|
|
77
|
+
'Xbar' for anything unrecognised (matches the legacy behaviour).
|
|
78
|
+
"""
|
|
79
|
+
for prefix in _KNOWN_CHART_TYPES:
|
|
80
|
+
if chart_name.startswith(prefix):
|
|
81
|
+
return prefix
|
|
82
|
+
return "Xbar"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Internal helpers
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_config(rules: Any, config: Any | None, kwargs: dict[str, Any]) -> Any:
|
|
91
|
+
"""Compose the SignalConfig from the public-API arguments.
|
|
92
|
+
|
|
93
|
+
Mirrors the legacy `AnalysisResult.detect_signals` precedence:
|
|
94
|
+
- If `config` is provided, use it as-is (kwargs and rules ignored).
|
|
95
|
+
- Otherwise instantiate a default SignalConfig and apply `rules`
|
|
96
|
+
(str | list | RuleSet) then any matching attributes from kwargs.
|
|
97
|
+
"""
|
|
98
|
+
from .signals import RuleSet, SignalConfig
|
|
99
|
+
|
|
100
|
+
if config is not None:
|
|
101
|
+
return config
|
|
102
|
+
|
|
103
|
+
# Route `rules` through the constructor: SignalConfig.__post_init__ is what
|
|
104
|
+
# resolves preset strings ('standard'/'extended'/'all') to rule lists.
|
|
105
|
+
# Assigning enabled_rules after construction bypasses it, leaving a raw
|
|
106
|
+
# string that get_rules_for_chart() rejects.
|
|
107
|
+
if rules is None:
|
|
108
|
+
config = SignalConfig()
|
|
109
|
+
elif isinstance(rules, RuleSet):
|
|
110
|
+
config = SignalConfig(enabled_rules=rules.get_rules())
|
|
111
|
+
else:
|
|
112
|
+
config = SignalConfig(enabled_rules=rules)
|
|
113
|
+
|
|
114
|
+
for key, value in kwargs.items():
|
|
115
|
+
if hasattr(config, key):
|
|
116
|
+
setattr(config, key, value)
|
|
117
|
+
|
|
118
|
+
return config
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _detect_for_chart(
|
|
122
|
+
detector: Any, result: AnalysisResult, chart_name: str, config: Any
|
|
123
|
+
) -> SignalResult:
|
|
124
|
+
"""Run signal detection on a single chart from `result`.
|
|
125
|
+
|
|
126
|
+
Reads `value_col` and `chart_type` from the chart's metadata
|
|
127
|
+
(required) and dispatches to the detector. Raises
|
|
128
|
+
`ProcessBehaviorError` if the chart is missing metadata.
|
|
129
|
+
"""
|
|
130
|
+
chart_info = result.charts[chart_name]
|
|
131
|
+
|
|
132
|
+
metadata = chart_info.get("metadata")
|
|
133
|
+
if metadata is None:
|
|
134
|
+
raise ProcessBehaviorError(
|
|
135
|
+
f"Chart '{chart_name}' missing metadata. "
|
|
136
|
+
"This indicates a bug in chart calculation."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
value_col = metadata["value_col"]
|
|
140
|
+
chart_type = metadata.get("chart_type") or extract_chart_type(chart_name)
|
|
141
|
+
|
|
142
|
+
return detector.detect(
|
|
143
|
+
data=chart_info["data"],
|
|
144
|
+
stats=chart_info["statistics"],
|
|
145
|
+
config=config,
|
|
146
|
+
value_col=value_col,
|
|
147
|
+
chart_name=chart_name,
|
|
148
|
+
chart_type=chart_type,
|
|
149
|
+
)
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Tabular views of an `AnalysisResult`.
|
|
2
|
+
|
|
3
|
+
Pure functions that produce DataFrames from a result. Extracted from
|
|
4
|
+
`AnalysisResult.chart_table` and `AnalysisResult.get_signals` so the
|
|
5
|
+
tabular-formatting logic is independent of the result class and directly
|
|
6
|
+
unit-testable (no need to construct a full AnalysisDataSet).
|
|
7
|
+
|
|
8
|
+
The class methods on `AnalysisResult` remain in place as thin delegates
|
|
9
|
+
to the functions here — the public API is unchanged.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from .data_preparation import encode_rsg_series
|
|
20
|
+
from .exceptions import ChartNotAvailableError
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .analysis_result import AnalysisResult
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Columns that are never the chart's "value" column — they're metadata,
|
|
27
|
+
# join keys, or limits. Used by `_resolve_value_col` when the response
|
|
28
|
+
# variable isn't directly identifiable in chart_data.
|
|
29
|
+
_NON_VALUE_COLS = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"rsg",
|
|
32
|
+
"center",
|
|
33
|
+
"lpl",
|
|
34
|
+
"upl",
|
|
35
|
+
"beyond_limits",
|
|
36
|
+
"n",
|
|
37
|
+
"N",
|
|
38
|
+
"obs_id",
|
|
39
|
+
"x",
|
|
40
|
+
"pull",
|
|
41
|
+
"time",
|
|
42
|
+
"date",
|
|
43
|
+
"datetime",
|
|
44
|
+
"rsg_key",
|
|
45
|
+
"cell_key",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# When the response variable can't be located, prefer one of these
|
|
50
|
+
# canonical statistic columns before falling back to "any non-meta column".
|
|
51
|
+
_PREFERRED_STATISTIC_COLS = ("xbar", "s", "mr", "r")
|
|
52
|
+
|
|
53
|
+
# beyond_limits encoding → user-facing signal symbol.
|
|
54
|
+
_SIGNAL_SYMBOLS = {-1: "↓", 0: "", 1: "↑"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Public: chart_table
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_chart_table(
|
|
63
|
+
result: AnalysisResult,
|
|
64
|
+
*,
|
|
65
|
+
chart: str | None = None,
|
|
66
|
+
include_signal_col: bool = True,
|
|
67
|
+
signal_symbols: bool = True,
|
|
68
|
+
) -> pd.DataFrame:
|
|
69
|
+
"""Build the summary DataFrame for one chart on `result`.
|
|
70
|
+
|
|
71
|
+
Identifies the value column, joins the per-cell `n` from the analysis
|
|
72
|
+
dataset if available, selects logical output columns, and optionally
|
|
73
|
+
converts `beyond_limits` into ↓/↑ symbols.
|
|
74
|
+
|
|
75
|
+
Parameters mirror `AnalysisResult.chart_table` exactly. See that
|
|
76
|
+
docstring for the column contract.
|
|
77
|
+
"""
|
|
78
|
+
chart = _resolve_chart(result, chart)
|
|
79
|
+
chart_data = result.charts[chart]["data"].copy()
|
|
80
|
+
|
|
81
|
+
value_col = _resolve_value_col(result, chart_data)
|
|
82
|
+
chart_data = _join_n_column(result, chart_data)
|
|
83
|
+
|
|
84
|
+
output_cols, col_renames = _output_column_plan(
|
|
85
|
+
chart_data, value_col, include_signal_col, chart=chart
|
|
86
|
+
)
|
|
87
|
+
table = chart_data[output_cols].copy().rename(columns=col_renames)
|
|
88
|
+
|
|
89
|
+
if include_signal_col and "signal" in table.columns and signal_symbols:
|
|
90
|
+
table["signal"] = table["signal"].map(_SIGNAL_SYMBOLS)
|
|
91
|
+
|
|
92
|
+
# 1-based position index so the table's leading column matches the chart's plotted
|
|
93
|
+
# x-axis (which starts at 1) rather than a 0-based row counter.
|
|
94
|
+
table = table.reset_index(drop=True)
|
|
95
|
+
table.index = table.index + 1
|
|
96
|
+
return table
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Public: signals_table
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_signals_table(
|
|
105
|
+
result: AnalysisResult,
|
|
106
|
+
*,
|
|
107
|
+
chart: str | None = None,
|
|
108
|
+
) -> pd.DataFrame:
|
|
109
|
+
"""Rows from `chart` (or all charts) where `beyond_limits != 0`.
|
|
110
|
+
|
|
111
|
+
When `chart` is None, the returned frame has a `chart` column
|
|
112
|
+
identifying which chart each signal row came from.
|
|
113
|
+
"""
|
|
114
|
+
if chart:
|
|
115
|
+
data = result.get_chart(chart)
|
|
116
|
+
if "beyond_limits" in data.columns:
|
|
117
|
+
return data[data["beyond_limits"] != 0]
|
|
118
|
+
return pd.DataFrame()
|
|
119
|
+
|
|
120
|
+
rows: list[pd.DataFrame] = []
|
|
121
|
+
for name, data, _ in result.iter_charts():
|
|
122
|
+
if "beyond_limits" not in data.columns:
|
|
123
|
+
continue
|
|
124
|
+
signals = data[data["beyond_limits"] != 0].copy()
|
|
125
|
+
signals["chart"] = name
|
|
126
|
+
rows.append(signals)
|
|
127
|
+
return pd.concat(rows, ignore_index=True) if rows else pd.DataFrame()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Internal helpers
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_chart(result: AnalysisResult, chart: str | None) -> str:
|
|
136
|
+
"""Resolve `chart=None` to the first chart and validate existence."""
|
|
137
|
+
resolved = result.all_charts[0] if chart is None else result._resolve_chart_name(chart)
|
|
138
|
+
if resolved not in result.charts:
|
|
139
|
+
raise ChartNotAvailableError(
|
|
140
|
+
f"Chart '{resolved}' not found. Available charts: {result.all_charts}",
|
|
141
|
+
chart=resolved,
|
|
142
|
+
available=result.all_charts,
|
|
143
|
+
)
|
|
144
|
+
return resolved
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _resolve_value_col(result: AnalysisResult, chart_data: pd.DataFrame) -> str | None:
|
|
148
|
+
"""Pick the chart's value column.
|
|
149
|
+
|
|
150
|
+
Preference order:
|
|
151
|
+
1. The analysis spec's response variable, if present in chart_data.
|
|
152
|
+
2. A known statistic column (`xbar`, `s`, `mr`, `r`).
|
|
153
|
+
3. The first non-metadata column.
|
|
154
|
+
Returns None if nothing fits (degenerate chart).
|
|
155
|
+
"""
|
|
156
|
+
if result._ads is not None:
|
|
157
|
+
response_var = result._ads.spec.response_var
|
|
158
|
+
if response_var in chart_data.columns:
|
|
159
|
+
return response_var
|
|
160
|
+
|
|
161
|
+
meta_cols = set(_NON_VALUE_COLS)
|
|
162
|
+
if result._ads is not None and result._ads.spec.time_var:
|
|
163
|
+
meta_cols.add(result._ads.spec.time_var)
|
|
164
|
+
|
|
165
|
+
value_cols = [c for c in chart_data.columns if c not in meta_cols]
|
|
166
|
+
for preferred in _PREFERRED_STATISTIC_COLS:
|
|
167
|
+
if preferred in value_cols:
|
|
168
|
+
return preferred
|
|
169
|
+
return value_cols[0] if value_cols else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _join_n_column(result: AnalysisResult, chart_data: pd.DataFrame) -> pd.DataFrame:
|
|
173
|
+
"""Merge per-cell subgroup size `n` from the analysis dataset.
|
|
174
|
+
|
|
175
|
+
Returns `chart_data` unchanged when:
|
|
176
|
+
- `n` already in chart_data
|
|
177
|
+
- no analysis dataset available
|
|
178
|
+
- `n` not in analysis dataset
|
|
179
|
+
- no matching kt columns (factor × time grain)
|
|
180
|
+
|
|
181
|
+
The join keys are coerced to string on both sides when dtypes differ
|
|
182
|
+
(chart construction sometimes stringifies by-columns while the
|
|
183
|
+
underlying ads keeps them numeric). Merge failures are swallowed —
|
|
184
|
+
a missing `n` column is preferable to a broken table.
|
|
185
|
+
"""
|
|
186
|
+
if "n" in chart_data.columns or result._ads is None:
|
|
187
|
+
return chart_data
|
|
188
|
+
|
|
189
|
+
ads = result._ads.analysis_dataset
|
|
190
|
+
spec = result._ads.spec
|
|
191
|
+
if "n" not in ads.columns:
|
|
192
|
+
return chart_data
|
|
193
|
+
|
|
194
|
+
kt_cols: list[str] = []
|
|
195
|
+
if spec.rsg_var_name and spec.rsg_var_name in ads.columns:
|
|
196
|
+
rsg_col = "rsg" if "rsg" in chart_data.columns else spec.rsg_var_name
|
|
197
|
+
if rsg_col in chart_data.columns:
|
|
198
|
+
kt_cols.append(spec.rsg_var_name)
|
|
199
|
+
if spec.has_time and spec.time_var in ads.columns and spec.time_var in chart_data.columns:
|
|
200
|
+
kt_cols.append(spec.time_var)
|
|
201
|
+
if not kt_cols:
|
|
202
|
+
# Xbar/S identify a subgroup with a single composite "group" column
|
|
203
|
+
# (rsg and time already joined, e.g. 'F1_1_F2_1_3') rather than the
|
|
204
|
+
# separate key columns X/mR carry. Rebuild the same composite on the
|
|
205
|
+
# ads side and join on that — otherwise `n` silently never lands on
|
|
206
|
+
# exactly the charts whose limits vary with it.
|
|
207
|
+
return _join_n_on_composite_group(ads, spec, chart_data)
|
|
208
|
+
|
|
209
|
+
n_per_kt = ads.groupby(kt_cols, observed=True)["n"].first().reset_index()
|
|
210
|
+
|
|
211
|
+
# Dtype-coerce join keys to string if either side disagrees.
|
|
212
|
+
mismatched = any(chart_data[c].dtype != n_per_kt[c].dtype for c in kt_cols)
|
|
213
|
+
if mismatched:
|
|
214
|
+
for c in kt_cols:
|
|
215
|
+
chart_data[c] = chart_data[c].astype(str)
|
|
216
|
+
n_per_kt[c] = n_per_kt[c].astype(str)
|
|
217
|
+
|
|
218
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
219
|
+
chart_data = chart_data.merge(n_per_kt, on=kt_cols, how="left")
|
|
220
|
+
return chart_data
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _join_n_on_composite_group(ads: pd.DataFrame, spec, chart_data: pd.DataFrame) -> pd.DataFrame:
|
|
224
|
+
"""Merge `n` onto a chart keyed by a single composite subgroup column.
|
|
225
|
+
|
|
226
|
+
Xbar/S label each subgroup with the encoded (rsg, time) cell — the same
|
|
227
|
+
encoding :func:`encode_rsg_series` produces — so the join key is rebuilt
|
|
228
|
+
rather than matched column-by-column. Returns `chart_data` unchanged if
|
|
229
|
+
the composite cannot be formed or does not match.
|
|
230
|
+
"""
|
|
231
|
+
if "group" not in chart_data.columns:
|
|
232
|
+
return chart_data
|
|
233
|
+
|
|
234
|
+
key_cols = [c for c in (spec.rsg_var_name, spec.time_var) if c and c in ads.columns]
|
|
235
|
+
if len(key_cols) < 2:
|
|
236
|
+
return chart_data
|
|
237
|
+
|
|
238
|
+
n_per_cell = ads.groupby(key_cols, observed=True)["n"].first().reset_index()
|
|
239
|
+
n_per_cell["group"] = encode_rsg_series(n_per_cell, key_cols)
|
|
240
|
+
n_per_cell = n_per_cell[["group", "n"]]
|
|
241
|
+
|
|
242
|
+
chart_data = chart_data.copy()
|
|
243
|
+
chart_data["group"] = chart_data["group"].astype(str)
|
|
244
|
+
|
|
245
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
246
|
+
merged = chart_data.merge(n_per_cell, on="group", how="left")
|
|
247
|
+
# Only adopt the merge if it actually matched; a delimiter collision
|
|
248
|
+
# would otherwise replace a missing column with a column of NaN.
|
|
249
|
+
if merged["n"].notna().any():
|
|
250
|
+
return merged
|
|
251
|
+
return chart_data
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _output_column_plan(
|
|
255
|
+
chart_data: pd.DataFrame,
|
|
256
|
+
value_col: str | None,
|
|
257
|
+
include_signal_col: bool,
|
|
258
|
+
chart: str | None = None,
|
|
259
|
+
) -> tuple[list[str], dict[str, str]]:
|
|
260
|
+
"""Decide which columns to keep and how to rename them for display."""
|
|
261
|
+
output_cols: list[str] = []
|
|
262
|
+
renames: dict[str, str] = {}
|
|
263
|
+
|
|
264
|
+
# Source-row id ("Obs") — Individuals charts only, where each row maps 1:1 to a
|
|
265
|
+
# single observation, so it traces back to the source data. Xbar/S rows are
|
|
266
|
+
# subgroup aggregates (identified by "subgroup"), so obs_id is not 1:1 there.
|
|
267
|
+
if chart in ("X", "mR") and "obs_id" in chart_data.columns:
|
|
268
|
+
output_cols.append("obs_id")
|
|
269
|
+
renames["obs_id"] = "Obs"
|
|
270
|
+
|
|
271
|
+
# The subgroup identifier is "rsg" on X/mR charts but "group" on Xbar/S.
|
|
272
|
+
# Only "rsg" was recognised, so chart_table('Xbar') silently dropped the
|
|
273
|
+
# subgroup column (and "n" with it) that its own docstring promises.
|
|
274
|
+
subgroup_col = next(
|
|
275
|
+
(col for col in ("rsg", "group") if col in chart_data.columns), None
|
|
276
|
+
)
|
|
277
|
+
if subgroup_col:
|
|
278
|
+
output_cols.append(subgroup_col)
|
|
279
|
+
renames[subgroup_col] = "subgroup"
|
|
280
|
+
|
|
281
|
+
if "n" in chart_data.columns:
|
|
282
|
+
output_cols.append("n")
|
|
283
|
+
|
|
284
|
+
if value_col:
|
|
285
|
+
output_cols.append(value_col)
|
|
286
|
+
renames[value_col] = "value"
|
|
287
|
+
|
|
288
|
+
for col in ("center", "lpl", "upl"):
|
|
289
|
+
if col in chart_data.columns:
|
|
290
|
+
output_cols.append(col)
|
|
291
|
+
|
|
292
|
+
if include_signal_col and "beyond_limits" in chart_data.columns:
|
|
293
|
+
output_cols.append("beyond_limits")
|
|
294
|
+
renames["beyond_limits"] = "signal"
|
|
295
|
+
|
|
296
|
+
return output_cols, renames
|