diff --git a/CHANGELOG.md b/CHANGELOG.md index edd254d..4f64ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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) +- **`result.phase_statistics(chart, stratum=None)`.** One row per phase of a phased chart. Xbar/S: + `phase`, `subgroups`, `N`, `center`, `s_bar`, `sigma_hat`, `lpl`, `upl`. X/mR: `phase`, `points`, + `center`, `mR_bar`, `lpl`, `upl`, per stratum on a stratified chart (`stratum=`, or through + `focus()`). (#144) ### Changed - **`formulate(response=..., factors=[])` with no time is the same study.** It used to treat the @@ -36,12 +38,13 @@ 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) +- **Phased chart data and `chart_table` carry a `phase` column** (the lane label) after the subgroup + label, on X/mR as well as Xbar/S. Phased Xbar/S 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, so the per-phase mean size is `phase_statistics(chart)['N']`. + (#144) ### Fixed - **Xbar/S `chart_table` keeps the subgroup label and `n` on every `by=`.** With a `by=` other than @@ -54,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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) +- **The companion mR of a phased X chart reports `single_point_phases`.** Only the solo mR did. (#144) ## [0.4.0] - 2026-10-14 diff --git a/docs/reference/api.md b/docs/reference/api.md index 06ababb..24b6b5b 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -330,17 +330,19 @@ 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()`. +result.phase_statistics(chart, stratum=None) # -> DataFrame, one row per phase +``` + +A chart executed with `phased=True` gives each phase — one factor combination, a +contiguous run of points in time order — its own center line and limits. 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. On +Xbar/S: `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). On X/mR: `phase`, `points`, `center`, `mR_bar`, `lpl`, +`upl`. A stratified phased X/mR chart (`by=['factor 1'], phased=True`) has phases per +stratum: pass `stratum=`, or call it on `focus(stratum)`. It raises `ValidationError` on a +chart that is not phased. Phased metadata carries `phased=True`, `run_rules_applicable=False`, `phase_by` (the factors a phase is one combination of), `lane_boundaries` (the divider positions), diff --git a/processbehavior/analysis.py b/processbehavior/analysis.py index c8a22b8..cb68c48 100644 --- a/processbehavior/analysis.py +++ b/processbehavior/analysis.py @@ -332,6 +332,25 @@ def _raise_no_replication(chart: str, sds, insufficient: list, total: int) -> No ) +def _mr_phase_table(phase_stats: pd.DataFrame, out: pd.DataFrame, keys: list[str], round_to: int) -> pd.DataFrame: + """One row per phase of a phased X or mR chart: label, points, centre, mR-bar and limits. + + ``phase_stats`` is the per-phase aggregation keyed by ``keys`` (``['_phase_id']``, or + ``[stratify_col, '_phase_id']``); ``out`` carries the ``phase`` label on every row. + """ + labels = out.groupby(keys, sort=False)['phase'].first().reset_index() + table = phase_stats.merge(labels, on=keys, how='left').rename(columns={'_n': 'points', 'mR': 'mR_bar'}) + return table[keys + ['phase', 'points', 'center', 'mR_bar', 'lpl', 'upl']].round(round_to) + + +def _split_phase_table(table: pd.DataFrame, stratify_col: str) -> dict: + """The per-stratum phase tables of a stratified phased chart, keyed by stratum.""" + return { + stratum: group.drop(columns=[stratify_col, '_phase_id']).reset_index(drop=True) + for stratum, group in table.groupby(stratify_col, sort=False) + } + + def _build_stratified_mr_payload( *, mr_spec: _MRChartSpec, @@ -345,6 +364,7 @@ def _build_stratified_mr_payload( phased: bool = False, insufficient_strata: list | None = None, single_point_phases: int | None = None, + phases: dict | None = None, ) -> dict: """Build the {chart_type: {...}} payload for a stratified MR-family chart. @@ -379,6 +399,7 @@ def _build_stratified_mr_payload( 'statistics': chart_statistics, 'metadata': metadata, 'strata': published_strata, + **({'phases': phases} if phased and phases is not None else {}), }, } @@ -392,6 +413,7 @@ def _build_ungrouped_mr_payload( lane_boundaries, phased: bool = False, single_point_phases: int | None = None, + phases: pd.DataFrame | None = None, ) -> dict: """Build the {chart_type: {...}} payload for an ungrouped MR-family chart.""" metadata: dict = { @@ -410,6 +432,7 @@ def _build_ungrouped_mr_payload( 'data': chart_out, 'statistics': chart_statistics, 'metadata': metadata, + **({'phases': phases} if phased and phases is not None else {}), }, } @@ -2239,10 +2262,13 @@ def _calculate_mr_chart_from_precomputed( phase_stats = precomputed['phase_stats'] # Recompute R-specific limits per phase within each stratum - r_phase_stats = phase_stats[[stratify_col, '_phase_id', 'mR']].copy() + r_phase_stats = phase_stats[[stratify_col, '_phase_id', '_n', 'mR']].copy() r_lims = _mr_family_limits(mr_spec, mean=0, mR=r_phase_stats['mR']) r_phase_stats = _join_limits(r_phase_stats, r_lims) r_phase_stats['center'] = r_phase_stats['mR'] + r_phases = _split_phase_table( + _mr_phase_table(r_phase_stats, out, [stratify_col, '_phase_id'], spec.round_to), stratify_col + ) # Replace X limits with mR limits out = out.drop(columns=['center', 'lpl', 'upl', 'beyond_limits'], errors='ignore') @@ -2261,7 +2287,7 @@ def _calculate_mr_chart_from_precomputed( # Format output chart_out = self._build_output_columns( df=out, - value_cols=extra_cols + [plot_col, 'center', 'lpl', 'upl', 'beyond_limits'], + value_cols=extra_cols + ['phase', plot_col, 'center', 'lpl', 'upl', 'beyond_limits'], ) chart_statistics = { @@ -2286,6 +2312,8 @@ def _calculate_mr_chart_from_precomputed( strata=strata, lane_boundaries=xmr_lane_boundaries, phased=True, + single_point_phases=precomputed.get('single_point_phases'), + phases=r_phases, ) # --- Non-phased precomputed path --- @@ -2396,12 +2424,14 @@ def _calculate_mr_chart_stratified( # noqa: C901 else: out['_phase_key'] = list(map(tuple, out[collapsed_factors].to_numpy())) - # 2. Assign within-stratum phase IDs via transitions of _phase_key + # 2. Assign within-stratum phase IDs via transitions of _phase_key, and the + # phase's display label (the lane label) on every row out['_phase_id'] = ( out.groupby(stratify_col, sort=False)['_phase_key'] .transform(lambda s: (s != s.shift()).cumsum()) .astype('int64') ) + out['phase'] = _lane_labels(out, collapsed_factors) # 3. Per-phase mR (reset at phase boundaries within each stratum) out['mr'] = out.groupby([stratify_col, '_phase_id'], sort=False)[mr_source_col].diff().abs() @@ -2455,12 +2485,15 @@ def _calculate_mr_chart_stratified( # noqa: C901 # 11. Track single-point phases n_single_point = int((phase_stats['_n'] < 2).sum()) + phases = _split_phase_table( + _mr_phase_table(phase_stats, out, [stratify_col, '_phase_id'], spec.round_to), stratify_col + ) # Format output extra_cols = [c for c in stratify_by if c != spec.rsg_var_name and c != spec.time_var] chart_out = self._build_output_columns( df=out, - value_cols=extra_cols + [plot_col, 'center', 'lpl', 'upl', 'beyond_limits'], + value_cols=extra_cols + ['phase', plot_col, 'center', 'lpl', 'upl', 'beyond_limits'], ) result.update(_build_stratified_mr_payload( @@ -2474,12 +2507,14 @@ def _calculate_mr_chart_stratified( # noqa: C901 lane_boundaries=lane_boundaries, phased=True, single_point_phases=n_single_point, + phases=phases, )) if _return_intermediates: result['_intermediates'] = { 'out': out, 'phase_stats': phase_stats, + 'single_point_phases': n_single_point, 'stratify_col': stratify_col, 'stratify_by': stratify_by, 'strata': strata, @@ -2614,7 +2649,7 @@ def _calculate_mr_chart_ungrouped( # noqa: C901 xmr_lane_boundaries = _precomputed['lane_boundaries'] # Recompute R-specific limits per phase - r_phase_stats = phase_stats[['_phase_id', 'mR']].copy() + r_phase_stats = phase_stats[['_phase_id', '_n', 'mR']].copy() r_lims = r_phase_stats.apply( lambda row: calculate_limits( mean=0, @@ -2628,6 +2663,11 @@ def _calculate_mr_chart_ungrouped( # noqa: C901 ) r_phase_stats = _join_limits(r_phase_stats, r_lims) r_phase_stats['center'] = r_phase_stats['mR'] + r_phases = ( + _mr_phase_table(r_phase_stats, out, ['_phase_id'], spec.round_to) + .drop(columns=['_phase_id']) + .reset_index(drop=True) + ) # Replace X limits with mR limits out = out.drop(columns=['center', 'lpl', 'upl'], errors='ignore') @@ -2646,7 +2686,7 @@ def _calculate_mr_chart_ungrouped( # noqa: C901 chart_out = self._build_output_columns( df=out, - value_cols=[plot_col, 'center', 'lpl', 'upl', 'beyond_limits'], + value_cols=['phase', plot_col, 'center', 'lpl', 'upl', 'beyond_limits'], ) chart_statistics = { @@ -2665,6 +2705,8 @@ def _calculate_mr_chart_ungrouped( # noqa: C901 plot_col=plot_col, lane_boundaries=xmr_lane_boundaries, phased=True, + single_point_phases=_precomputed.get('single_point_phases'), + phases=r_phases, )) return result @@ -2738,8 +2780,10 @@ def _calculate_mr_chart_ungrouped( # noqa: C901 # === Phased limits path === if phased and collapsed_factors: self._reject_calibration('phased X/mR') - # 1. Assign phase_id: contiguous runs of the same rsg_key + # 1. Assign phase_id: contiguous runs of the same rsg_key, and the phase's display + # label (the lane label) on every row out['_phase_id'] = (out['rsg_key'] != out['rsg_key'].shift()).cumsum().astype('int64') + out['phase'] = _lane_labels(out, collapsed_factors) # 2. Per-phase moving range (reset at boundaries) out['mr'] = out.groupby('_phase_id', sort=False)[mr_source_col].diff().abs() @@ -2806,7 +2850,7 @@ def _calculate_mr_chart_ungrouped( # noqa: C901 # 9. Format output chart_out = self._build_output_columns( df=out, - value_cols=[plot_col, 'center', 'lpl', 'upl', 'beyond_limits'], + value_cols=['phase', plot_col, 'center', 'lpl', 'upl', 'beyond_limits'], ) # 10. Statistics: limits_vary=True signals stepped rendering @@ -2818,8 +2862,13 @@ def _calculate_mr_chart_ungrouped( # noqa: C901 'limits_vary': True, } - # 11. Track single-point phases for visibility + # 11. Track single-point phases for visibility, and the per-phase table n_single_point = int((phase_stats['_n'] < 2).sum()) + phases = ( + _mr_phase_table(phase_stats, out, ['_phase_id'], spec.round_to) + .drop(columns=['_phase_id']) + .reset_index(drop=True) + ) result.update(_build_ungrouped_mr_payload( mr_spec=mr_spec, @@ -2829,6 +2878,7 @@ def _calculate_mr_chart_ungrouped( # noqa: C901 lane_boundaries=lane_boundaries, phased=True, single_point_phases=n_single_point, + phases=phases, )) if _return_intermediates: @@ -2839,6 +2889,7 @@ def _calculate_mr_chart_ungrouped( # noqa: C901 'is_stratified': False, 'phased': True, 'phase_stats': phase_stats, + 'single_point_phases': n_single_point, } return result diff --git a/processbehavior/analysis_result.py b/processbehavior/analysis_result.py index 3a3f2b8..13cd389 100644 --- a/processbehavior/analysis_result.py +++ b/processbehavior/analysis_result.py @@ -656,21 +656,34 @@ def statistics(self, chart: str, stratum: str | None = None) -> dict: ) return dict(stats[stratum]) - def phase_statistics(self, chart: str) -> pd.DataFrame: + def phase_statistics(self, chart: str, stratum: str | None = None) -> 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. + Xbar/S 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. + + X/mR columns: ``phase``, ``points``, ``center``, ``mR_bar``, ``lpl``, ``upl``. On the + mR chart ``center`` is ``mR_bar``. + + Parameters + ---------- + chart : str + Chart name, e.g. ``'Xbar'`` or ``'X'``. + stratum : str, optional + Required on a stratified phased chart (X/mR with ``by=[factor]``), where each + stratum has its own phases; rejected otherwise. ``focus(stratum)`` gives the same + table without the argument. 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). + On a chart that is not phased, or when a stratum is needed and not given, given + and not needed, or unknown. ChartNotAvailableError If the chart is not in this result. """ @@ -680,16 +693,30 @@ def phase_statistics(self, chart: str) -> pd.DataFrame: 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'): + if not payload.get('metadata', {}).get('phased') or payload.get('phases') is None: 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: + phases = payload['phases'] + if isinstance(phases, dict): + available = list(phases) + if stratum is None: + raise ValidationError( + f'Chart {chart!r} is stratified, so a stratum is required to return one set of ' + f'phases.\nAvailable strata: {available}\n' + f'Use result.phase_statistics({chart!r}, stratum={available[0]!r}) ' + f'or result.focus({available[0]!r}).phase_statistics({chart!r}).' + ) + if stratum not in phases: + raise ValidationError( + f'Stratum {stratum!r} not found in chart {chart!r}.\nAvailable strata: {available}' + ) + return phases[stratum].copy() + if stratum is not 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}))." + f'This result is not stratified, so stratum={stratum!r} does not apply.\n' + f'Use result.phase_statistics({chart!r}) with no stratum.' ) return phases.copy() diff --git a/processbehavior/focused_result.py b/processbehavior/focused_result.py index 1bc4982..a6823e4 100644 --- a/processbehavior/focused_result.py +++ b/processbehavior/focused_result.py @@ -99,6 +99,11 @@ def _build_focused_chart_info( focused_stats = _extract_stratum_statistics(chart_name, chart_info, stratum) focused_lb = _extract_stratum_lane_boundaries(parent_metadata, stratum) + # A phased stratified chart keeps one per-phase table per stratum; the focused chart + # carries its own, in the unstratified shape phase_statistics() reads. + phases = chart_info.get("phases") + focused_phases = {"phases": phases[stratum]} if isinstance(phases, dict) and stratum in phases else {} + return { "data": focused_data, "statistics": focused_stats, @@ -108,6 +113,7 @@ def _build_focused_chart_info( "focused_stratum": stratum, "lane_boundaries": focused_lb, }, + **focused_phases, } diff --git a/tests/test_phased_limits.py b/tests/test_phased_limits.py index 82b435d..685ee6d 100644 --- a/tests/test_phased_limits.py +++ b/tests/test_phased_limits.py @@ -470,16 +470,22 @@ def test_explicit_rules_run_against_each_phase_centre(self): # Phase 1 sits above its centre 0, phase 2 below its centre 10: one run each. # Phase 2's last two points fall in its lower zone A (7 to 8, sigma 1). - data = pd.DataFrame({ - 'x': [1.0] * 8 + [9.0] * 6 + [7.5, 7.5], - 'center': [0.0] * 8 + [10.0] * 8, - 'upl': [3.0] * 8 + [13.0] * 8, - 'lpl': [-3.0] * 8 + [7.0] * 8, - }) + data = pd.DataFrame( + { + 'x': [1.0] * 8 + [9.0] * 6 + [7.5, 7.5], + 'center': [0.0] * 8 + [10.0] * 8, + 'upl': [3.0] * 8 + [13.0] * 8, + 'lpl': [-3.0] * 8 + [7.0] * 8, + } + ) stats = {'N': None, 'center': None, 'lpl': None, 'upl': None, 'limits_vary': True} signals = SignalDetector().detect( - data, stats, SignalConfig(enabled_rules=['rule_2', 'rule_4'], min_observations=1), - value_col='x', chart_type='X', run_rules_applicable=False, + data, + stats, + SignalConfig(enabled_rules=['rule_2', 'rule_4'], min_observations=1), + value_col='x', + chart_type='X', + run_rules_applicable=False, ) assert signals.rules_evaluated == ['rule_2', 'rule_4'] assert set(signals.by_rule['rule_4']['obs_id']) == {7, 15} @@ -488,3 +494,83 @@ def test_explicit_rules_run_against_each_phase_centre(self): def test_unphased_x_keeps_every_rule(self, sds1_study): signals = sds1_study.execute(chart='X', by=[]).detect_signals(chart='X') assert signals.rules_evaluated == [f'rule_{i}' for i in range(1, 9)] + + +# ============================================================================= +# Phase column and per-phase statistics (#144, parity with phased Xbar/S) +# ============================================================================= + + +class TestPhaseColumnAndStatistics: + """Phased X/mR name each row's phase and expose one row per phase, as phased Xbar/S do.""" + + def test_phase_column_is_the_lane_label(self, sds1_study): + result = sds1_study.execute(chart='X', by=[], phased=True, companion=True) + for chart in ('X', 'mR'): + data = result.get_chart(chart) + assert list(data.columns) == [ + 'time', + 'rsg', + 'phase', + data.columns[3], + 'center', + 'lpl', + 'upl', + 'beyond_limits', + 'obs_id', + ] + assert list(data['phase']) == list(data['rsg'].astype(str)) + # one centre per phase, and every row of a phase shares it + assert data.groupby('phase', sort=False)['center'].nunique().eq(1).all() + assert list(result.chart_table(chart).columns[:3]) == ['Obs', 'subgroup', 'phase'] + + def test_phase_statistics_x_and_mr(self, sds1_study): + result = sds1_study.execute(chart='X', by=[], phased=True, companion=True) + x, mr = result.phase_statistics('X'), result.phase_statistics('mR') + assert list(x.columns) == ['phase', 'points', 'center', 'mR_bar', 'lpl', 'upl'] + assert list(x.columns) == list(mr.columns) + data = result.get_chart('X') + assert list(x['phase']) == list(dict.fromkeys(data['phase'])) # display order, one row each + assert x['points'].sum() == len(data) + for _, row in x.iterrows(): + rows = data[data['phase'] == row['phase']] + assert rows['center'].iloc[0] == row['center'] + assert (rows['lpl'].iloc[0], rows['upl'].iloc[0]) == (row['lpl'], row['upl']) + assert (mr['center'] == mr['mR_bar']).all() + assert (mr['lpl'] == 0).all() + pd.testing.assert_series_equal(mr['mR_bar'], x['mR_bar']) + + def test_companion_mr_carries_single_point_phases(self, sds1_study): + result = sds1_study.execute(chart='X', by=[], phased=True, companion=True) + x_meta, mr_meta = result.charts['X']['metadata'], result.charts['mR']['metadata'] + assert 'single_point_phases' in mr_meta + assert mr_meta['single_point_phases'] == x_meta['single_point_phases'] + assert x_meta['single_point_phases'] == int((result.phase_statistics('X')['points'] < 2).sum()) + + def test_stratified_phases_by_stratum_and_focus(self, sds1_study): + result = sds1_study.execute(chart='X', by=['factor 1'], phased=True, companion=True) + for chart in ('X', 'mR'): + assert 'phase' in result.get_chart(chart).columns + with pytest.raises(ValidationError, match='stratum is required'): + result.phase_statistics(chart) + with pytest.raises(ValidationError, match='not found'): + result.phase_statistics(chart, stratum='no such stratum') + for stratum in result.strata: + table = result.phase_statistics(chart, stratum=stratum) + assert list(table.columns) == ['phase', 'points', 'center', 'mR_bar', 'lpl', 'upl'] + focused = result.focus(stratum) + pd.testing.assert_frame_equal(focused.phase_statistics(chart), table) + data = focused.get_chart(chart) + assert list(table['phase']) == list(dict.fromkeys(data['phase'])) + assert table['points'].sum() == len(data) + assert 'single_point_phases' in result.charts[chart]['metadata'] + + def test_unphased_charts_have_no_phase(self, sds1_study): + result = sds1_study.execute(chart='X', by=[], companion=True) + for chart in ('X', 'mR'): + assert 'phase' not in result.get_chart(chart).columns + with pytest.raises(ValidationError, match='not phased'): + result.phase_statistics(chart) + phased = sds1_study.execute(chart='X', by=[], phased=True) + with pytest.raises(ValidationError, match='does not apply'): + phased.phase_statistics('X', stratum='F1_1_F2_1') diff --git a/tests/test_phased_xbar_s.py b/tests/test_phased_xbar_s.py index c4b3ebc..4eb51c7 100644 --- a/tests/test_phased_xbar_s.py +++ b/tests/test_phased_xbar_s.py @@ -411,11 +411,11 @@ def test_chart_table_has_phase_after_subgroup(self, sds1_study): 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): + def test_phase_statistics_refuses_unphased(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') + with pytest.raises(ValidationError, match='does not apply'): + sds1_study.execute(chart='Xbar', phased=True).phase_statistics('Xbar', stratum='1_1') 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)