diff --git a/CHANGELOG.md b/CHANGELOG.md index f62957d..09a447e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **A bare response is charted in the order received.** `formulate(response=...)` with no time, + factors or plan used to be refused. The values are now numbered 1..N in an `Observation` column + (the same numbers as `obs_id`) and the study is exactly the one `time='Observation'` gives: a + 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). + +### Changed +- **`formulate(response=..., factors=[])` with no time is the same study.** It used to treat the + whole column as one replicated cell: ADS 1, a recommended one-point Xbar chart, the "within-cell + replication" series-length line, and an X chart plotted against the response values. Its chart + tables now start with `Observation` (#129). + ## [0.4.0] - 2026-10-14 This release aligns the library with Bishop's VAS documentation manual dated 10-12-2026 and diff --git a/README.md b/README.md index 5fca995..cc7049c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,22 @@ pip install "processbehavior[images]" # kaleido, for static plot images ## Quickstart +A column of values, charted in the order received, is an X/mR chart in two calls: + +```python +import pandas as pd +import processbehavior as pb + +df = pd.DataFrame({'weight': [50.2, 49.8, 50.5, 50.1, 49.6, 50.9, 50.3, 49.9, 50.4, 50.0, 49.7, 50.6]}) + +study = pb.formulate(df, response='weight') # no time column: rows are numbered 1..N as 'Observation' +result = study.execute(companion=True) # X chart with its mR companion +print(result.statistics('X')) # {'N': 1, 'center': 50.167, 'lpl': 48.619, 'upl': 51.714} +result.plot() +``` + +When the data has a time order, factors, or both, name them. The design state routes the analysis: + ```python import processbehavior as pb diff --git a/docs/examples/xmr-chart-in-python.md b/docs/examples/xmr-chart-in-python.md index 8b99c02..fcdb2ff 100644 --- a/docs/examples/xmr-chart-in-python.md +++ b/docs/examples/xmr-chart-in-python.md @@ -14,11 +14,11 @@ from processbehavior import ProcessBehavior rng = np.random.default_rng(3) df = pd.DataFrame({ - 'week': range(1, 25), 'on_time_pct': np.clip(rng.normal(92, 2.5, 24), 80, 100).round(1), }) -study = ProcessBehavior(df).formulate(response='on_time_pct', time='week') +# No time column: the values are charted in the order received +study = ProcessBehavior(df).formulate(response='on_time_pct') result = study.execute(chart='X', companion=True) print(result.get_statistics('X')) @@ -33,6 +33,14 @@ Output: {'N': 2, 'center': 3.261, 'lpl': 0.0, 'upl': 10.654} ``` +With no time column the rows are numbered 1..N in an `Observation` column, in the +order received. If your data carries its own order (a `week` column, a timestamp), +name it and the points are charted in that order instead: + +```python +study = ProcessBehavior(df).formulate(response='on_time_pct', time='week') +``` + ## Reading it - `center` on the X chart is the process's typical level; `lpl`/`upl` are the diff --git a/docs/getting-started/quickstart.ipynb b/docs/getting-started/quickstart.ipynb index 1b9436b..d82b9bf 100644 --- a/docs/getting-started/quickstart.ipynb +++ b/docs/getting-started/quickstart.ipynb @@ -10,11 +10,12 @@ "\n", "## What You'll Learn\n", "\n", - "1. Load data into a ProcessBehavior wrapper\n", - "2. Formulate a study with response, factors, and time\n", - "3. Execute the analysis and create control charts\n", - "4. Detect signals (out-of-control points)\n", - "5. Export results" + "1. Chart a plain list of values in two calls\n", + "2. Load data into a ProcessBehavior wrapper\n", + "3. Formulate a study with response, factors, and time\n", + "4. Execute the analysis and create control charts\n", + "5. Detect signals (out-of-control points)\n", + "6. Export results" ] }, { @@ -44,6 +45,48 @@ "from processbehavior import ProcessBehavior" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## The two-call version: a list of values\n", + "\n", + "A column of values with no time column is charted in the order received. The rows are numbered 1..N in an `Observation` column and the study is a single stream (ADS 2), so the recommended chart is X, with its mR companion:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Recommended chart: X\n", + "T=12. Sigma for X/mR rests on 11 moving ranges.\n", + "Center 50.167, limits 48.619 to 51.714\n" + ] + } + ], + "source": [ + "values = pd.DataFrame({'weight': [50.2, 49.8, 50.5, 50.1, 49.6, 50.9, 50.3, 49.9, 50.4, 50.0, 49.7, 50.6]})\n", + "\n", + "quick = ProcessBehavior(values).formulate(response='weight')\n", + "x = quick.execute(companion=True).statistics('X')\n", + "\n", + "print(f\"Recommended chart: {quick.recommended_chart}\")\n", + "print(quick.series_length_description)\n", + "print(f\"Center {x['center']}, limits {x['lpl']} to {x['upl']}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If the rows are not in time order, pass the time column with `time=`. The rest of this tutorial works with a richer dataset: factors, a time order and replicates." + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/docs/user-guide/formulation.md b/docs/user-guide/formulation.md index 028b30f..1e1b39a 100644 --- a/docs/user-guide/formulation.md +++ b/docs/user-guide/formulation.md @@ -171,9 +171,20 @@ study = pb.formulate( response=pb.cols.temperature, time=pb.cols.day ) -# Results in DS 4, recommends X +# Results in ADS 2, recommends X ``` +With no time column either, the values are charted in the order received. They are +numbered 1..N in an `Observation` column and the study is the one `time='Observation'` +would give; the design report says `Time: received order (no time column given)`: + +```python +study = pb.formulate(response=pb.cols.temperature) +# Results in ADS 2, recommends X +``` + +If the rows are not in time order, pass the time column. + ### Pattern 2: Comparing Groups Factors but no time dimension: diff --git a/docs/user-guide/series-length.md b/docs/user-guide/series-length.md index 5e41ceb..3371632 100644 --- a/docs/user-guide/series-length.md +++ b/docs/user-guide/series-length.md @@ -47,7 +47,9 @@ knows what the study is for. Series length: T=2. Sigma for X/mR rests on a single moving range. ``` -- **No time variable and no replication.** Nothing to say; the line is omitted. +- **No time variable and no replication.** Nothing to say; the line is omitted. A bare + response (no time, factors or plan) is not this case: its values are numbered in the + order received, so it has T = N and the moving-range line above. The same numbers appear in code as `study.series_length`, a frozen record with `T`, `n_moving_ranges`, `within_cell_df` (the sum over cells of N_kt − 1), `sigma_from_time`, diff --git a/processbehavior/formulation_spec.py b/processbehavior/formulation_spec.py index 884ead7..8e9df15 100644 --- a/processbehavior/formulation_spec.py +++ b/processbehavior/formulation_spec.py @@ -46,6 +46,11 @@ class FormulationSpec: Delimiter for multi-variable grouping. Default '_'. unit_of_analysis : str or None The fundamental entity being measured (informational only). + received_order : bool + True when ``time_var`` is the ``RECEIVED_ORDER_COL`` that formulate() + added because no time, factors or plan were given: the values are + charted in the order received. Informational (the design report says + so); the analysis is the ordinary single-stream one. Raises ------ @@ -72,6 +77,7 @@ class FormulationSpec: rsg_var_name: str = 'rsg' rsg_var_delim: str = '_' unit_of_analysis: str | None = None + received_order: bool = False def __post_init__(self): if self.response_var is None: diff --git a/processbehavior/process_behavior.py b/processbehavior/process_behavior.py index 0db54da..019ffe0 100644 --- a/processbehavior/process_behavior.py +++ b/processbehavior/process_behavior.py @@ -39,6 +39,7 @@ from .exceptions import ColumnNotFoundError, ProcessBehaviorWarning, ValidationError from .formulation_spec import FormulationSpec from .sds_detector import SDSRegistry, SDSResult +from .spc_constants import RECEIVED_ORDER_COL if TYPE_CHECKING: from .study import Study @@ -403,21 +404,18 @@ def __dir__(self): # --------------------------------------------------------------------------- -def _validate_factors_or_plan_args( - factors: list | None, plan: dict | None, time: object | None = None -) -> None: +def _validate_factors_or_plan_args(factors: list | None, plan: dict | None) -> None: """Enforce the factors XOR plan contract for formulate(). Both provided is rejected (ambiguous source of factor structure). - Omitting both is fine as long as there is a ``time`` column: that is a - single measurement stream over time — the most common SPC study there is, - and an X/mR chart handles it. It used to be refused outright, while the - equivalent ``factors=[]`` was accepted and worked, so the two spellings of - "no factors" did opposite things and the refusal named neither. - - With no factors, no plan *and* no time there is nothing to analyse - against: no grid, and no order to plot in. + Omitting both is fine. With a ``time`` column that is a single measurement + stream over time — the most common SPC study there is, and an X/mR chart + handles it. With no time either, the values are charted in the order + received: formulate() numbers them 1..N in ``RECEIVED_ORDER_COL`` and the + study is the same single stream (#129). Both used to be refused, while the + equivalent ``factors=[]`` was accepted, so the spellings of "no factors" + did different things and the refusal named neither. """ if factors is not None and plan is not None: raise ValidationError( @@ -425,17 +423,6 @@ def _validate_factors_or_plan_args( ' • factors=[...] to infer structure from observed data (complete designs)\n' ' • plan={col: [levels], ...} to specify expected structure (complete + incomplete designs)' ) - if factors is None and plan is None and time is None: - raise ValidationError( - 'A study needs either a time order or a grouping structure — this has neither.\n\n' - 'Add whichever describes your data:\n' - " • time='' — a single measurement stream in order (X/mR charts)\n" - " • factors=['', ...] — categorical variables defining subgroups\n" - ' • plan={column: [levels], ...} — expected factor levels, which also\n' - ' lets incomplete designs be detected\n\n' - 'A single stream is the common case:\n' - " study = pb.formulate(df, response='weight', time='hour')" - ) def _compute_pds( @@ -730,6 +717,11 @@ def read_clipboard(cls, na_values: list[str] | None = None, **kwargs) -> Process Copy data from Excel, then: >>> pb = ProcessBehavior.read_clipboard() + + A pasted column of values with a header, charted in the order pasted: + + >>> study = ProcessBehavior.read_clipboard().formulate(response='Weight') + >>> study.execute().plot() """ df = pd.read_clipboard(**kwargs) return cls(df, na_values=na_values) @@ -1043,7 +1035,7 @@ def formulate( Examples -------- - Basic formulation (infer factors from data): + A list of values, charted in the order received (X/mR): >>> pb = ProcessBehavior(df) >>> study = pb.formulate(response='weight') @@ -1074,33 +1066,27 @@ def formulate( Notes ----- - **Implicit Time Ordering** - - When no ``time`` parameter is specified, the system treats observation - order as implicit time. This design decision is intentional: + **Received order** - 1. Wheeler's XmR chart fundamentally assumes temporal ordering - moving - ranges between consecutive observations only make sense in sequence. + With no ``time``, ``factors`` or ``plan``, the values are charted in the + order received. formulate() numbers them 1..N in an ``Observation`` + column (the same numbers as ``obs_id``) and the study is exactly the one + ``time='Observation'`` would give: a single stream, ADS 2, charted with + X/mR. The design report says the order is the received order. + ``factors=[]`` with no time is the same study. - 2. The ``obs_id`` column (assigned during data preparation) serves as - the implicit time dimension, preserving the order in which data was - provided. + Moving ranges between consecutive values only make sense in sequence. + If the rows are NOT in time order, pass the ``time`` column. - If your observations are NOT in temporal order, you MUST specify the - ``time`` parameter to ensure correct analysis. - - **Factors are optional; structure is not** + **Factors are optional** Supply a grouping structure (``factors`` or ``plan``), a ``time`` - column, or both. Bishop's design-state classification works over a - factor × time grid, and a single measurement stream in time order is - the degenerate case of one — charted with X/mR:: + column, both, or neither. Bishop's design-state classification works + over a factor × time grid, and a single measurement stream in time + order is the degenerate case of one — charted with X/mR:: study = pb.formulate(df, response='weight', time='hour') - Only a study with no factors, no plan *and* no time is refused: there - is no grid to classify and no order to plot in. - **SDS Detection** SDS detection runs on raw data (before NA rows are dropped) to preserve @@ -1112,8 +1098,8 @@ def formulate( Study : The returned Study object ColumnRef : Column reference with .levels property for discoverability """ - # 1. Validate the factors/plan combination + presence. - _validate_factors_or_plan_args(factors, plan, time) + # 1. Validate the factors/plan combination. + _validate_factors_or_plan_args(factors, plan) # 2. Normalize inputs into (response_str, time_str, factors_str, # sampling_plan, factor_order, T_planned, N_planned). @@ -1123,6 +1109,13 @@ def formulate( self._resolve_factors_and_plan(factors, plan) ) + # No time, factors or plan (factors=[] included): the values in the order + # received, numbered in RECEIVED_ORDER_COL (step 4b). From here on it is the + # single stream time=RECEIVED_ORDER_COL gives (#129). + received_order = time_str is None and not factors_str and plan is None + if received_order: + time_str = RECEIVED_ORDER_COL + # 3. Build the FormulationSpec. spec = FormulationSpec( response_var=response_str, @@ -1130,6 +1123,7 @@ def formulate( time_var=time_str, round_to=precision, unit_of_analysis=unit_of_analysis, + received_order=received_order, ) # 4. Materialize pending derived variables ONCE into the augmented frame, @@ -1138,6 +1132,23 @@ def formulate( # never self.data — so a derived factor is seen consistently and the # materialize-once ordering cannot drift in a later refactor. frame, resolved_derivations = self._materialize_derivations() + + # 4b. Number the rows in received order on the raw frame, before ODS + # detection, so an NA response keeps its place as an empty cell — the + # same numbers obs_id gets in data preparation. Copy first: with no + # derivations the frame is self.data itself. + if received_order: + if RECEIVED_ORDER_COL in frame.columns: + raise ValidationError( + f'No time column was given, so the values are charted in the order ' + f"received and numbered in a column called '{RECEIVED_ORDER_COL}' — " + f'but the data already has one.\n\n' + f" • If it gives the order: time='{RECEIVED_ORDER_COL}'\n" + f' • Otherwise rename it' + ) + frame = frame.copy() + frame[RECEIVED_ORDER_COL] = range(1, len(frame) + 1) + return self._formulate_from_frame( frame, spec, @@ -1263,6 +1274,10 @@ def formulate( >>> study = pb.formulate(df, response='weight', factors=['machine'], time='shift') >>> study.execute().plot() + A plain list of values is charted in the order received (X/mR): + + >>> study = pb.formulate(df, response='weight') + Reach for :class:`ProcessBehavior` when you need the fluent derivation verbs, since those must be attached before formulating:: diff --git a/processbehavior/spc_constants.py b/processbehavior/spc_constants.py index 1b4323a..adfe7af 100644 --- a/processbehavior/spc_constants.py +++ b/processbehavior/spc_constants.py @@ -602,6 +602,16 @@ def detect_beyond_limits(x: float, lpl: float, upl: float) -> int: return 0 +# ============================================================================ +# Received-order time column +# ============================================================================ + +# The time column formulate() adds when a study names no time, factors or plan: the +# values numbered 1..N in the order received (the same numbers as obs_id). The study is +# then the single stream formulate(response, time=RECEIVED_ORDER_COL) would give (#129). +RECEIVED_ORDER_COL = 'Observation' + + # ============================================================================ # Chart Name Constants # ============================================================================ diff --git a/processbehavior/study.py b/processbehavior/study.py index 2f4a935..01ff909 100644 --- a/processbehavior/study.py +++ b/processbehavior/study.py @@ -190,6 +190,7 @@ class DesignReport: _ods_result: SDSResult | None = None # Observed Design State _ads_result: SDSResult | None = None # Analytical Design State _series_length: SeriesLengthPrecision | None = None # Where sigma comes from, and how precise + _received_order: bool = False # Time is the received order formulate() numbered (#129) @property def factors_table(self) -> pd.DataFrame: @@ -758,6 +759,8 @@ def __repr__(self) -> str: # Compact metrics line (includes min cell size) lines.append(self._repr_metrics_line()) + if self._received_order: + lines.append(' Time: received order (no time column given)') # Factor details lines.append('') @@ -1729,6 +1732,7 @@ def design(self) -> DesignReport: _ods_result=self._sds_result, _ads_result=self._ads.analytical_design_state, _series_length=self.series_length, + _received_order=self._spec.received_order, ) def capability( diff --git a/tests/test_process_behavior.py b/tests/test_process_behavior.py index d0e3ef2..1c515c0 100644 --- a/tests/test_process_behavior.py +++ b/tests/test_process_behavior.py @@ -12,6 +12,7 @@ """ import logging +from pathlib import Path import numpy as np import pandas as pd @@ -19,6 +20,9 @@ from processbehavior.exceptions import ColumnNotFoundError, FactorNotFoundError, ValidationError from processbehavior.process_behavior import ColumnAccessor, ProcessBehavior +from processbehavior.spc_constants import RECEIVED_ORDER_COL + +REFERENCE_CSV = Path(__file__).resolve().parent.parent / 'validation' / 'PBTESTDATABASE_T100.csv' # ============================================================================ # Test: ColumnAccessor - Auto-completion support @@ -1018,13 +1022,14 @@ def test_string_date_column_still_converts_to_datetime(self): class TestSingleStreamFormulation: - """A response and a time column is a complete study. + """A response and a time column is a complete study; so is a bare response. Omitting `factors` used to be refused outright while the equivalent - `factors=[]` was accepted and worked — two spellings of "no factors" - doing opposite things, with the refusal naming neither. An individuals - chart of one measurement over time is the most common SPC study there is, - so it is the one thing that must not need a trick to express. + `factors=[]` was accepted — two spellings of "no factors" doing different + things, with the refusal naming neither. An individuals chart of one + measurement over time is the most common SPC study there is, so it is the + one thing that must not need a trick to express. With no time column + either, the values are charted in the order received (#129). """ @staticmethod @@ -1061,16 +1066,6 @@ def test_module_level_formulate_agrees(self): study = pb.formulate(self._stream(), response='weight', time='hour') assert study.recommended_chart - def test_no_structure_at_all_is_refused(self): - """No factors, no plan, no time — no grid and no order to plot in.""" - df = self._stream() - with pytest.raises(ValidationError) as exc: - ProcessBehavior(df).formulate(response='weight') - - message = str(exc.value) - assert 'time' in message and 'factors' in message - assert "time='hour'" in message or "time=''" in message - def test_factors_and_plan_together_still_rejected(self): df = self._stream() df['machine'] = ['A', 'B'] * 15 @@ -1078,3 +1073,112 @@ def test_factors_and_plan_together_still_rejected(self): ProcessBehavior(df).formulate( response='weight', factors=['machine'], plan={'machine': ['A', 'B']}, time='hour' ) + + +class TestReceivedOrderFormulation: + """No time, factors or plan: the values are charted in the order received (#129). + + formulate() numbers the rows 1..N in RECEIVED_ORDER_COL — the same numbers as + obs_id — on the raw frame, so the study must be exactly the one an explicit + time column holding those numbers gives. Checked on Bishop's reference data. + """ + + @staticmethod + def _reference(column): + df = pd.read_csv(REFERENCE_CSV, na_values=['*']) + return df[[column]] + + @staticmethod + def _numbered(df): + return df.assign(**{RECEIVED_ORDER_COL: range(1, len(df) + 1)}) + + def _assert_same_study(self, received, explicit): + assert received.observed_design_state.sds == explicit.observed_design_state.sds + assert received.analytical_design_state.sds == explicit.analytical_design_state.sds + assert received.recommended_chart == explicit.recommended_chart + assert received.series_length == explicit.series_length + pd.testing.assert_frame_equal(received.dataset, explicit.dataset) + got = received.execute(chart='X', companion=True) + want = explicit.execute(chart='X', companion=True) + for chart in ('X', 'mR'): + pd.testing.assert_frame_equal(got.get_chart(chart), want.get_chart(chart)) + assert got.statistics(chart) == want.statistics(chart) + + @pytest.mark.parametrize('factors', [None, []], ids=['omitted', 'empty']) + def test_matches_explicit_time_on_reference_data(self, factors): + df = self._reference('PM SDS 1') + received = ProcessBehavior(df).formulate(response='PM SDS 1', factors=factors) + explicit = ProcessBehavior(self._numbered(df)).formulate(response='PM SDS 1', time=RECEIVED_ORDER_COL) + + assert received.time == RECEIVED_ORDER_COL + assert received.analytical_design_state.sds == 2 + assert received.recommended_chart == 'X' + self._assert_same_study(received, explicit) + + def test_na_responses_are_empty_cells_in_order(self): + """An NA keeps its place: ODS sees an empty cell, ADS charts the rest.""" + df = self._reference('PM SDS 2') + n_values = int(df['PM SDS 2'].notna().sum()) + assert n_values < len(df) # the reference column has '*' gaps + + received = ProcessBehavior(df).formulate(response='PM SDS 2') + explicit = ProcessBehavior(self._numbered(df)).formulate(response='PM SDS 2', time=RECEIVED_ORDER_COL) + + assert received.observed_design_state.sds == 5 + assert received.analytical_design_state.sds == 2 + assert n_values == received.series_length.T + self._assert_same_study(received, explicit) + + def test_design_report_says_received_order(self): + df = self._reference('PM SDS 1') + report = repr(ProcessBehavior(df).formulate(response='PM SDS 1').design()) + assert 'Time: received order (no time column given)' in report + assert f'T: {len(df)}' in report + assert f'{len(df) - 1} moving ranges' in report + + explicit = ProcessBehavior(self._numbered(df)).formulate(response='PM SDS 1', time=RECEIVED_ORDER_COL) + assert 'received order' not in repr(explicit.design()) + + def test_received_order_is_what_is_charted(self): + df = self._reference('PM SDS 1') + shuffled = df.sample(frac=1, random_state=0).reset_index(drop=True) + mr = ProcessBehavior(df).formulate(response='PM SDS 1').execute(chart='mR').get_chart('mR') + mr_shuffled = ProcessBehavior(shuffled).formulate(response='PM SDS 1').execute(chart='mR').get_chart('mR') + assert not np.allclose(mr['mr'].dropna().to_numpy(), mr_shuffled['mr'].dropna().to_numpy()) + + @pytest.mark.parametrize('chart', ['Xbar', 'S']) + def test_subgroup_charts_need_replication(self, chart): + study = ProcessBehavior(self._reference('PM SDS 1')).formulate(response='PM SDS 1') + with pytest.raises(ValidationError, match='requires replicated observations'): + study.execute(chart=chart) + assert 'replicat' in study.why_not(chart).lower() + + def test_existing_column_of_that_name_is_not_overwritten(self): + df = pd.DataFrame({'weight': [1.0, 2.0, 3.0, 4.0], RECEIVED_ORDER_COL: [4, 3, 2, 1]}) + with pytest.raises(ValidationError, match=f"time='{RECEIVED_ORDER_COL}'"): + ProcessBehavior(df).formulate(response='weight') + + def test_input_is_not_mutated_and_can_be_reformulated(self): + df = self._reference('PM SDS 1') + process = ProcessBehavior(df) + first = process.formulate(response='PM SDS 1') + second = process.formulate(response='PM SDS 1') + assert list(df.columns) == ['PM SDS 1'] + assert RECEIVED_ORDER_COL not in process.data.columns + pd.testing.assert_frame_equal(first.dataset, second.dataset) + + def test_single_value_behaves_like_explicit_time(self): + df = pd.DataFrame({'weight': [5.0]}) + + def outcome(make): + try: + study = make() + except ValidationError as exc: + return ('raises', str(exc)) + return (study.analytical_design_state.sds, study.recommended_chart, study.series_length) + + received = outcome(lambda: ProcessBehavior(df).formulate(response='weight')) + explicit = outcome( + lambda: ProcessBehavior(self._numbered(df)).formulate(response='weight', time=RECEIVED_ORDER_COL) + ) + assert received == explicit diff --git a/tests/test_sds_detector.py b/tests/test_sds_detector.py index 44a93cf..41cb8a8 100644 --- a/tests/test_sds_detector.py +++ b/tests/test_sds_detector.py @@ -1299,11 +1299,13 @@ def test_plan_missing_a_factor_falls_back_with_warning(self, caplog): class TestDegenerateInputs: - def test_response_only_raises_with_guidance(self): - """A study needs time or grouping; the error says which to add.""" + def test_response_only_is_one_stream_in_received_order(self): + """No time or grouping: a 1 × N grid in received order, classified like any other (#129).""" df = pd.DataFrame({'y': [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]}) - with pytest.raises(ValidationError, match='time'): - ProcessBehavior(df).formulate(response='y') + study = ProcessBehavior(df).formulate(response='y') + assert study.observed_design_state.sds == 2 + assert study.analytical_design_state.sds == 2 + assert study.analytical_design_state.min_cell_size == 1 def test_all_na_response_raises(self): df = pd.DataFrame({'y': ['*', 'N/A', '*', 'N/A'], 'time': [1, 2, 3, 4]})