Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 13 additions & 2 deletions processbehavior/data_preparation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions tests/test_ordered_categorical_lane_order.py
Original file line number Diff line number Diff line change
@@ -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'])
Loading