diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f64ca9..e8863f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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) +- **X/mR lane order honours an ordered pandas Categorical factor.** A stage column with categories + `before < after` already ordered the Xbar/S subgroups that way, but the canonical row order sorted + the raw labels, so X/mR lanes, phases and `strata` came out `after, before`. The canonical sort now + uses an ordered Categorical's category codes; plain string and numeric factors sort as before. (#144) ## [0.4.0] - 2026-10-14 diff --git a/processbehavior/data_preparation.py b/processbehavior/data_preparation.py index 0e3db11..f9e4bfa 100644 --- a/processbehavior/data_preparation.py +++ b/processbehavior/data_preparation.py @@ -529,8 +529,19 @@ def build_keys(self, df: pd.DataFrame, spec: FormulationSpec) -> pd.DataFrame: else: out['cell_key'] = out['rsg_key'] - # 3. Sort by canonical order: (cell_key, obs_id) - out = out.sort_values(['cell_key', 'obs_id'], kind='stable') + # 3. Sort by canonical order: (cell_key, obs_id). An ordered Categorical factor sorts by + # its category order rather than its labels, so a stage labelled before < after keeps + # that order on every chart and in every stratum list, not only in the Xbar/S + # aggregation (issue #144). Every other column sorts as the tuple of its values, + # exactly as before: the parallel key exists only when an ordered Categorical is present. + key_cols = (k_vars + [t]) if (k_vars and t) else ([t] if t else list(k_vars)) + ordered_cols = {c for c in key_cols if isinstance(out[c].dtype, pd.CategoricalDtype) and out[c].cat.ordered} + if ordered_cols: + parts = [out[c].cat.codes.astype(object) if c in ordered_cols else out[c].astype(object) for c in key_cols] + out['_order_key'] = list(map(tuple, pd.concat(parts, axis=1).to_numpy())) + out = out.sort_values(['_order_key', 'obs_id'], kind='stable').drop(columns=['_order_key']) + else: + out = out.sort_values(['cell_key', 'obs_id'], kind='stable') # 4. Assign sort_key AFTER sorting (canonical ordering 0..N-1) out['sort_key'] = np.arange(len(out), dtype=np.int64) diff --git a/tests/test_ordered_categorical_lane_order.py b/tests/test_ordered_categorical_lane_order.py new file mode 100644 index 0000000..259cd1e --- /dev/null +++ b/tests/test_ordered_categorical_lane_order.py @@ -0,0 +1,73 @@ +"""An ordered pandas Categorical factor orders the lanes on every chart (issue #144, PR 4). + +The Xbar/S aggregation groups on the factor column and so followed an ordered Categorical's +category order; the canonical row order in ``DataPreparation.build_keys`` sorted the raw labels, +so X/mR lanes, phases and ``strata`` disagreed (``after, before`` for a stage ``before < after``). +Plain string and numeric factors keep their previous order, which the golden masters and the +Bishop gate pin. +""" + +from __future__ import annotations + +import pandas as pd + +import processbehavior as pb + + +def _staged_frame(labels=('before', 'after')) -> pd.DataFrame: + rows = [] + for base, stage in zip((10, 20), labels, strict=True): + for t in range(1, 4): + for v in (0, 1, 2): + rows.append({'stage': stage, 'time': t, 'y': float(base + t + v)}) + return pd.DataFrame(rows) + + +def _lane_order(study, **kwargs) -> list[str]: + data = study.execute(chart='X', by=[], phased=True, **kwargs).get_chart('X') + return list(dict.fromkeys(data['phase'])) + + +class TestOrderedCategorical: + def test_every_view_follows_the_category_order(self): + df = _staged_frame() + ordered = df.assign(stage=pd.Categorical(df['stage'], categories=['before', 'after'], ordered=True)) + study = pb.formulate(ordered, response='y', time='time', factors=['stage']) + + assert _lane_order(study) == ['before', 'after'] + assert study.execute(chart='X', by=['stage']).strata == ['before', 'after'] + xbar = study.execute(chart='Xbar', phased=True) + assert list(xbar.phase_statistics('Xbar')['phase']) == ['before', 'after'] + boundaries = study.execute(chart='X', by=[]).charts['X']['metadata']['lane_boundaries'] + assert [(b['position'], b['label']) for b in boundaries] == [(9, 'after')] + + frame = study._ads.analysis_dataset.sort_values('sort_key') + assert list(frame['stage'].astype(str).unique()) == ['before', 'after'] + assert list(frame['sort_key']) == list(range(len(frame))) + + def test_plain_strings_still_sort_lexically(self): + study = pb.formulate(_staged_frame(), response='y', time='time', factors=['stage']) + assert _lane_order(study) == ['after', 'before'] + assert study.execute(chart='X', by=['stage']).strata == ['after', 'before'] + assert list(study.execute(chart='Xbar', phased=True).phase_statistics('Xbar')['phase']) == ['after', 'before'] + + def test_sortable_labels_need_no_categorical(self): + df = _staged_frame(labels=('1 before', '2 after')) + study = pb.formulate(df, response='y', time='time', factors=['stage']) + assert _lane_order(study) == ['1 before', '2 after'] + + def test_two_factors_with_one_ordered(self): + df = _staged_frame() + two = pd.concat([df.assign(machine=1), df.assign(machine=2, y=df['y'] + 0.5)], ignore_index=True) + two['stage'] = pd.Categorical(two['stage'], categories=['before', 'after'], ordered=True) + study = pb.formulate(two, response='y', time='time', factors=['stage', 'machine']) + assert _lane_order(study) == ['before_1', 'before_2', 'after_1', 'after_2'] + assert study.execute(chart='X', by=['stage']).strata == ['before', 'after'] + + def test_rows_within_a_cell_keep_source_order(self): + df = _staged_frame() + ordered = df.assign(stage=pd.Categorical(df['stage'], categories=['before', 'after'], ordered=True)) + study = pb.formulate(ordered, response='y', time='time', factors=['stage']) + frame = study._ads.analysis_dataset.sort_values('sort_key') + for _, cell in frame.groupby(['stage', 'time'], observed=True, sort=False): + assert list(cell['obs_id']) == sorted(cell['obs_id'])