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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 10 additions & 2 deletions docs/examples/xmr-chart-in-python.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand All @@ -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
Expand Down
53 changes: 48 additions & 5 deletions docs/getting-started/quickstart.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
},
{
Expand Down Expand Up @@ -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": {},
Expand Down
13 changes: 12 additions & 1 deletion docs/user-guide/formulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion docs/user-guide/series-length.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
6 changes: 6 additions & 0 deletions processbehavior/formulation_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
------
Expand All @@ -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:
Expand Down
103 changes: 59 additions & 44 deletions processbehavior/process_behavior.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -403,39 +404,25 @@ 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(
"Cannot specify both 'factors' and 'plan'. Use either:\n"
' • 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='<column>' — a single measurement stream in order (X/mR charts)\n"
" • factors=['<column>', ...] — 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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -1123,13 +1109,21 @@ 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,
rsg_vars=tuple(factors_str) if factors_str else None,
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,
Expand All @@ -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,
Expand Down Expand Up @@ -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::

Expand Down
Loading
Loading