diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd8d52..edd254d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 single stream, ADS 2, X/mR, sigma from N − 1 moving ranges. An NA response keeps its place as an empty cell. The design report adds `Time: received order (no time column given)`. If the data already has an `Observation` column, formulate asks you to pass it as `time=` or rename it (#129). +- **Phased limits on Xbar and S charts.** `execute(chart='Xbar', phased=True)` gives each factor + combination its own centre line and limits on one chart, drawn as stepped lines with a divider at + each change, the way phased X/mR already does for its lanes. Each phase is computed as a separate + Xbar/S analysis, exactly as `by=[time]` computes one chart per condition: the centre is the mean of + the phase's subgroup means, with its own S-bar and sigma-hat (10-12 manual Eqs 11-5, 11-8, 11-9, + 11-16, 11-17). `by=['stage', time]` pools the other factors into the subgroup and phases on the + stage; the centre is then the mean of the pooled subgroup averages, as Dr. Bishop specified + (2026-10-18). On Wheeler's four-cavity thickness data the per-cavity centres and limits match + Minitab's staged Xbar-S chart. Residual values, calibrations and `by=[time]` are refused with + messages that say what to use instead; a phase with no replicated subgroup keeps its points without + limits and is named in `metadata['insufficient_phases']`. (#144) +- **`result.phase_statistics(chart)`.** One row per phase of a phased Xbar/S chart: `phase`, + `subgroups`, `N`, `center`, `s_bar`, `sigma_hat`, `lpl`, `upl`. (#144) ### Changed - **`formulate(response=..., factors=[])` with no time is the same study.** It used to treat the @@ -23,6 +36,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Xbar and S chart data carry `n`.** `get_chart('Xbar')` / `get_chart('S')`, and the Excel chart sheets, gain an `n` column after the subgroup label. Unstratified Xbar/S metadata names the label column as `group_col` (#143). +- **Phased Xbar/S chart data and `chart_table` carry a `phase` column** after the subgroup label, and + the metadata carries `phase_by`, `lane_boundaries`, `single_point_phases` and `insufficient_phases`. + `n_avg` is not reported on a phased chart, whose `n_mode='average'` is applied per phase; the + per-phase mean size is `phase_statistics(chart)['N']`. (#144) +- **Lane dividers on an Xbar or S chart are labelled with the phase.** These charts have no time + column and so no cell band under the axis, which is where X charts name their lanes. (#144) ### Fixed - **Xbar/S `chart_table` keeps the subgroup label and `n` on every `by=`.** With a `by=` other than @@ -33,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (R1, R3, R4, R5) in a design with singletons: the stream's first cell, where R2 is undefined at the first value, showed its cell size, not the one-fewer values its S and limits use. Limits, centres and statistics are unchanged (#143). +- **`execute()` no longer documents `phased=True` as requiring `by=[]`.** Stratified phasing of X/mR + (`by=['factor 1'], phased=True`) has been accepted since 75bcdd1; the docstring never caught up. (#144) ## [0.4.0] - 2026-10-14 diff --git a/docs/reference/api.md b/docs/reference/api.md index 78456c3..06ababb 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -113,7 +113,7 @@ result = study.execute( recentered=False, # bool — recentered residual (RCR*) form bins=None, # int | None — Histogram bins companion=False, # bool — also compute the companion chart (see below) - phased=False, # bool — phase-wise limits + phased=False, # bool — a center line and limits per phase (X/mR and Xbar/S) n_sigma=3.0, # float — limit width n_mode='actual', # 'actual' | 'average' — subgroup-size handling calibration=None, # Calibration | str | None — standards-given limits @@ -276,7 +276,8 @@ Every chart's statistics dict carries exactly these keys: When limits vary point-to-point (e.g. varying subgroup sizes with `n_mode='actual'`), `N`, `lpl` and `upl` are `None` and the dict additionally has -`'limits_vary': True` — read per-point limits from `get_chart()`. Histogram adds +`'limits_vary': True` — read per-point limits from `get_chart()`. On a phased chart +`center` is `None` too. Histogram adds `{'mean', 'std', 'n'}` and has `lpl`/`upl` of `None`. For a stratified result, `get_statistics()` returns `{stratum: {...}}`; `statistics(chart, stratum=...)` picks one. @@ -326,6 +327,27 @@ result.focus(stratum) # -> FocusedAnalysisResult `False`. (`FocusedAnalysisResult` is created by `focus()`, not constructed directly, and is not a top-level export.) +### Phases + +```python +result.phase_statistics(chart) # -> DataFrame, one row per phase (phased Xbar/S) +``` + +A chart executed with `phased=True` gives each phase — one combination of the grouping +factors, a contiguous run of subgroups in time order — its own center line and limits. +On Xbar/S the chart data gains a `phase` column (the lane label) next to the subgroup +label, and `chart_table` shows it. `phase_statistics(chart)` returns the numbers behind +the stepped lines: `phase`, `subgroups`, `N` (the subgroup size the phase's limits assume; +`NaN` when sizes vary within it), `center`, `s_bar`, `sigma_hat`, `lpl`, `upl` (`NaN` when +the limits step within the phase). It raises `ValidationError` on a chart that is not +phased, and on phased X/mR, whose per-phase values are on each row of `get_chart()`. + +Phased metadata carries `phased=True`, `run_rules_applicable=False`, `phase_by` (the +factors a phase is one combination of), `lane_boundaries` (the divider positions), +`single_point_phases` and `insufficient_phases` (phases with no replicated subgroup, +charted without limits). Phased Xbar/S refuses residual values, calibrations and +`by=[time]`; see [Staged Xbar/S](../user-guide/chart-types.md#staged-xbars). + ### Plotting, reporting, export ```python diff --git a/docs/user-guide/chart-types.md b/docs/user-guide/chart-types.md index d4cea28..4901d7f 100644 --- a/docs/user-guide/chart-types.md +++ b/docs/user-guide/chart-types.md @@ -240,6 +240,51 @@ Charted](residuals.md#how-effect-residuals-are-charted). 2. **Then interpret Xbar** - Only meaningful if S is stable 3. **Investigate signals** - What makes that subgroup different? +### Staged Xbar/S + +A staged study records the stage as a factor, and the default Xbar chart already lays the +subgroups out stage by stage: every subgroup of stage A, then every subgroup of stage B, +each in time order. `phased=True` gives each stage its own center line and limits on that +one chart, drawn as stepped lines with a divider at each stage change. + +```python +study = pb.formulate(df, response='Thickness', time='Hour', factors=['Cavity']) +staged = study.execute(chart='Xbar', phased=True, companion=True) +staged.plot(chart='Xbar') +staged.phase_statistics('Xbar') # one row per stage: center, S-bar, sigma-hat, limits +``` + +Each stage is computed as a separate analysis, exactly as `by=['Hour']` computes one chart +per stage: its center is the unweighted mean of its subgroup means, its S̄ is the mean of its +subgroup standard deviations, and its σ̂ and limits follow from those (10-12 manual Eqs 11-5, +11-8, 11-9, 11-16, 11-17). Nothing is pooled across stages. On Wheeler's four-cavity +thickness data the per-cavity centers and limits match Minitab's staged Xbar-S chart to the +digit; the per-cavity charts of `by=['Hour']` give the same numbers, one chart at a time. + +With more factors than the stage, `by=['Cavity', 'Hour']` pools the others into the +subgroup and phases on `Cavity`. The center is then the mean of the pooled subgroup +averages, per Dr. Bishop: ignore the other factor completely, group by stage and time, and +chart per the usual rules. That equals charting a study formulated without the pooled +factors. + +What the phased chart reports: + +- `get_chart('Xbar')` has a `phase` column and each row's own `center`, `lpl` and `upl`; + `chart_table` shows `phase` after `subgroup`. +- `get_statistics` returns `None` for `center`, `N`, `lpl` and `upl` with + `limits_vary=True`, since no single value describes the chart; `phase_statistics(chart)` + carries the per-stage numbers. +- `detect_signals` applies Rule 1 against each stage's own limits; the run rules are off, + as on phased X/mR, because the zones differ from stage to stage. +- A stage with a single replicated subgroup takes its σ̂ from that subgroup. A stage with + no replicated subgroup keeps its points but has no limits, and is named in + `metadata['insufficient_phases']`. + +Stages are ordered as the factor's levels sort, so label them to sort (`1 before`, +`2 after`, or an ordered pandas Categorical). `by=[time]` with `phased=True` is refused: +that view is already one chart per stage. Residual values and calibrations are refused on +phased Xbar/S. + ## Understanding X and mR Charts ### The X (Individual) Chart @@ -314,6 +359,10 @@ phased = study.execute(chart='X', by=[], phased=True, companion=True) phased.plot(chart='X') ``` +The same view exists for subgrouped data: `study.execute(chart='Xbar', phased=True, +companion=True)` gives each factor combination its own center and limits on one Xbar/S +chart (see [Staged Xbar/S](#staged-xbars)). + **3. One subgroup at a time.** Full stratification: one chart per subgroup, each with its own limits. @@ -350,6 +399,7 @@ only the lane order, and reports which results move. | "This transition is a signal" | `chart='mR', by=[]` | No. Which transitions are flagged depends on which lanes are adjacent. | | "Subgroup *k* differs in level" | `chart='Xbar', by=['lane'], value='R5', recentered=True` | The points, yes: each is a subgroup mean. The limits, not entirely in DS 2 and 3: they are set from R2, which differences the condition-and-period-adjusted series along the same lane-major sequence. The level difference between lanes no longer enters R2, but which observations are paired still depends on lane order. In DS 1, R2 is within-cell and carries no order. | | "Subgroup *k* is stable on its own" | `phased=True`, or `by=['lane']` | Yes. Only that subgroup's own ranges enter. | +| "Stage *k* is stable on its own" (subgrouped data) | `chart='Xbar', phased=True` | Yes. Only that stage's own subgroups enter its center, S̄ and limits. | So a boundary signal should be read as evidence along the path, and claims about the subgroups themselves should come from the charts that have no path: the R5 chart for @@ -401,6 +451,7 @@ VAS residuals (R1–R6) need both factors and time. |----------|-------|----------------| | Is this one process? | `chart='X', by=[], companion=True` | Subgroups do not join into one stable stream | | Each subgroup on its own? | `chart='X', by=[], phased=True` | Special cause within that subgroup | +| Each stage on its own, with subgroups? | `chart='Xbar', phased=True, companion=True` | Special cause within that stage | | Are groups different? | `chart='Xbar'` | Group deviates from average | | Is variation stable? | `chart='S'` | Group has unusual variation | | Process over time? | `chart='X', by=[...]` | Special cause detected | diff --git a/processbehavior/analysis.py b/processbehavior/analysis.py index f229664..c8a22b8 100644 --- a/processbehavior/analysis.py +++ b/processbehavior/analysis.py @@ -42,6 +42,7 @@ from .residual_calculator import calculate_r6_residuals, resolve_r6_groupby from .spc_constants import ( REQUEST_RESIDUALS, + c4, calculate_limits, calculate_limits_vectorized, calibrated_limits, @@ -705,7 +706,7 @@ def calibration_rejection_reason(self) -> str | None: """The path description a calibration would be refused for, or None if it applies. Calibration is applied on global and grouped (single-chart) Xbar/S/X/mR paths. - Stratified paths and phased X/mR are a documented follow-up. + Stratified paths and phased charts (X/mR and Xbar/S) are a documented follow-up. This is the predicate behind :meth:`Study.supports_calibration`. It asks the same two resolvers the chart code asks — :meth:`_resolve_by_grouping` for @@ -724,7 +725,11 @@ def calibration_rejection_reason(self) -> str | None: if chart_type in ('Xbar', 'S'): _, _, stratify_by = self._resolve_by_grouping(value_col) - return f'stratified {chart_type}' if stratify_by else None + if stratify_by: + return f'stratified {chart_type}' + if self.request.phased: + return f'phased {chart_type}' + return None if chart_type in ('X', 'mR'): stratify_by, collapsed_factors = self._resolve_xmr_stratification() @@ -1361,6 +1366,9 @@ def _calculate_xbar( # noqa: C901 # Resolve grouping based on `by` parameter groupby_cols, ybar_col, stratify_by = self._resolve_by_grouping(value_col) + if self.request.phased: + return {'Xbar': self._calculate_xbar_s_phased(value_col)['Xbar']} + # Stratified path: separate chart per factor combination if stratify_by: self._reject_calibration('stratified Xbar') @@ -1821,6 +1829,9 @@ def _calculate_s( # noqa: C901 # Resolve grouping based on `by` parameter groupby_cols, _, stratify_by = self._resolve_by_grouping(value_col) + if self.request.phased: + return {'S': self._calculate_xbar_s_phased(value_col)['S']} + # Stratified path: separate chart per factor combination if stratify_by: self._reject_calibration('stratified S') @@ -1942,6 +1953,9 @@ def _calculate_xbar_s(self, value_col: str | None = None) -> dict[str, ChartPayl dict Combined chart data: {'Xbar': {...}, 'S': {...}} """ + if self.request.phased: + return self._calculate_xbar_s_phased(value_col) + # Calculate Xbar with intermediates for S reuse xbar_result = self._calculate_xbar(value_col, _return_intermediates=True) @@ -1964,6 +1978,158 @@ def _calculate_xbar_s(self, value_col: str | None = None) -> dict[str, ChartPayl # Combine results return {**xbar_result, **s_result} + def _calculate_xbar_s_phased(self, value_col: str | None = None) -> dict[str, ChartPayload]: + """Xbar and S with a centre line and limits per phase (issue #144). + + A phase is one combination of the grouping factors: a contiguous run of subgroups in + time order on the stage-by-stage layout that ``by=None`` already draws. Each phase is + computed as the per-condition chart of ``by=[time]`` computes one stratum — the + unweighted mean of its subgroup means as centre (10-12 manual Eq 10-9 within the + phase), its own S-bar and sigma-hat (Eq 11-5), its own limits (Eqs 11-8, 11-9, 11-16, + 11-17) — and the phases are concatenated in display order with the stepped columns. + Nothing is pooled across phases: Dr. Bishop (2026-10-18), "treat each stage separately + as if it were a single analysis". + + With factors besides the phase factors pooled into the subgroup (``by=['stage', time]`` + in a study with more factors) the subgroups are the pooled ones and the centre is the + mean of their averages, per Bishop: "ignore the [other factor] completely and simply + group the data by [stage] and Time". That equals re-formulating the study without the + pooled factors and charting its per-condition charts. + + Solo Xbar, solo S and the companion pair all come through here, so they agree by + construction. Residual values are refused in validation; a calibration here. + """ + spec = self.spec + if value_col is None: + value_col = spec.response_var + self._reject_calibration(f'phased {self.analysis_type}') + + df = self._source_frame().copy() + groupby_cols, _, _ = self._resolve_by_grouping(value_col) + time_var = spec.time_var + phase_by = [c for c in groupby_cols if c != time_var] + limits_col = self._resolve_limits_column(value_col, df) + + # The subgroup means are computed from the data, as the per-condition charts do, not + # read from the cached cell means: a pooled subgroup has no cached mean anyway. + out = self._aggregate_subgroups(df, groupby_cols, value_col, limits_col) + out = out[out['n'].ge(1)].reset_index(drop=True) + lane_boundaries = self._calculate_lane_boundaries(out, phase_by) + labels = _lane_labels(out, phase_by) + starts = [0] + [b['position'] for b in lane_boundaries] + ends = starts[1:] + [len(out)] + + xbar_frames: list[pd.DataFrame] = [] + s_frames: list[pd.DataFrame] = [] + xbar_rows: list[dict] = [] + s_rows: list[dict] = [] + insufficient: list[str] = [] + single_point = 0 + for start, end in zip(starts, ends, strict=True): + label = str(labels[start]) + segment = out.iloc[start:end].copy() + segment['N'] = segment['n'].max() # this phase's own N, as a stratum's would be + center = segment['xbar'].mean() + group = self._fit_xbar_group(segment) + if group is None: + # No replicated subgroup: the points stay, the limits are NaN (so nothing is + # flagged) and the phase is named in the metadata. + segment[['center', 'lpl', 'upl']] = np.nan + segment['beyond_limits'] = 0 + segment['phase'] = label + xbar_frames.append(segment.round(spec.round_to)) + insufficient.append(label) + xbar_rows.append({'phase': label, 'subgroups': len(segment)}) + continue + + xbar_seg = self._apply_xbar_limits(group.out, center, group) + xbar_seg['phase'] = label + xbar_frames.append(xbar_seg) + single_point += int(len(xbar_seg) == 1) + + # The S chart works from the rounded Xbar slice with n >= 2, as the stratified + # companion path does, so the two S charts agree to the last digit. + s_seg = xbar_seg[xbar_seg['n'].ge(2)].drop(columns=['s_value'], errors='ignore').copy() + s_seg = self._apply_s_limits(s_seg, group.s_bar, group.s_n_to_use) + s_seg['phase'] = label + s_frames.append(s_seg) + + n_used = group.out['N'].iloc[0] # n_avg under n_mode='average', else the phase's N + sigma_hat = ( + subgroup_sigma_hat(group.out.loc[group.fit, 's'], group.out.loc[group.fit, 'n']) + if group.n_to_use == 'n' + else group.s_bar / c4(int(n_used)) + ) + common = {'center': center, 's_bar': group.s_bar, 'sigma_hat': sigma_hat} + xbar_rows.append( + { + 'phase': label, + 'subgroups': len(xbar_seg), + **self._phase_limits(xbar_seg, group.n_to_use, group.n_avg), + **common, + } + ) + s_rows.append( + { + 'phase': label, + 'subgroups': len(s_seg), + **self._phase_limits(s_seg, group.s_n_to_use, group.n_avg), + **{**common, 'center': group.s_bar}, + } + ) + + if not s_frames: + sds = self.ads._ads_result.sds if self.ads._ads_result else '?' + _raise_no_replicated_subgroups(self.analysis_type, sds) + + xbar_out = pd.concat(xbar_frames, ignore_index=True) + xbar_out, group_col = self._subgroup_label_column(xbar_out, groupby_cols) + s_out = pd.concat(s_frames, ignore_index=True) + s_out, _ = self._subgroup_label_column(s_out, groupby_cols) + s_lane_boundaries = self._calculate_lane_boundaries(s_out, ['phase']) + + phase_cols = ['phase', 'subgroups', 'N', 'center', 's_bar', 'sigma_hat', 'lpl', 'upl'] + statistics = {'N': None, 'center': None, 'lpl': None, 'upl': None, 'limits_vary': True} + + def payload(chart: str, value_name: str, frame: pd.DataFrame, lanes: list[dict], rows: list[dict]): + cols = [group_col, 'phase', 'n', value_name, 'center', 'lpl', 'upl', 'beyond_limits'] + phases = pd.DataFrame(rows, columns=phase_cols).round(spec.round_to) + return { + 'data': frame[[c for c in cols if c in frame.columns]], + 'statistics': dict(statistics), + 'metadata': { + 'chart_type': chart, + 'value_col': value_name, + 'center_col': 'center', + 'n_sigma': self.request.n_sigma, + 'n_mode': self.request.n_mode, + **({'group_col': group_col} if group_col else {}), + 'phased': True, + 'run_rules_applicable': False, + 'phase_by': list(phase_by), + 'lane_boundaries': lanes or None, + 'single_point_phases': single_point, + 'insufficient_phases': insufficient or None, + }, + 'phases': phases, + } + + return { + 'Xbar': payload('Xbar', 'xbar', xbar_out, lane_boundaries, xbar_rows), + 'S': payload('S', 's', s_out, s_lane_boundaries, s_rows), + } + + @staticmethod + def _phase_limits(frame: pd.DataFrame, n_to_use: str, n_avg: float | None) -> dict: + """The N, lpl and upl a phase reports: scalars when its limits are one pair, NaN when they step.""" + if n_to_use != 'N': + return {'N': np.nan, 'lpl': np.nan, 'upl': np.nan} + return { + 'N': n_avg if n_avg is not None else frame['N'].iloc[0], + 'lpl': frame['lpl'].iloc[0], + 'upl': frame['upl'].iloc[0], + } + @staticmethod def _resolve_mr_source_column(value_col: str) -> str: """Map recentered column (RCR*) to its non-recentered residual (R*). diff --git a/processbehavior/analysis_result.py b/processbehavior/analysis_result.py index d0d7267..3a3f2b8 100644 --- a/processbehavior/analysis_result.py +++ b/processbehavior/analysis_result.py @@ -656,6 +656,43 @@ def statistics(self, chart: str, stratum: str | None = None) -> dict: ) return dict(stats[stratum]) + def phase_statistics(self, chart: str) -> pd.DataFrame: + """One row per phase of a phased chart: the numbers behind its stepped lines. + + Columns: ``phase`` (the lane label, as in ``chart_table``), ``subgroups`` (points + charted in it), ``N`` (the subgroup size its limits assume: the common size, the mean + size under ``n_mode='average'``, or NaN when sizes vary within the phase), ``center``, + ``s_bar``, ``sigma_hat``, ``lpl`` and ``upl`` (NaN when the limits step within the + phase; ``get_chart`` carries them per row). A phase with no replicated subgroup keeps + its ``subgroups`` and is NaN elsewhere; ``metadata['insufficient_phases']`` names it. + + Raises + ------ + ValidationError + On a chart that is not phased, and on phased X/mR, whose per-phase values are on + each row of ``get_chart`` (a later release adds this table there; issue #144). + ChartNotAvailableError + If the chart is not in this result. + """ + name = self._resolve_chart_name(chart) + if name not in self.charts: + raise ChartNotAvailableError( + f"Chart '{name}' not found. Available charts: {self.all_charts}", chart=name, available=self.all_charts + ) + payload = self.charts[name] + if not payload.get('metadata', {}).get('phased'): + raise ValidationError( + f'Chart {chart!r} is not phased, so it has no per-phase statistics. ' + 'execute(..., phased=True) gives each phase its own centre line and limits.' + ) + phases = payload.get('phases') + if phases is None: + raise ValidationError( + 'phase_statistics is available on phased Xbar and S charts; a phased X or mR chart ' + f"carries each phase's centre and limits on its rows (get_chart({chart!r}))." + ) + return phases.copy() + def get_residual(self, residual_type: str) -> pd.Series: """ Get a specific residual series. diff --git a/processbehavior/exceptions.py b/processbehavior/exceptions.py index a1238e0..b16f996 100644 --- a/processbehavior/exceptions.py +++ b/processbehavior/exceptions.py @@ -132,9 +132,9 @@ class CalibrationNotSupportedError(ValidationError): """A calibration was supplied on a chart path that cannot apply it. Calibration is applied on global and grouped (single-chart) Xbar/S/X/mR paths. - Stratified paths (one chart per stratum) and phased X/mR are a documented - follow-up, so a calibration passed to one of those is refused rather than - silently ignored. + Stratified paths (one chart per stratum) and phased charts (X/mR and Xbar/S) + are a documented follow-up, so a calibration passed to one of those is refused + rather than silently ignored. Ask *before* executing with :meth:`Study.supports_calibration`, which answers the same question without raising — preferable to catching this, and far @@ -146,7 +146,7 @@ class CalibrationNotSupportedError(ValidationError): Attributes ---------- context : str | None - The unsupported path, e.g. ``'stratified Xbar'`` or ``'phased X/mR'``. + The unsupported path, e.g. ``'stratified Xbar'``, ``'phased X/mR'`` or ``'phased Xbar'``. Examples -------- diff --git a/processbehavior/plotting/plotter.py b/processbehavior/plotting/plotter.py index 425ac93..7623279 100644 --- a/processbehavior/plotting/plotter.py +++ b/processbehavior/plotting/plotter.py @@ -1040,6 +1040,7 @@ def _get_x_column(self, data: pd.DataFrame) -> str | None: 'groups', 'n', 'N', + 'phase', } for col in data.columns: if col in metric_cols: diff --git a/processbehavior/plotting/renderers.py b/processbehavior/plotting/renderers.py index 54902f9..12bf9e1 100644 --- a/processbehavior/plotting/renderers.py +++ b/processbehavior/plotting/renderers.py @@ -254,7 +254,8 @@ def render_control_chart( # Single-chart mode: cell labels are rendered as a band below the # axis title by Plotter._apply_time_tick_labels, so suppress the # top-of-plot lane labels here. Faceted mode keeps top labels - # per panel. + # per panel. Phased Xbar/S has no time column and so no band: its + # divider labels are the only place the phase is named. add_lane_boundaries( fig, lane_boundaries, @@ -262,7 +263,7 @@ def render_control_chart( theme, row=row, col=col, - show_labels=ctx.is_faceted, + show_labels=ctx.is_faceted or spec.chart_type in ('Xbar', 'S'), ) diff --git a/processbehavior/result_tabular.py b/processbehavior/result_tabular.py index 44ac81d..3729fb3 100644 --- a/processbehavior/result_tabular.py +++ b/processbehavior/result_tabular.py @@ -34,6 +34,7 @@ "beyond_limits", "n", "N", + "phase", "obs_id", "x", "pull", @@ -250,6 +251,10 @@ def _output_column_plan( output_cols.append(subgroup_col) renames[subgroup_col] = "subgroup" + # Phased Xbar/S: which phase's centre and limits the row uses. + if "phase" in chart_data.columns: + output_cols.append("phase") + if "n" in chart_data.columns: output_cols.append("n") diff --git a/processbehavior/study.py b/processbehavior/study.py index 01ff909..48d460c 100644 --- a/processbehavior/study.py +++ b/processbehavior/study.py @@ -1919,16 +1919,28 @@ def execute( Not applicable to Histogram. phased : bool, default False - When True, computes per-phase center lines and control limits. - Each phase is a contiguous run of the same collapsed factor - combination (the groups demarcated by vertical lane boundaries). - Within each phase, the moving range is computed independently. - - - phased=False (default): Global limits across entire chart - - phased=True: Per-phase limits (requires by=[]) - - Single-point phases yield zero-width limits because mR is - imputed as 0; count available in metadata['single_point_phases']. + When True, each phase gets its own center line and limits, drawn + as stepped lines on one chart. A phase is a contiguous run of one + factor combination (the lanes between the vertical dividers). + + - X/mR: the collapsed factors define the phases (``by=[]`` + collapses all of them; ``by=['factor 1']`` stratifies on it and + phases on the rest). Each phase's moving range is computed on + its own. + - Xbar/S: the grouping factors define the phases. ``by=None`` (or + all factors + time) phases on every factor combination; + ``by=['stage', time]`` pools the other factors into the + subgroup and phases on ``stage``. Each phase is computed as a + separate Xbar/S analysis: its center is the mean of its + subgroup means, with its own S-bar and sigma-hat (Dr. Bishop, + 2026-10-18). ``by=[time]`` and residual values are refused. + Per-phase numbers: ``result.phase_statistics(chart)``. + + Statistics report ``center``, ``N``, ``lpl`` and ``upl`` as None + with ``limits_vary=True``; run rules are off (Rule 1 only). + Single-point phases: X/mR impute mR as 0 (zero-width limits); + Xbar/S take sigma-hat from the one subgroup's S. Count in + metadata['single_point_phases']. n_sigma : float, default 3.0 Sigma multiplier for Xbar/S chart control limits. Standard SPC @@ -2261,7 +2273,7 @@ def _validate_execute_request( # noqa: C901 if recentered: self._validate_recentered(value) if phased: - self._validate_phased(base_chart, by_validated) + self._validate_phased(base_chart, by_validated, value) if companion and base_chart == 'Histogram': raise ValidationError('companion is not applicable to Histogram charts.') @@ -2423,10 +2435,13 @@ def _validate_recentered(self, value: str | None) -> None: if value.upper() not in recenterable: raise ValidationError(f"recentered=True requires a residual value (R1-R6), got '{value}'") - def _validate_phased(self, base_chart: str, by_validated: list[str] | None) -> None: - """Validate phased=True requirements.""" + def _validate_phased(self, base_chart: str, by_validated: list[str] | None, value: str | None = None) -> None: + """Validate phased=True: X/mR phase on the collapsed factors, Xbar/S on the grouping factors.""" + if base_chart in ('Xbar', 'S'): + self._validate_phased_xbar_s(base_chart, by_validated, value) + return if base_chart not in ('X', 'mR'): - raise ValidationError(f"phased=True is only valid for X or mR charts, got '{base_chart}'.") + raise ValidationError(f"phased=True is only valid for X, mR, Xbar or S charts, got '{base_chart}'.") if by_validated is None: raise ValidationError('phased=True requires an explicit by= parameter.') if not self._spec.rsg_vars_list: @@ -2445,6 +2460,57 @@ def _validate_phased(self, base_chart: str, by_validated: list[str] | None) -> N 'Remove factors from by= or set phased=False.' ) + def _validate_phased_xbar_s(self, base_chart: str, by_validated: list[str] | None, value: str | None) -> None: + """phased=True on Xbar/S: one centre line and one set of limits per factor combination. + + A phase is a contiguous run of subgroups sharing the grouping factors, in time order, on + the stage-by-stage layout ``by=None`` already draws. So the request must chart the + response, have factors and a time variable, and group by some factors with time last; + ``by=None`` / ``[]`` phases on every factor, ``by=['stage', time]`` pools the others into + the subgroup. ``by=[time]`` is refused because that view is already one chart per + combination (issue #144). + """ + factors = self._spec.rsg_vars_list + time_var = self._spec.time_var + if value is not None and value.upper().startswith('R'): + raise ValidationError( + f'phased=True on {base_chart} charts the response; value={value!r} is not supported. ' + "Residual charts repeat the response's phases or are flat within a phase; for a phased " + f"residual use chart='X', by=[], value={value!r}, phased=True." + ) + if not factors: + raise ValidationError( + 'phased=True requires factors to define phases (rsg_key). ' + 'This study has no factors; phased limits do not apply.' + ) + if not time_var: + raise ValidationError( + f'phased=True on {base_chart} requires a time variable: a phase is one factor ' + "combination's subgroups in time order, and this study has none." + ) + if not by_validated: + return # None or []: phases are the factor combinations + by_list = list(by_validated) + if set(by_list) == set(factors) | {time_var}: + return # all factors + time, any order: the same subgroups as by=None + if by_list == [time_var]: + raise ValidationError( + f'phased=True with by={by_list} on {base_chart}: that view already gives each factor ' + 'combination its own chart, centre line and limits (result.focus(k)). For the same ' + 'limits side by side on one chart use by=None with phased=True.' + ) + if time_var not in by_list: + raise ValidationError( + f'phased=True on {base_chart} requires the time variable in by=: by={by_list} gives one ' + f'point per level with no time order, so there are no phases. Use by={by_list + [time_var]}.' + ) + if by_list[-1] != time_var: + ordered = [c for c in by_list if c != time_var] + [time_var] + raise ValidationError( + f'phased=True requires the time variable last in by= (got by={by_list}): phasing changes ' + f'the limits, not the order of the points. Use by={ordered}.' + ) + def _parse_chart_request(self, chart: str) -> str: """ Parse chart string to validate base chart type. diff --git a/processbehavior/types.py b/processbehavior/types.py index cc84d80..0325768 100644 --- a/processbehavior/types.py +++ b/processbehavior/types.py @@ -61,7 +61,9 @@ class ChartStatistics(TypedDict): When the control limits and/or subgroup size vary across subgroups (e.g. Xbar/S with ``n_mode='actual'`` on unbalanced data), ``N``, ``lpl``, and ``upl`` are ``None`` and the optional - ``limits_vary`` flag is ``True``. ``center`` remains a single scalar. + ``limits_vary`` flag is ``True``; ``center`` stays a scalar. On a phased + chart ``center`` is ``None`` too, and each row's ``center`` column + carries its phase's value. """ N: int | None @@ -95,10 +97,15 @@ class ChartMetadata(TypedDict, total=False): charts only. - ``group_col``: unstratified Xbar/S — the chart-data column that labels each subgroup (``group``, ``subgroup``, or the factor's own name). - - ``lane_boundaries``: stratified X/mR charts with collapsed factors. + - ``lane_boundaries``: X/mR charts with collapsed factors and phased + Xbar/S — a flat list of ``{'position', 'label', 'variables'}`` dicts, + or one such list per stratum on a stratified chart. - ``insufficient_strata``: mR/r charts that dropped first-per-stratum. - ``phased`` / ``single_point_phases``: charts run with ``phased=True``. + - ``phase_by`` / ``insufficient_phases``: phased Xbar/S — the factors a + phase is one combination of, and the phases with no replicated + subgroup (charted without limits). - ``run_rules_applicable``: present on charts where Western Electric rules apply (or explicitly don't). - ``limits_source`` / ``calibration``: reserved for the standards- @@ -112,10 +119,12 @@ class ChartMetadata(TypedDict, total=False): stratify_col: str | None stratify_by: list[str] group_col: str # Unstratified Xbar/S: the subgroup label column - lane_boundaries: dict[str, list[float]] | None + lane_boundaries: list[dict] | dict[str, list[dict]] | None insufficient_strata: list[str] | None phased: bool single_point_phases: int + phase_by: list[str] # Phased Xbar/S: the factors a phase is one combination of + insufficient_phases: list[str] | None # Phased Xbar/S: phases charted without limits run_rules_applicable: bool bins: int # Histogram only focused_stratum: str # After AnalysisResult.focus() @@ -134,13 +143,15 @@ class ChartPayload(TypedDict): """The dict structure stored in ``AnalysisResult.charts[name]``. Required: ``data``, ``statistics``, ``metadata``. - Optional: ``strata`` (present on stratified charts). + Optional: ``strata`` (present on stratified charts); ``phases`` (phased + Xbar/S: one row per phase, read through ``AnalysisResult.phase_statistics``). """ data: pd.DataFrame statistics: ChartStatisticsValue metadata: ChartMetadata strata: NotRequired[list[str]] + phases: NotRequired[pd.DataFrame] # Type alias for the full charts dict on AnalysisResult. diff --git a/tests/fixtures/data/wheeler_cavity_thickness.csv b/tests/fixtures/data/wheeler_cavity_thickness.csv new file mode 100644 index 0000000..01d62fc --- /dev/null +++ b/tests/fixtures/data/wheeler_cavity_thickness.csv @@ -0,0 +1,401 @@ +"Hour","Cavity","Cycle","Thickness","CAVITY LABEL","CAVITY MEAN","CAVITY STDEV","CAVITY ID","RSG LABEL-HOUR","XBAR CHART CL","S CHART CL","XBAR CHART LBL","XBAR CHART UBL","S CHART LBL","S CHART UBL","RSG SAMPLE SIZE" +1,"A",1,15,"A",14.65,1.4705279674745848,"A",1,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +1,"A",2,16,"B",8.97,1.4948554613643728,"A",2,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +1,"A",3,17,"C",7.47,1.6805390209912117,"A",3,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +1,"A",4,16,"D",7.86,1.4694560741275022,"A",4,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +1,"A",5,18,,,,"A",5,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +2,"A",1,13,,,,"A",6,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +2,"A",2,18,,,,"A",7,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +2,"A",3,15,,,,"A",8,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +2,"A",4,15,,,,"A",9,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +2,"A",5,15,,,,"A",10,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +3,"A",1,14,,,,"A",11,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +3,"A",2,14,,,,"A",12,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +3,"A",3,18,,,,"A",13,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +3,"A",4,14,,,,"A",14,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +3,"A",5,14,,,,"A",15,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +4,"A",1,14,,,,"A",16,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +4,"A",2,15,,,,"A",17,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +4,"A",3,15,,,,"A",18,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +4,"A",4,15,,,,"A",19,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +4,"A",5,14,,,,"A",20,14.65,1.3822751182152948,12.677079701147338,16.622920298852662,0,2.8875697758124175,5 +5,"A",1,12,,,,"B",1,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +5,"A",2,13,,,,"B",2,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +5,"A",3,13,,,,"B",3,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +5,"A",4,12,,,,"B",4,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +5,"A",5,13,,,,"B",5,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +6,"A",1,14,,,,"B",6,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +6,"A",2,15,,,,"B",7,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +6,"A",3,17,,,,"B",8,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +6,"A",4,14,,,,"B",9,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +6,"A",5,13,,,,"B",10,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +7,"A",1,12,,,,"B",11,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +7,"A",2,12,,,,"B",12,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +7,"A",3,13,,,,"B",13,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +7,"A",4,13,,,,"B",14,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +7,"A",5,11,,,,"B",15,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +8,"A",1,13,,,,"B",16,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +8,"A",2,15,,,,"B",17,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +8,"A",3,16,,,,"B",18,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +8,"A",4,14,,,,"B",19,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +8,"A",5,13,,,,"B",20,8.97,1.4051426122284405,6.964440943111471,10.97555905688853,0,2.9353399220667944,5 +9,"A",1,15,,,,"C",1,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +9,"A",2,16,,,,"C",2,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +9,"A",3,17,,,,"C",3,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +9,"A",4,14,,,,"C",4,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +9,"A",5,13,,,,"C",5,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +10,"A",1,13,,,,"C",6,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +10,"A",2,13,,,,"C",7,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +10,"A",3,14,,,,"C",8,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +10,"A",4,13,,,,"C",9,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +10,"A",5,13,,,,"C",10,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +11,"A",1,13,,,,"C",11,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +11,"A",2,16,,,,"C",12,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +11,"A",3,13,,,,"C",13,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +11,"A",4,16,,,,"C",14,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +11,"A",5,18,,,,"C",15,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +12,"A",1,16,,,,"C",16,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +12,"A",2,18,,,,"C",17,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +12,"A",3,18,,,,"C",18,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +12,"A",4,16,,,,"C",19,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +12,"A",5,13,,,,"C",20,7.47,1.5796824849889777,5.215320306133623,9.724679693866378,0,3.2999533442545568,5 +13,"A",1,13,,,,"D",1,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +13,"A",2,18,,,,"D",2,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +13,"A",3,18,,,,"D",3,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +13,"A",4,14,,,,"D",4,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +13,"A",5,13,,,,"D",5,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +14,"A",1,13,,,,"D",6,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +14,"A",2,13,,,,"D",7,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +14,"A",3,14,,,,"D",8,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +14,"A",4,14,,,,"D",9,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +14,"A",5,14,,,,"D",10,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +15,"A",1,13,,,,"D",11,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +15,"A",2,15,,,,"D",12,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +15,"A",3,18,,,,"D",13,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +15,"A",4,15,,,,"D",14,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +15,"A",5,14,,,,"D",15,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +16,"A",1,15,,,,"D",16,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +16,"A",2,14,,,,"D",17,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +16,"A",3,15,,,,"D",18,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +16,"A",4,14,,,,"D",19,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +16,"A",5,12,,,,"D",20,7.86,1.3812675539011,5.888517796980562,9.831482203019439,0,2.8854649761075564,5 +17,"A",1,14,,,,,,,,,,,, +17,"A",2,16,,,,,,,,,,,, +17,"A",3,14,,,,,,,,,,,, +17,"A",4,13,,,,,,,,,,,, +17,"A",5,14,,,,,,,,,,,, +18,"A",1,12,,,,,,,,,,,, +18,"A",2,15,,,,,,,,,,,, +18,"A",3,15,,,,,,,,,,,, +18,"A",4,15,,,,,,,,,,,, +18,"A",5,14,,,,,,,,,,,, +19,"A",1,17,,,,,,,,,,,, +19,"A",2,15,,,,,,,,,,,, +19,"A",3,18,,,,,,,,,,,, +19,"A",4,18,,,,,,,,,,,, +19,"A",5,17,,,,,,,,,,,, +20,"A",1,18,,,,,,,,,,,, +20,"A",2,16,,,,,,,,,,,, +20,"A",3,17,,,,,,,,,,,, +20,"A",4,14,,,,,,,,,,,, +20,"A",5,13,,,,,,,,,,,, +1,"B",1,10,,,,,,,,,,,, +1,"B",2,13,,,,,,,,,,,, +1,"B",3,11,,,,,,,,,,,, +1,"B",4,10,,,,,,,,,,,, +1,"B",5,10,,,,,,,,,,,, +2,"B",1,9,,,,,,,,,,,, +2,"B",2,10,,,,,,,,,,,, +2,"B",3,11,,,,,,,,,,,, +2,"B",4,8,,,,,,,,,,,, +2,"B",5,9,,,,,,,,,,,, +3,"B",1,9,,,,,,,,,,,, +3,"B",2,10,,,,,,,,,,,, +3,"B",3,10,,,,,,,,,,,, +3,"B",4,12,,,,,,,,,,,, +3,"B",5,9,,,,,,,,,,,, +4,"B",1,10,,,,,,,,,,,, +4,"B",2,10,,,,,,,,,,,, +4,"B",3,11,,,,,,,,,,,, +4,"B",4,11,,,,,,,,,,,, +4,"B",5,10,,,,,,,,,,,, +5,"B",1,8,,,,,,,,,,,, +5,"B",2,7,,,,,,,,,,,, +5,"B",3,8,,,,,,,,,,,, +5,"B",4,7,,,,,,,,,,,, +5,"B",5,7,,,,,,,,,,,, +6,"B",1,7,,,,,,,,,,,, +6,"B",2,11,,,,,,,,,,,, +6,"B",3,12,,,,,,,,,,,, +6,"B",4,8,,,,,,,,,,,, +6,"B",5,6,,,,,,,,,,,, +7,"B",1,6,,,,,,,,,,,, +7,"B",2,6,,,,,,,,,,,, +7,"B",3,6,,,,,,,,,,,, +7,"B",4,6,,,,,,,,,,,, +7,"B",5,7,,,,,,,,,,,, +8,"B",1,9,,,,,,,,,,,, +8,"B",2,8,,,,,,,,,,,, +8,"B",3,7,,,,,,,,,,,, +8,"B",4,7,,,,,,,,,,,, +8,"B",5,8,,,,,,,,,,,, +9,"B",1,11,,,,,,,,,,,, +9,"B",2,13,,,,,,,,,,,, +9,"B",3,11,,,,,,,,,,,, +9,"B",4,13,,,,,,,,,,,, +9,"B",5,8,,,,,,,,,,,, +10,"B",1,7,,,,,,,,,,,, +10,"B",2,8,,,,,,,,,,,, +10,"B",3,7,,,,,,,,,,,, +10,"B",4,6,,,,,,,,,,,, +10,"B",5,7,,,,,,,,,,,, +11,"B",1,10,,,,,,,,,,,, +11,"B",2,8,,,,,,,,,,,, +11,"B",3,10,,,,,,,,,,,, +11,"B",4,10,,,,,,,,,,,, +11,"B",5,11,,,,,,,,,,,, +12,"B",1,13,,,,,,,,,,,, +12,"B",2,8,,,,,,,,,,,, +12,"B",3,8,,,,,,,,,,,, +12,"B",4,10,,,,,,,,,,,, +12,"B",5,7,,,,,,,,,,,, +13,"B",1,11,,,,,,,,,,,, +13,"B",2,10,,,,,,,,,,,, +13,"B",3,9,,,,,,,,,,,, +13,"B",4,10,,,,,,,,,,,, +13,"B",5,10,,,,,,,,,,,, +14,"B",1,7,,,,,,,,,,,, +14,"B",2,8,,,,,,,,,,,, +14,"B",3,9,,,,,,,,,,,, +14,"B",4,8,,,,,,,,,,,, +14,"B",5,8,,,,,,,,,,,, +15,"B",1,7,,,,,,,,,,,, +15,"B",2,12,,,,,,,,,,,, +15,"B",3,10,,,,,,,,,,,, +15,"B",4,10,,,,,,,,,,,, +15,"B",5,7,,,,,,,,,,,, +16,"B",1,6,,,,,,,,,,,, +16,"B",2,7,,,,,,,,,,,, +16,"B",3,8,,,,,,,,,,,, +16,"B",4,6,,,,,,,,,,,, +16,"B",5,10,,,,,,,,,,,, +17,"B",1,9,,,,,,,,,,,, +17,"B",2,10,,,,,,,,,,,, +17,"B",3,13,,,,,,,,,,,, +17,"B",4,7,,,,,,,,,,,, +17,"B",5,6,,,,,,,,,,,, +18,"B",1,9,,,,,,,,,,,, +18,"B",2,7,,,,,,,,,,,, +18,"B",3,8,,,,,,,,,,,, +18,"B",4,10,,,,,,,,,,,, +18,"B",5,9,,,,,,,,,,,, +19,"B",1,10,,,,,,,,,,,, +19,"B",2,10,,,,,,,,,,,, +19,"B",3,12,,,,,,,,,,,, +19,"B",4,12,,,,,,,,,,,, +19,"B",5,6,,,,,,,,,,,, +20,"B",1,8,,,,,,,,,,,, +20,"B",2,10,,,,,,,,,,,, +20,"B",3,11,,,,,,,,,,,, +20,"B",4,10,,,,,,,,,,,, +20,"B",5,7,,,,,,,,,,,, +1,"C",1,7,,,,,,,,,,,, +1,"C",2,8,,,,,,,,,,,, +1,"C",3,10,,,,,,,,,,,, +1,"C",4,7,,,,,,,,,,,, +1,"C",5,10,,,,,,,,,,,, +2,"C",1,7,,,,,,,,,,,, +2,"C",2,11,,,,,,,,,,,, +2,"C",3,10,,,,,,,,,,,, +2,"C",4,10,,,,,,,,,,,, +2,"C",5,9,,,,,,,,,,,, +3,"C",1,8,,,,,,,,,,,, +3,"C",2,10,,,,,,,,,,,, +3,"C",3,9,,,,,,,,,,,, +3,"C",4,10,,,,,,,,,,,, +3,"C",5,8,,,,,,,,,,,, +4,"C",1,7,,,,,,,,,,,, +4,"C",2,9,,,,,,,,,,,, +4,"C",3,12,,,,,,,,,,,, +4,"C",4,10,,,,,,,,,,,, +4,"C",5,10,,,,,,,,,,,, +5,"C",1,5,,,,,,,,,,,, +5,"C",2,6,,,,,,,,,,,, +5,"C",3,8,,,,,,,,,,,, +5,"C",4,5,,,,,,,,,,,, +5,"C",5,4,,,,,,,,,,,, +6,"C",1,6,,,,,,,,,,,, +6,"C",2,4,,,,,,,,,,,, +6,"C",3,7,,,,,,,,,,,, +6,"C",4,6,,,,,,,,,,,, +6,"C",5,5,,,,,,,,,,,, +7,"C",1,4,,,,,,,,,,,, +7,"C",2,5,,,,,,,,,,,, +7,"C",3,4,,,,,,,,,,,, +7,"C",4,4,,,,,,,,,,,, +7,"C",5,6,,,,,,,,,,,, +8,"C",1,10,,,,,,,,,,,, +8,"C",2,8,,,,,,,,,,,, +8,"C",3,6,,,,,,,,,,,, +8,"C",4,7,,,,,,,,,,,, +8,"C",5,5,,,,,,,,,,,, +9,"C",1,9,,,,,,,,,,,, +9,"C",2,9,,,,,,,,,,,, +9,"C",3,6,,,,,,,,,,,, +9,"C",4,8,,,,,,,,,,,, +9,"C",5,10,,,,,,,,,,,, +10,"C",1,4,,,,,,,,,,,, +10,"C",2,5,,,,,,,,,,,, +10,"C",3,6,,,,,,,,,,,, +10,"C",4,5,,,,,,,,,,,, +10,"C",5,5,,,,,,,,,,,, +11,"C",1,8,,,,,,,,,,,, +11,"C",2,7,,,,,,,,,,,, +11,"C",3,8,,,,,,,,,,,, +11,"C",4,7,,,,,,,,,,,, +11,"C",5,11,,,,,,,,,,,, +12,"C",1,13,,,,,,,,,,,, +12,"C",2,10,,,,,,,,,,,, +12,"C",3,10,,,,,,,,,,,, +12,"C",4,6,,,,,,,,,,,, +12,"C",5,7,,,,,,,,,,,, +13,"C",1,14,,,,,,,,,,,, +13,"C",2,9,,,,,,,,,,,, +13,"C",3,10,,,,,,,,,,,, +13,"C",4,9,,,,,,,,,,,, +13,"C",5,7,,,,,,,,,,,, +14,"C",1,6,,,,,,,,,,,, +14,"C",2,6,,,,,,,,,,,, +14,"C",3,7,,,,,,,,,,,, +14,"C",4,6,,,,,,,,,,,, +14,"C",5,6,,,,,,,,,,,, +15,"C",1,7,,,,,,,,,,,, +15,"C",2,8,,,,,,,,,,,, +15,"C",3,12,,,,,,,,,,,, +15,"C",4,12,,,,,,,,,,,, +15,"C",5,6,,,,,,,,,,,, +16,"C",1,7,,,,,,,,,,,, +16,"C",2,10,,,,,,,,,,,, +16,"C",3,8,,,,,,,,,,,, +16,"C",4,6,,,,,,,,,,,, +16,"C",5,6,,,,,,,,,,,, +17,"C",1,6,,,,,,,,,,,, +17,"C",2,5,,,,,,,,,,,, +17,"C",3,5,,,,,,,,,,,, +17,"C",4,7,,,,,,,,,,,, +17,"C",5,5,,,,,,,,,,,, +18,"C",1,5,,,,,,,,,,,, +18,"C",2,7,,,,,,,,,,,, +18,"C",3,6,,,,,,,,,,,, +18,"C",4,6,,,,,,,,,,,, +18,"C",5,10,,,,,,,,,,,, +19,"C",1,5,,,,,,,,,,,, +19,"C",2,9,,,,,,,,,,,, +19,"C",3,7,,,,,,,,,,,, +19,"C",4,8,,,,,,,,,,,, +19,"C",5,7,,,,,,,,,,,, +20,"C",1,7,,,,,,,,,,,, +20,"C",2,7,,,,,,,,,,,, +20,"C",3,7,,,,,,,,,,,, +20,"C",4,10,,,,,,,,,,,, +20,"C",5,5,,,,,,,,,,,, +1,"D",1,8,,,,,,,,,,,, +1,"D",2,9,,,,,,,,,,,, +1,"D",3,10,,,,,,,,,,,, +1,"D",4,10,,,,,,,,,,,, +1,"D",5,10,,,,,,,,,,,, +2,"D",1,10,,,,,,,,,,,, +2,"D",2,13,,,,,,,,,,,, +2,"D",3,13,,,,,,,,,,,, +2,"D",4,10,,,,,,,,,,,, +2,"D",5,9,,,,,,,,,,,, +3,"D",1,8,,,,,,,,,,,, +3,"D",2,9,,,,,,,,,,,, +3,"D",3,10,,,,,,,,,,,, +3,"D",4,8,,,,,,,,,,,, +3,"D",5,9,,,,,,,,,,,, +4,"D",1,11,,,,,,,,,,,, +4,"D",2,12,,,,,,,,,,,, +4,"D",3,11,,,,,,,,,,,, +4,"D",4,10,,,,,,,,,,,, +4,"D",5,13,,,,,,,,,,,, +5,"D",1,4,,,,,,,,,,,, +5,"D",2,4,,,,,,,,,,,, +5,"D",3,5,,,,,,,,,,,, +5,"D",4,3,,,,,,,,,,,, +5,"D",5,4,,,,,,,,,,,, +6,"D",1,4,,,,,,,,,,,, +6,"D",2,7,,,,,,,,,,,, +6,"D",3,6,,,,,,,,,,,, +6,"D",4,5,,,,,,,,,,,, +6,"D",5,4,,,,,,,,,,,, +7,"D",1,4,,,,,,,,,,,, +7,"D",2,4,,,,,,,,,,,, +7,"D",3,5,,,,,,,,,,,, +7,"D",4,4,,,,,,,,,,,, +7,"D",5,5,,,,,,,,,,,, +8,"D",1,6,,,,,,,,,,,, +8,"D",2,6,,,,,,,,,,,, +8,"D",3,8,,,,,,,,,,,, +8,"D",4,6,,,,,,,,,,,, +8,"D",5,5,,,,,,,,,,,, +9,"D",1,9,,,,,,,,,,,, +9,"D",2,10,,,,,,,,,,,, +9,"D",3,11,,,,,,,,,,,, +9,"D",4,9,,,,,,,,,,,, +9,"D",5,5,,,,,,,,,,,, +10,"D",1,5,,,,,,,,,,,, +10,"D",2,5,,,,,,,,,,,, +10,"D",3,6,,,,,,,,,,,, +10,"D",4,6,,,,,,,,,,,, +10,"D",5,5,,,,,,,,,,,, +11,"D",1,11,,,,,,,,,,,, +11,"D",2,10,,,,,,,,,,,, +11,"D",3,9,,,,,,,,,,,, +11,"D",4,10,,,,,,,,,,,, +11,"D",5,10,,,,,,,,,,,, +12,"D",1,9,,,,,,,,,,,, +12,"D",2,6,,,,,,,,,,,, +12,"D",3,8,,,,,,,,,,,, +12,"D",4,7,,,,,,,,,,,, +12,"D",5,7,,,,,,,,,,,, +13,"D",1,9,,,,,,,,,,,, +13,"D",2,12,,,,,,,,,,,, +13,"D",3,11,,,,,,,,,,,, +13,"D",4,11,,,,,,,,,,,, +13,"D",5,6,,,,,,,,,,,, +14,"D",1,6,,,,,,,,,,,, +14,"D",2,7,,,,,,,,,,,, +14,"D",3,6,,,,,,,,,,,, +14,"D",4,6,,,,,,,,,,,, +14,"D",5,6,,,,,,,,,,,, +15,"D",1,11,,,,,,,,,,,, +15,"D",2,12,,,,,,,,,,,, +15,"D",3,12,,,,,,,,,,,, +15,"D",4,12,,,,,,,,,,,, +15,"D",5,6,,,,,,,,,,,, +16,"D",1,6,,,,,,,,,,,, +16,"D",2,8,,,,,,,,,,,, +16,"D",3,7,,,,,,,,,,,, +16,"D",4,9,,,,,,,,,,,, +16,"D",5,7,,,,,,,,,,,, +17,"D",1,7,,,,,,,,,,,, +17,"D",2,7,,,,,,,,,,,, +17,"D",3,4,,,,,,,,,,,, +17,"D",4,7,,,,,,,,,,,, +17,"D",5,5,,,,,,,,,,,, +18,"D",1,6,,,,,,,,,,,, +18,"D",2,8,,,,,,,,,,,, +18,"D",3,7,,,,,,,,,,,, +18,"D",4,12,,,,,,,,,,,, +18,"D",5,12,,,,,,,,,,,, +19,"D",1,7,,,,,,,,,,,, +19,"D",2,7,,,,,,,,,,,, +19,"D",3,5,,,,,,,,,,,, +19,"D",4,12,,,,,,,,,,,, +19,"D",5,8,,,,,,,,,,,, +20,"D",1,10,,,,,,,,,,,, +20,"D",2,7,,,,,,,,,,,, +20,"D",3,9,,,,,,,,,,,, +20,"D",4,8,,,,,,,,,,,, +20,"D",5,8,,,,,,,,,,,, diff --git a/tests/fixtures/golden_masters/phased_xbar_s/S_data.parquet b/tests/fixtures/golden_masters/phased_xbar_s/S_data.parquet new file mode 100644 index 0000000..eb544fa Binary files /dev/null and b/tests/fixtures/golden_masters/phased_xbar_s/S_data.parquet differ diff --git a/tests/fixtures/golden_masters/phased_xbar_s/S_metadata.json b/tests/fixtures/golden_masters/phased_xbar_s/S_metadata.json new file mode 100644 index 0000000..06793b0 --- /dev/null +++ b/tests/fixtures/golden_masters/phased_xbar_s/S_metadata.json @@ -0,0 +1,53 @@ +{ + "center_col": "center", + "chart_type": "S", + "group_col": "group", + "insufficient_phases": null, + "lane_boundaries": [ + { + "label": "F1_1_F2_2", + "position": 6, + "variables": [ + "phase" + ] + }, + { + "label": "F1_2_F2_1", + "position": 12, + "variables": [ + "phase" + ] + }, + { + "label": "F1_2_F2_2", + "position": 18, + "variables": [ + "phase" + ] + }, + { + "label": "F1_3_F2_1", + "position": 24, + "variables": [ + "phase" + ] + }, + { + "label": "F1_3_F2_2", + "position": 30, + "variables": [ + "phase" + ] + } + ], + "n_mode": "actual", + "n_sigma": 3.0, + "phase_by": [ + "factor 1", + "factor 2" + ], + "phased": true, + "run_rules_applicable": false, + "single_point_phases": 0, + "value_col": "s" +} diff --git a/tests/fixtures/golden_masters/phased_xbar_s/S_statistics.json b/tests/fixtures/golden_masters/phased_xbar_s/S_statistics.json new file mode 100644 index 0000000..6a89b33 --- /dev/null +++ b/tests/fixtures/golden_masters/phased_xbar_s/S_statistics.json @@ -0,0 +1,7 @@ +{ + "N": null, + "center": null, + "limits_vary": true, + "lpl": null, + "upl": null +} diff --git a/tests/fixtures/golden_masters/phased_xbar_s/Xbar_data.parquet b/tests/fixtures/golden_masters/phased_xbar_s/Xbar_data.parquet new file mode 100644 index 0000000..35f76be Binary files /dev/null and b/tests/fixtures/golden_masters/phased_xbar_s/Xbar_data.parquet differ diff --git a/tests/fixtures/golden_masters/phased_xbar_s/Xbar_metadata.json b/tests/fixtures/golden_masters/phased_xbar_s/Xbar_metadata.json new file mode 100644 index 0000000..45e976a --- /dev/null +++ b/tests/fixtures/golden_masters/phased_xbar_s/Xbar_metadata.json @@ -0,0 +1,58 @@ +{ + "center_col": "center", + "chart_type": "Xbar", + "group_col": "group", + "insufficient_phases": null, + "lane_boundaries": [ + { + "label": "F1_1_F2_2", + "position": 6, + "variables": [ + "factor 1", + "factor 2" + ] + }, + { + "label": "F1_2_F2_1", + "position": 12, + "variables": [ + "factor 1", + "factor 2" + ] + }, + { + "label": "F1_2_F2_2", + "position": 18, + "variables": [ + "factor 1", + "factor 2" + ] + }, + { + "label": "F1_3_F2_1", + "position": 24, + "variables": [ + "factor 1", + "factor 2" + ] + }, + { + "label": "F1_3_F2_2", + "position": 30, + "variables": [ + "factor 1", + "factor 2" + ] + } + ], + "n_mode": "actual", + "n_sigma": 3.0, + "phase_by": [ + "factor 1", + "factor 2" + ], + "phased": true, + "run_rules_applicable": false, + "single_point_phases": 0, + "value_col": "xbar" +} diff --git a/tests/fixtures/golden_masters/phased_xbar_s/Xbar_statistics.json b/tests/fixtures/golden_masters/phased_xbar_s/Xbar_statistics.json new file mode 100644 index 0000000..6a89b33 --- /dev/null +++ b/tests/fixtures/golden_masters/phased_xbar_s/Xbar_statistics.json @@ -0,0 +1,7 @@ +{ + "N": null, + "center": null, + "limits_vary": true, + "lpl": null, + "upl": null +} diff --git a/tests/test_analysis_golden_master.py b/tests/test_analysis_golden_master.py index 607fc48..923c567 100644 --- a/tests/test_analysis_golden_master.py +++ b/tests/test_analysis_golden_master.py @@ -274,6 +274,11 @@ def _make_residual_chart(): return synthetic.make_design(1, K1=3, K2=2, T=4, n_min=3, n_max=3, seed=42) +def _make_phased_xbar_s(): + """Scenario: phased Xbar/S — a centre line and limits per factor combination (#144).""" + return synthetic.make_design(1, K1=3, K2=2, T=6, n_min=2, n_max=4, seed=42) + + def _make_single_obs_strata(): """Scenario 7: Stratum with n=1 — edge case for mR computation.""" # SDS 3 has mixed replication. Use specific params to get some n=1 cells. @@ -356,6 +361,14 @@ def _execute_single_obs_strata(): return study.execute(chart='X', by=[], companion=True) +def _execute_phased_xbar_s(): + """Execute scenario: phased Xbar/S companion pair (#144).""" + df = _make_phased_xbar_s() + pb = ProcessBehavior(df) + study = pb.formulate(response='y', factors=['factor 1', 'factor 2'], time='time') + return study.execute(chart='Xbar', phased=True, companion=True) + + def _execute_pathological_ordering(): """Execute scenario 8: reverse-sorted input, X companion.""" df = _make_pathological_ordering() @@ -404,6 +417,10 @@ def test_single_obs_strata(self): result = _execute_single_obs_strata() _assert_result_matches_snapshot('single_obs_strata', result) + def test_phased_xbar_s(self): + result = _execute_phased_xbar_s() + _assert_result_matches_snapshot('phased_xbar_s', result) + def test_pathological_ordering(self): result = _execute_pathological_ordering() _assert_result_matches_snapshot('pathological_ordering', result) diff --git a/tests/test_phased_limits.py b/tests/test_phased_limits.py index d4c5cf6..82b435d 100644 --- a/tests/test_phased_limits.py +++ b/tests/test_phased_limits.py @@ -182,10 +182,12 @@ def test_phased_lane_boundaries_preserved(self, sds1_study): class TestPhasedValidation: """Test that invalid phased configurations raise appropriate errors.""" - def test_phased_requires_xmr_or_r(self, sds1_study): - """phased=True with Xbar should raise ValidationError.""" - with pytest.raises(ValidationError, match='only valid for X or mR'): - sds1_study.execute(chart='Xbar', phased=True) + def test_phased_requires_a_limits_chart(self, sds1_study): + """phased=True is refused on Histogram and accepted on Xbar/S (#144) as on X/mR.""" + with pytest.raises(ValidationError, match='only valid for'): + sds1_study.execute(chart='Histogram', phased=True) + result = sds1_study.execute(chart='Xbar', phased=True) + assert result.charts['Xbar']['metadata']['phased'] is True def test_phased_stratified_allowed_with_collapsed_factors(self, sds1_study): """phased=True with by=['factor 1'] should work (factor 2 is collapsed).""" diff --git a/tests/test_phased_xbar_s.py b/tests/test_phased_xbar_s.py new file mode 100644 index 0000000..c4b3ebc --- /dev/null +++ b/tests/test_phased_xbar_s.py @@ -0,0 +1,536 @@ +"""Phased (staged) Xbar/S charts: one centre line and one set of limits per phase (issue #144). + +A phase is one combination of the grouping factors, charted the way the per-condition charts +of ``by=[time]`` chart one stratum, and the phases sit side by side on one axis. Dr. Bishop +settled the two design questions on 2026-10-18: each stage has its own S-bar and sigma-hat +("treat each stage separately as if it were a single analysis"), and with other factors pooled +into the subgroup the centre is "the average of the rational subgroup averages for that stage". +He also sent Wheeler's four-cavity thickness data with Minitab's per-cavity limits; that file is +``tests/fixtures/data/wheeler_cavity_thickness.csv`` (Hour 1-20, Cavity A-D, Cycle 1-5, +Thickness, plus VAS's per-cavity CL/LBL/UBL columns on its first four rows). +""" + +from __future__ import annotations + +import time as _time +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +import processbehavior as pb +from processbehavior.calibration import Calibration +from processbehavior.datasets.synthetic import make_design +from processbehavior.exceptions import CalibrationNotSupportedError, ValidationError +from processbehavior.spc_constants import b3, b4, c4 + +REFERENCE_CSV = Path(__file__).resolve().parent.parent / 'validation' / 'PBTESTDATABASE_T100.csv' +CAVITY_CSV = Path(__file__).resolve().parent / 'fixtures' / 'data' / 'wheeler_cavity_thickness.csv' +FACTORS = ['FACTOR 1', 'FACTOR 2'] +TIME = 'PRODUCTION TIME' +XBAR_COLS = ['n', 'xbar', 'center', 'lpl', 'upl', 'beyond_limits'] +S_COLS = ['n', 's', 'center', 'lpl', 'upl', 'beyond_limits'] +VALUE_COLS = {'Xbar': XBAR_COLS, 'S': S_COLS} + + +@pytest.fixture(scope='module') +def reference_df(): + return pd.read_csv(REFERENCE_CSV, na_values=['*']) + + +@pytest.fixture(scope='module') +def reference_studies(reference_df): + return { + response: pb.formulate(reference_df, response=response, factors=FACTORS, time=TIME) + for response in ('PM SDS 1', 'PM SDS 3') + } + + +@pytest.fixture(scope='module') +def cavity_df(): + return pd.read_csv(CAVITY_CSV) + + +@pytest.fixture(scope='module') +def cavity_study(cavity_df): + return pb.formulate(cavity_df, response='Thickness', time='Hour', factors=['Cavity']) + + +@pytest.fixture +def sds1_study(): + df = make_design(1, K1=3, K2=2, T=6, n_min=2, n_max=4, seed=42) + return pb.formulate(df, response='y', time='time', factors=['factor 1', 'factor 2']) + + +def _staged(values_by_stage: dict[str, list[list[float]]]) -> pd.DataFrame: + """A staged study: ``{stage: [readings at time 1, readings at time 2, ...]}``.""" + rows = [] + for stage, subgroups in values_by_stage.items(): + for t, readings in enumerate(subgroups, start=1): + rows += [{'stage': stage, 'time': t, 'y': float(v)} for v in readings] + return pd.DataFrame(rows) + + +def _focus_concat(result, chart: str) -> pd.DataFrame: + """The per-condition charts of a stratified result, one after another, in strata order.""" + return pd.concat([result.focus(k).get_chart(chart) for k in result.strata], ignore_index=True) + + +def _frame(result, chart: str) -> pd.DataFrame: + return result.get_chart(chart)[VALUE_COLS[chart]].reset_index(drop=True) + + +# ============================================================================= +# Oracles: the phased chart is the per-condition charts laid side by side +# ============================================================================= + + +class TestOracles: + @pytest.mark.parametrize('chart', ['Xbar', 'S']) + @pytest.mark.parametrize('response', ['PM SDS 1', 'PM SDS 3']) + def test_phased_lanes_equal_stratified_focus(self, reference_studies, response, chart): + study = reference_studies[response] + phased = study.execute(chart=chart, phased=True) + stratified = study.execute(chart=chart, by=[TIME]) + expected = _focus_concat(stratified, chart) + + pd.testing.assert_frame_equal(_frame(phased, chart), expected[VALUE_COLS[chart]].reset_index(drop=True)) + + data = phased.get_chart(chart) + phases = [k for k in stratified.strata for _ in range(len(stratified.focus(k).get_chart(chart)))] + assert list(data['phase']) == phases + assert list(data['group']) == [f'{k}_{t}' for k, t in zip(phases, expected[TIME], strict=True)] + + @pytest.mark.parametrize('chart', ['Xbar', 'S']) + @pytest.mark.parametrize('response', ['PM SDS 1', 'PM SDS 3']) + def test_phase_statistics_equal_stratified_statistics(self, reference_studies, response, chart): + study = reference_studies[response] + phases = study.execute(chart=chart, phased=True).phase_statistics(chart).set_index('phase') + stratified = study.execute(chart=chart, by=[TIME]) + assert list(phases.index) == stratified.strata + for stratum in stratified.strata: + stats = stratified.statistics(chart, stratum=stratum) + row = phases.loc[stratum] + for key in ('center', 'N', 'lpl', 'upl'): + if stats[key] is None: + assert np.isnan(row[key]), (stratum, key, row[key]) + else: + assert row[key] == pytest.approx(stats[key]), (stratum, key) + + @pytest.mark.parametrize('chart', ['Xbar', 'S']) + def test_pooled_phase_equals_reformulated_study(self, reference_df, chart): + """by=['FACTOR 1', time] with FACTOR 2 pooled: each phase is the chart of a study without FACTOR 2. + + Checked on the balanced reference data and on an unbalanced copy, where the mean of the + pooled subgroup means (Bishop's rule) differs from the mean of the fine cell means. + """ + df = reference_df + thinned = df.drop( + df[(df['FACTOR 1'] == 1) & (df['FACTOR 2'] == 1) & (df[TIME] <= 50)].groupby(TIME).head(2).index + ) + for frame in (df, thinned): + two_factor = pb.formulate(frame, response='PM SDS 1', factors=FACTORS, time=TIME) + phased = two_factor.execute(chart=chart, by=['FACTOR 1', TIME], phased=True) + one_factor = pb.formulate(frame, response='PM SDS 1', factors=['FACTOR 1'], time=TIME) + expected = _focus_concat(one_factor.execute(chart=chart, by=[TIME]), chart) + pd.testing.assert_frame_equal(_frame(phased, chart), expected[VALUE_COLS[chart]].reset_index(drop=True)) + + # The non-phased pooled chart keeps its study-wide centre: the mean of the fine cell means. + two_factor = pb.formulate(thinned, response='PM SDS 1', factors=FACTORS, time=TIME) + pooled = two_factor.execute(chart='Xbar', by=['FACTOR 1', TIME]).get_statistics('Xbar')['center'] + fine = thinned.groupby(FACTORS + [TIME])['PM SDS 1'].mean().mean() + plotted = thinned.groupby(['FACTOR 1', TIME])['PM SDS 1'].mean().mean() + assert pooled == pytest.approx(round(fine, 3)) + assert abs(fine - plotted) > 1e-3 # the two rules really differ here + + def test_by_none_equals_by_empty_and_the_full_by_in_any_order(self, reference_studies): + study = reference_studies['PM SDS 3'] + base = study.execute(chart='Xbar', phased=True, companion=True) + for by in ([], FACTORS + [TIME], ['FACTOR 2', 'FACTOR 1', TIME]): + other = study.execute(chart='Xbar', by=by, phased=True, companion=True) + for chart in ('Xbar', 'S'): + pd.testing.assert_frame_equal(_frame(base, chart), _frame(other, chart)) + assert list(base.get_chart(chart)['phase']) == list(other.get_chart(chart)['phase']) + pd.testing.assert_frame_equal(base.phase_statistics(chart), other.phase_statistics(chart)) + assert base.charts[chart]['metadata']['phase_by'] == other.charts[chart]['metadata']['phase_by'] + + def test_wheeler_cavity_oracle(self, cavity_df, cavity_study): + """Tom's file carries VAS's per-cavity limits; the phased chart reproduces them to the digit.""" + result = cavity_study.execute(chart='Xbar', phased=True, companion=True) + chart_cols = [ + 'XBAR CHART CL', + 'XBAR CHART LBL', + 'XBAR CHART UBL', + 'S CHART CL', + 'S CHART LBL', + 'S CHART UBL', + 'RSG SAMPLE SIZE', + ] + charts = cavity_df.dropna(subset=['CAVITY ID']) + assert (charts.groupby('CAVITY ID')[chart_cols].nunique() == 1).all().all() # one value per cavity + vas = charts.groupby('CAVITY ID')[chart_cols].first() + xbar = result.phase_statistics('Xbar').set_index('phase') + s = result.phase_statistics('S').set_index('phase') + assert list(xbar.index) == ['A', 'B', 'C', 'D'] + for cavity in 'ABCD': + assert xbar.loc[cavity, 'center'] == pytest.approx(vas.loc[cavity, 'XBAR CHART CL'], abs=5e-4) + assert xbar.loc[cavity, 'lpl'] == pytest.approx(vas.loc[cavity, 'XBAR CHART LBL'], abs=5e-4) + assert xbar.loc[cavity, 'upl'] == pytest.approx(vas.loc[cavity, 'XBAR CHART UBL'], abs=5e-4) + assert s.loc[cavity, 'center'] == pytest.approx(vas.loc[cavity, 'S CHART CL'], abs=5e-4) + assert s.loc[cavity, 'lpl'] == pytest.approx(vas.loc[cavity, 'S CHART LBL'], abs=5e-4) + assert s.loc[cavity, 'upl'] == pytest.approx(vas.loc[cavity, 'S CHART UBL'], abs=5e-4) + assert xbar.loc[cavity, 'N'] == vas.loc[cavity, 'RSG SAMPLE SIZE'] + assert xbar.loc['A', 'center'] == 14.65 and xbar.loc['D', 'upl'] == 9.831 # the Minitab screenshot + + stratified = cavity_study.execute(chart='Xbar', by=['Hour'], companion=True) + for chart in ('Xbar', 'S'): + expected = _focus_concat(stratified, chart)[VALUE_COLS[chart]].reset_index(drop=True) + pd.testing.assert_frame_equal(_frame(result, chart), expected) + + +# ============================================================================= +# Hand-computed +# ============================================================================= + + +class TestHandComputed: + def test_two_stage_example(self): + stages = { + 'A': [[10, 12, 14], [11, 13, 12], [9, 10, 14], [12, 12, 15]], + 'B': [[20, 22, 21], [19, 23, 22], [21, 21, 24], [20, 20, 20]], + } + study = pb.formulate(_staged(stages), response='y', time='time', factors=['stage'], precision=9) + result = study.execute(chart='Xbar', phased=True, companion=True) + xbar, s = result.get_chart('Xbar'), result.get_chart('S') + phases = result.phase_statistics('Xbar').set_index('phase') + + for stage, subgroups in stages.items(): + means = np.array([np.mean(g) for g in subgroups]) + stds = np.array([np.std(g, ddof=1) for g in subgroups]) + centre, s_bar = means.mean(), stds.mean() + sigma_hat = s_bar / c4(3) + half = 3 * sigma_hat / np.sqrt(3) + rows = xbar[xbar['phase'] == stage] + assert rows['xbar'].to_numpy() == pytest.approx(means, abs=1e-9) + assert rows['center'].to_numpy() == pytest.approx(centre, abs=1e-9) + assert rows['lpl'].to_numpy() == pytest.approx(centre - half, abs=1e-9) + assert rows['upl'].to_numpy() == pytest.approx(centre + half, abs=1e-9) + srows = s[s['phase'] == stage] + assert srows['s'].to_numpy() == pytest.approx(stds, abs=1e-9) + assert srows['center'].to_numpy() == pytest.approx(s_bar, abs=1e-9) + assert srows['lpl'].to_numpy() == pytest.approx(b3(3) * s_bar, abs=1e-9) # 0 at n = 3 + assert srows['upl'].to_numpy() == pytest.approx(b4(3) * s_bar, abs=1e-9) + row = phases.loc[stage] + assert row['subgroups'] == 4 and row['N'] == 3 + assert (row['center'], row['s_bar'], row['sigma_hat']) == pytest.approx( + (centre, s_bar, sigma_hat), abs=1e-9 + ) + assert (row['lpl'], row['upl']) == pytest.approx((centre - half, centre + half), abs=1e-9) + + def test_unequal_n_uses_eq_11_5_within_the_phase(self): + stages = { + 'A': [[10, 12, 14], [11, 13, 12, 16], [9, 10, 14, 11], [12, 12, 15]], + 'B': [[20, 22, 21], [19, 23, 22], [21, 21, 24], [20, 20, 25]], + } + study = pb.formulate(_staged(stages), response='y', time='time', factors=['stage'], precision=9) + result = study.execute(chart='Xbar', phased=True) + xbar = result.get_chart('Xbar') + phases = result.phase_statistics('Xbar').set_index('phase') + + a = stages['A'] + stds = np.array([np.std(g, ddof=1) for g in a]) + sizes = np.array([len(g) for g in a]) + sigma_hat = np.mean(stds / np.array([c4(n) for n in sizes])) # Eq 11-5 within the phase + rows = xbar[xbar['phase'] == 'A'] + assert (rows['upl'] - rows['center']).to_numpy() == pytest.approx(3 * sigma_hat / np.sqrt(sizes), abs=1e-9) + assert phases.loc['A', 'sigma_hat'] == pytest.approx(sigma_hat, abs=1e-9) + assert all(np.isnan(phases.loc['A', k]) for k in ('N', 'lpl', 'upl')) # the limits step within A + assert phases.loc['B', 'N'] == 3 and not np.isnan(phases.loc['B', 'upl']) # B has one pair + assert result.get_statistics('Xbar') == { + 'N': None, + 'center': None, + 'lpl': None, + 'upl': None, + 'limits_vary': True, + } + + def test_average_n_mode_is_applied_per_phase(self): + stages = { + 'A': [[10, 12, 14], [11, 13, 12, 16], [9, 10, 14, 11], [12, 12, 15]], + 'B': [[20, 22], [19, 23, 22, 25], [21, 21, 24, 26], [20, 20]], + } + study = pb.formulate(_staged(stages), response='y', time='time', factors=['stage'], precision=9) + result = study.execute(chart='Xbar', phased=True, companion=True, n_mode='average') + xbar = result.get_chart('Xbar') + phases = result.phase_statistics('Xbar').set_index('phase') + for stage, subgroups in stages.items(): + n_avg = np.mean([len(g) for g in subgroups]) + s_bar = np.mean([np.std(g, ddof=1) for g in subgroups]) + half = 3 * (s_bar / c4(int(n_avg))) / np.sqrt(n_avg) # Eq 11-20, as the unphased chart applies it + rows = xbar[xbar['phase'] == stage] + assert phases.loc[stage, 'N'] == pytest.approx(n_avg) + assert rows['upl'].nunique() == 1 + assert (rows['upl'] - rows['center']).iloc[0] == pytest.approx(half, abs=1e-9) + assert 'n_avg' not in result.charts['Xbar']['metadata'] + assert 'n_avg' not in result.charts['S']['metadata'] + + def test_n_sigma_two_halves_the_width_ratio(self, sds1_study): + three = sds1_study.execute(chart='Xbar', phased=True).get_chart('Xbar') + two = sds1_study.execute(chart='Xbar', phased=True, n_sigma=2).get_chart('Xbar') + assert ((two['upl'] - two['center']) / (three['upl'] - three['center'])).to_numpy() == pytest.approx( + 2 / 3, abs=1e-2 + ) + + +# ============================================================================= +# Edge cases +# ============================================================================= + + +class TestEdgeCases: + def test_one_reading_subgroups_sit_on_xbar_and_not_on_s(self, reference_studies): + result = reference_studies['PM SDS 3'].execute(chart='Xbar', phased=True, companion=True) + xbar, s = result.get_chart('Xbar'), result.get_chart('S') + singles = xbar[xbar['n'] == 1] + assert len(singles) > 0 + assert (s['n'] >= 2).all() + assert len(s) == len(xbar) - len(singles) + phases = result.phase_statistics('Xbar').set_index('phase') + for _, row in singles.iterrows(): + # N_r = 1: center ± n_sigma · sigma_hat (10-12 manual Eqs 11-16, 11-17) + assert row['upl'] - row['center'] == pytest.approx(3 * phases.loc[row['phase'], 'sigma_hat'], abs=3e-3) + + def test_single_subgroup_phase_has_finite_limits(self): + stages = {'A': [[10, 12, 14], [11, 13, 12], [9, 10, 14]], 'B': [[20, 22, 21]]} + result = pb.formulate(_staged(stages), response='y', time='time', factors=['stage']).execute( + chart='Xbar', phased=True, companion=True + ) + b = result.get_chart('Xbar').query("phase == 'B'") + assert len(b) == 1 and np.isfinite(b[['lpl', 'upl']].to_numpy()).all() + assert result.charts['Xbar']['metadata']['single_point_phases'] == 1 + assert result.phase_statistics('Xbar').set_index('phase').loc['B', 'subgroups'] == 1 + + def test_insufficient_phase_keeps_its_points_without_limits(self): + stages = {'A': [[10, 12, 14], [11, 13, 12], [9, 10, 14]], 'B': [[20], [19], [21]]} + study = pb.formulate(_staged(stages), response='y', time='time', factors=['stage']) + result = study.execute(chart='Xbar', phased=True, companion=True) + xbar, s = result.get_chart('Xbar'), result.get_chart('S') + b = xbar[xbar['phase'] == 'B'] + assert len(b) == 3 + assert b[['center', 'lpl', 'upl']].isna().all().all() + assert (b['beyond_limits'] == 0).all() + assert 'B' not in set(s['phase']) + for chart in ('Xbar', 'S'): + assert result.charts[chart]['metadata']['insufficient_phases'] == ['B'] + row = result.phase_statistics('Xbar').set_index('phase').loc['B'] + assert row['subgroups'] == 3 and np.isnan(row['center']) and np.isnan(row['upl']) + assert 'B' not in set(result.phase_statistics('S')['phase']) + flagged = result.detect_signals('Xbar').flagged_observations + assert flagged.isdisjoint(set(b.index)) + + def test_no_replicated_phase_raises(self): + stages = {'A': [[10], [11], [9]], 'B': [[20], [19], [21]]} + study = pb.formulate(_staged(stages), response='y', time='time', factors=['stage']) + with pytest.raises(ValidationError): + study.execute(chart='Xbar', phased=True) + + +# ============================================================================= +# Contracts +# ============================================================================= + + +class TestContracts: + def test_solo_charts_equal_the_companion_pair(self, reference_studies): + study = reference_studies['PM SDS 3'] + pair = study.execute(chart='Xbar', phased=True, companion=True) + for chart in ('Xbar', 'S'): + solo = study.execute(chart=chart, phased=True) + pd.testing.assert_frame_equal(solo.get_chart(chart), pair.get_chart(chart)) + assert solo.get_statistics(chart) == pair.get_statistics(chart) + assert solo.charts[chart]['metadata'] == pair.charts[chart]['metadata'] + pd.testing.assert_frame_equal(solo.phase_statistics(chart), pair.phase_statistics(chart)) + + def test_statistics_and_metadata_shape(self, sds1_study): + result = sds1_study.execute(chart='Xbar', phased=True, companion=True, n_mode='average') + for chart, value_col in (('Xbar', 'xbar'), ('S', 's')): + assert result.get_statistics(chart) == { + 'N': None, + 'center': None, + 'lpl': None, + 'upl': None, + 'limits_vary': True, + } + meta = result.charts[chart]['metadata'] + assert set(meta) == { + 'chart_type', + 'value_col', + 'center_col', + 'n_sigma', + 'n_mode', + 'group_col', + 'phased', + 'run_rules_applicable', + 'phase_by', + 'lane_boundaries', + 'single_point_phases', + 'insufficient_phases', + } + assert meta['value_col'] == value_col and meta['group_col'] == 'group' + assert meta['phased'] is True and meta['run_rules_applicable'] is False + assert meta['phase_by'] == ['factor 1', 'factor 2'] + data = result.get_chart(chart) + assert list(data.columns) == ['group', 'phase', 'n', value_col, 'center', 'lpl', 'upl', 'beyond_limits'] + assert list(result.phase_statistics(chart).columns) == [ + 'phase', + 'subgroups', + 'N', + 'center', + 's_bar', + 'sigma_hat', + 'lpl', + 'upl', + ] + + def test_lane_positions_are_the_first_rows_of_phases(self, reference_studies): + result = reference_studies['PM SDS 3'].execute(chart='Xbar', phased=True, companion=True) + for chart in ('Xbar', 'S'): + data = result.get_chart(chart).reset_index(drop=True) + starts = [i for i in range(1, len(data)) if data.loc[i, 'phase'] != data.loc[i - 1, 'phase']] + boundaries = result.charts[chart]['metadata']['lane_boundaries'] + assert [b['position'] for b in boundaries] == starts + assert [b['label'] for b in boundaries] == [data.loc[i, 'phase'] for i in starts] + xbar_positions = [b['position'] for b in result.charts['Xbar']['metadata']['lane_boundaries']] + s_positions = [b['position'] for b in result.charts['S']['metadata']['lane_boundaries']] + assert xbar_positions != s_positions # one-reading subgroups shift the S chart's dividers + + def test_chart_table_has_phase_after_subgroup(self, sds1_study): + result = sds1_study.execute(chart='Xbar', phased=True, companion=True) + for chart in ('Xbar', 'S'): + table = result.chart_table(chart) + assert list(table.columns) == ['subgroup', 'phase', 'n', 'value', 'center', 'lpl', 'upl', 'signal'] + + def test_phase_statistics_refuses_unphased_and_xmr(self, sds1_study): + with pytest.raises(ValidationError, match='not phased'): + sds1_study.execute(chart='Xbar').phase_statistics('Xbar') + with pytest.raises(ValidationError, match='phased Xbar and S'): + sds1_study.execute(chart='X', by=[], phased=True).phase_statistics('X') + + def test_detect_signals_rule_1_against_each_phase(self, reference_studies): + result = reference_studies['PM SDS 1'].execute(chart='Xbar', phased=True, companion=True) + for chart in ('Xbar', 'S'): + signals = result.detect_signals(chart) + data = result.get_chart(chart) + assert signals.rules_evaluated == ['rule_1'] + assert not signals.is_partial + assert signals.flagged_observations == set(data.index[data['beyond_limits'] != 0]) + + def test_stage_order_follows_the_labels(self): + readings = [[10, 12, 14], [11, 13, 12]] + by_name = _staged({'before': readings, 'after': [[20, 22, 21], [19, 23, 22]]}) + result = pb.formulate(by_name, response='y', time='time', factors=['stage']).execute(chart='Xbar', phased=True) + assert list(result.phase_statistics('Xbar')['phase']) == ['after', 'before'] # strings sort + + sortable = by_name.assign(stage=by_name['stage'].map({'before': '1 before', 'after': '2 after'})) + result = pb.formulate(sortable, response='y', time='time', factors=['stage']).execute(chart='Xbar', phased=True) + assert list(result.phase_statistics('Xbar')['phase']) == ['1 before', '2 after'] + + ordered = by_name.assign(stage=pd.Categorical(by_name['stage'], categories=['before', 'after'], ordered=True)) + result = pb.formulate(ordered, response='y', time='time', factors=['stage']).execute(chart='Xbar', phased=True) + assert list(result.phase_statistics('Xbar')['phase']) == ['before', 'after'] + + def test_unphased_output_is_unchanged(self, reference_studies): + study = reference_studies['PM SDS 3'] + plain = study.execute(chart='Xbar', companion=True) + phased = study.execute(chart='Xbar', phased=True, companion=True) + for chart, value_col in (('Xbar', 'xbar'), ('S', 's')): + a = plain.get_chart(chart).reset_index(drop=True) + b = phased.get_chart(chart).reset_index(drop=True) + assert list(a['group']) == list(b['group']) + pd.testing.assert_series_equal(a[value_col], b[value_col]) + pd.testing.assert_series_equal(a['n'], b['n']) + assert 'phase' not in a.columns + assert 'phased' not in plain.charts[chart]['metadata'] + + @pytest.mark.slow + def test_a_thousand_phases_is_quick(self): + rng = np.random.default_rng(0) + rows = [ + {'stage': f'{k:04d}', 'time': t, 'y': rng.normal(k % 7, 1)} + for k in range(1000) + for t in range(3) + for _ in range(2) + ] + study = pb.formulate(pd.DataFrame(rows), response='y', time='time', factors=['stage']) + started = _time.perf_counter() + result = study.execute(chart='Xbar', phased=True, companion=True) + assert _time.perf_counter() - started < 5 + assert len(result.phase_statistics('Xbar')) == 1000 + + +# ============================================================================= +# Validation +# ============================================================================= + + +class TestValidation: + @pytest.mark.parametrize( + ('kwargs', 'fragment'), + [ + (dict(chart='Histogram'), 'only valid for'), + (dict(chart='Xbar', by=[TIME]), 'already gives each factor combination its own chart'), + (dict(chart='S', by=[TIME]), 'use by=None with phased=True'), + (dict(chart='Xbar', by=['FACTOR 1']), 'requires the time variable in by='), + (dict(chart='Xbar', by=FACTORS), 'requires the time variable in by='), + (dict(chart='Xbar', by=[TIME, 'FACTOR 1']), 'time variable last in by='), + (dict(chart='Xbar', value='R5'), 'charts the response'), + (dict(chart='S', value='R1', recentered=True), 'charts the response'), + ], + ) + def test_refused_requests(self, reference_studies, kwargs, fragment): + with pytest.raises(ValidationError, match=fragment): + reference_studies['PM SDS 1'].execute(phased=True, **kwargs) + + def test_refused_without_factors(self, reference_df): + study = pb.formulate(reference_df, response='PM SDS 1', time=TIME) + with pytest.raises(ValidationError, match='requires factors to define phases'): + study.execute(chart='Xbar', phased=True) + + @pytest.mark.parametrize('by', [None, [], FACTORS + [TIME], ['FACTOR 2', 'FACTOR 1', TIME], ['FACTOR 1', TIME]]) + def test_accepted_shapes(self, reference_studies, by): + result = reference_studies['PM SDS 1'].execute(chart='Xbar', by=by, phased=True, companion=True) + assert result.charts['Xbar']['metadata']['phased'] is True + expected_phase_by = ['FACTOR 1'] if by == ['FACTOR 1', TIME] else FACTORS + assert result.charts['Xbar']['metadata']['phase_by'] == expected_phase_by + + def test_calibration_is_refused_with_the_chart_in_the_context(self, reference_studies): + study = reference_studies['PM SDS 1'] + cal = Calibration(label='ref', mean=10.0, sigma=1.0) + for chart in ('Xbar', 'S'): + with pytest.raises(CalibrationNotSupportedError) as exc: + study.execute(chart=chart, phased=True, calibration=cal) + assert exc.value.context == f'phased {chart}' + assert study.supports_calibration(chart=chart, phased=True) is False + + +# ============================================================================= +# Plotting +# ============================================================================= + + +@pytest.mark.plotting +class TestPlotting: + def test_stepped_lines_dividers_and_note(self, cavity_study): + result = cavity_study.execute(chart='Xbar', phased=True, companion=True) + for chart in ('Xbar', 'S'): + fig = result.plot(chart=chart) + stepped = [t.name for t in fig.data if t.name and 'varies' in t.name] + assert any('CL' in name for name in stepped) + assert any('UPL' in name for name in stepped) + assert any('LPL' in name for name in stepped) + texts = [a.text for a in fig.layout.annotations] + assert 'Process limits computed per phase' in texts + assert {'B', 'C', 'D'} <= set(texts) # divider labels name the phases + assert len(fig.layout.shapes) >= 3 + assert not any(t.name and 'Zone' in t.name for t in fig.data) diff --git a/tests/test_result_tabular.py b/tests/test_result_tabular.py index 6274451..cfb6211 100644 --- a/tests/test_result_tabular.py +++ b/tests/test_result_tabular.py @@ -273,7 +273,7 @@ def test_signal_symbol_map_is_complete(self): assert set(_SIGNAL_SYMBOLS.keys()) == {-1, 0, 1} def test_non_value_cols_includes_typical_metadata(self): - for col in ("rsg", "center", "lpl", "upl", "beyond_limits", "n", "obs_id"): + for col in ("rsg", "center", "lpl", "upl", "beyond_limits", "n", "phase", "obs_id"): assert col in _NON_VALUE_COLS def test_preferred_statistic_cols_order(self): diff --git a/tests/test_supports_calibration.py b/tests/test_supports_calibration.py index bb58606..3ab8ad7 100644 --- a/tests/test_supports_calibration.py +++ b/tests/test_supports_calibration.py @@ -47,7 +47,7 @@ def _requests(): for by in (None, [], ['FACTOR 1'], FACTORS, [TIME], FACTORS + [TIME]): for value in (None, 'R5', 'R2'): for recentered in ((False, True) if value else (False,)): - for phased in ((False, True) if chart in ('X', 'mR') else (False,)): + for phased in (False, True): kwargs = dict( chart=chart, by=by, recentered=recentered, phased=phased, ) @@ -136,6 +136,19 @@ def test_phased_xmr_is_refused_only_when_collapsing(study): assert study.supports_calibration(chart='X', by=[], phased=True) is False +def test_phased_xbar_s_is_refused(study): + """Phased Xbar/S gives each phase its own limits, so one calibration cannot apply (#144).""" + assert study.supports_calibration(chart='Xbar', phased=True) is False + assert study.supports_calibration(chart='S', phased=True) is False + assert study.supports_calibration(chart='Xbar', by=['FACTOR 1', TIME], phased=True) is False + with pytest.raises(CalibrationNotSupportedError) as exc: + study.execute(chart='Xbar', phased=True, calibration=CAL) + assert exc.value.context == 'phased Xbar' + with pytest.raises(CalibrationNotSupportedError) as exc: + study.execute(chart='S', phased=True, calibration=CAL) + assert exc.value.context == 'phased S' + + def test_companion_does_not_change_the_answer(study): """Xbar+S together must agree with Xbar alone — they share the grouping.""" for by in ([], [TIME], FACTORS):