Metadata-Version: 2.4
Name: westgard-python
Version: 0.1.0
Summary: Typed Westgard QC rule evaluation, sigma mappings, and planning helpers for Python.
Project-URL: Homepage, https://github.com/jouno53/Westgard-Python
Project-URL: Source, https://github.com/jouno53/Westgard-Python
Project-URL: Documentation, https://github.com/jouno53/Westgard-Python/tree/main/docs
Project-URL: Changelog, https://github.com/jouno53/Westgard-Python/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/jouno53/Westgard-Python/issues
Author: Westgard-Python contributors
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: clinical-laboratory,quality-control,sigma-metrics,statistics,westgard
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Healthcare Industry
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: mypy>=1.16; extra == 'dev'
Requires-Dist: pre-commit>=4.2; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.3; extra == 'dev'
Requires-Dist: ruff>=0.11; extra == 'dev'
Requires-Dist: twine>=6.1; extra == 'dev'
Provides-Extra: docs
Requires-Dist: mkdocs-material>=9.6; extra == 'docs'
Requires-Dist: mkdocstrings[python]>=0.29; extra == 'docs'
Provides-Extra: pandas
Requires-Dist: pandas>=2.2; extra == 'pandas'
Description-Content-Type: text/markdown

# Westgard-Python

`westgard-python` is a pure-Python package for evaluating Westgard rules, named multirule QC procedures, Westgard Sigma Rules mappings, and reference-backed planning guidance.

The package is built directly from the canonical reference in [the master reference](https://github.com/jouno53/Westgard-Python/blob/main/docs/westgard-master-reference.md). It treats QC semantics as explicit API surface rather than implicit chart logic.

## Status

The project is in active early development and targets `0.1.0`.

## Installation

```bash
pip install westgard-python
```

Optional dataframe helpers:

```bash
pip install "westgard-python[pandas]"
```

## Public Function Index

Top-level API (`import westgard`):

- `get_rule_definition(rule)`
- `evaluate_rule(rule, observations, *, scope, stream_name)`
- `get_preset_definition(preset)`
- `evaluate_multirule(*, context, preset, interpretation_mode=InterpretationMode.MODERN)`
- `calculate_sigma_metrics(*, total_allowable_error, bias, coefficient_of_variation, analyte=None, level=None)`
- `recommend_sigma_rules(*, sigma, control_levels)`
- `plan_qc_strategy(*, sigma_metrics, control_levels, mode=PlanningMode.MONITOR, residual_risk_target=1.0)`

Optional adapter API (`from westgard.adapters import dataframe_to_context`):

- `dataframe_to_context(frame, *, current_run_id, across_material=False)`

## Common Setup

All examples below use these shared imports/helpers.

```python
from westgard import (
    ControlObservation,
    EvaluationScope,
    InterpretationMode,
    MultiruleContext,
    MultiruleVariant,
    PlanningMode,
    RuleName,
    RunIdentifier,
    calculate_sigma_metrics,
    evaluate_multirule,
    evaluate_rule,
    get_preset_definition,
    get_rule_definition,
    plan_qc_strategy,
    recommend_sigma_rules,
)


def obs(run_id: str, material: str, sequence: int, z: float) -> ControlObservation:
    return ControlObservation(
        analyte="glucose",
        material=material,
        run_id=RunIdentifier(run_id),
        sequence=sequence,
        z_score=z,
    )
```

## How-To: `get_rule_definition`

Signature:

```python
get_rule_definition(rule: RuleName) -> RuleDefinition
```

Use when you need static metadata for one primitive rule: taxonomy, minimum observations, default scope, intended error type, and description.

```python
definition = get_rule_definition(RuleName.R_4S)
print(definition.minimum_observations)  # 2
print(definition.default_scope.value)   # within_run
print(definition.intended_error)        # random error
```

Raises:

- `KeyError` if the rule is not present in `RULE_DEFINITIONS`.

## How-To: `evaluate_rule`

Signature:

```python
evaluate_rule(
    rule: RuleName,
    observations: tuple[ControlObservation, ...],
    *,
    scope: EvaluationScope,
    stream_name: str,
) -> EvaluationResult
```

Use when you want to evaluate exactly one rule against one explicit stream.

```python
result = evaluate_rule(
    RuleName.TWO_2S,
    (
        obs("run-001", "L1", 1, 2.2),
        obs("run-002", "L1", 2, 2.3),
    ),
    scope=EvaluationScope.WITHIN_MATERIAL,
    stream_name="material:L1",
)
print(result.violated)      # True
print(result.enough_data)   # True
print(result.reason)
```

Behavior notes:

- Observations are sorted by `sequence` before evaluation.
- If there are too few observations, it returns `violated=False`, `enough_data=False`.
- Limit rules use strict inequality. Boundary values at exactly `+2`, `-2`, `+3`, or `-3` do not violate.
- `R_4s` must be evaluated with `scope=within_run`.

Raises:

- `ValueError` for invalid rule/scope combinations (for example `R_4s` outside `within_run`).

## How-To: `get_preset_definition`

Signature:

```python
get_preset_definition(preset: MultiruleVariant) -> PresetDefinition
```

Use when you need the exact rule bundle for a named preset before evaluating.

```python
preset = get_preset_definition(MultiruleVariant.MODERN_2_CONTROL)
print(preset.current_run_rules)  # (1_3s, R_4s)
print(preset.history_rules)      # (2_2s, 4_1s, 8_x)
print(preset.classic_warning_gate)  # False
```

Raises:

- `KeyError` if the preset key is not defined.

## How-To: `evaluate_multirule`

Signature:

```python
evaluate_multirule(
    *,
    context: MultiruleContext,
    preset: MultiruleVariant,
    interpretation_mode: InterpretationMode = InterpretationMode.MODERN,
) -> MultiruleResult
```

Use when you want end-to-end multirule evaluation with preset rule families.

```python
l1 = (
    obs("run-001", "L1", 1, 0.2),
    obs("run-002", "L1", 3, 0.4),
    obs("run-003", "L1", 5, 3.2),
)
l2 = (
    obs("run-001", "L2", 2, -0.1),
    obs("run-002", "L2", 4, 0.2),
    obs("run-003", "L2", 6, 0.1),
)

context = MultiruleContext(
    current_run=(l1[-1], l2[-1]),
    material_streams={"L1": l1, "L2": l2},
)

result = evaluate_multirule(
    context=context,
    preset=MultiruleVariant.MODERN_2_CONTROL,
    interpretation_mode=InterpretationMode.MODERN,
)
print(result.accepted)
print([v.rule.value for v in result.violations])
```

Behavior notes:

- `modern` evaluates configured rejection rules directly.
- `classic` first evaluates a current-run `1_2s` warning gate.
- If `classic` and no warning gate trigger, the run is accepted early.
- Cross-material history is evaluated only if `context.across_material_stream` is supplied.

## How-To: `calculate_sigma_metrics`

Signature:

```python
calculate_sigma_metrics(
    *,
    total_allowable_error: float,
    bias: float,
    coefficient_of_variation: float,
    analyte: str | None = None,
    level: str | None = None,
) -> SigmaMetrics
```

Use when you have TEa/bias/CV and need sigma for rule-selection workflows.

```python
metrics = calculate_sigma_metrics(
    total_allowable_error=10.0,
    bias=-2.0,
    coefficient_of_variation=2.0,
    analyte="glucose",
    level="L1",
)
print(metrics.sigma)  # 4.0
```

Formula:

- `sigma = (TEa - abs(bias)) / CV`

Raises:

- `ValueError` if `total_allowable_error <= 0`
- `ValueError` if `coefficient_of_variation <= 0`

## How-To: `recommend_sigma_rules`

Signature:

```python
recommend_sigma_rules(*, sigma: float, control_levels: int) -> SigmaRuleMapping
```

Use when you need a reference-backed sigma-band mapping to a rule set.

```python
mapping = recommend_sigma_rules(sigma=4.5, control_levels=2)
print(mapping.sigma_band)          # 4 to <5
print(mapping.recommended_rules)   # 1_3s, 2_2s, R_4s, 4_1s
print(mapping.control_measurements_per_event)
print(mapping.max_runs_between_qc_events)
```

Raises:

- `ValueError` if `control_levels` is not `2` or `3`.

## How-To: `plan_qc_strategy`

Signature:

```python
plan_qc_strategy(
    *,
    sigma_metrics: SigmaMetrics,
    control_levels: int,
    mode: PlanningMode = PlanningMode.MONITOR,
    residual_risk_target: float = 1.0,
) -> PlanningRecommendation
```

Use when you need deterministic planning notes around startup vs routine monitor usage.

```python
metrics = calculate_sigma_metrics(
    total_allowable_error=8.0,
    bias=1.0,
    coefficient_of_variation=1.0,
)

recommendation = plan_qc_strategy(
    sigma_metrics=metrics,
    control_levels=2,
    mode=PlanningMode.STARTUP,
    residual_risk_target=1.0,
)
print(recommendation.mode.value)                # startup
print(recommendation.sigma_mapping.sigma_band)  # based on computed sigma
print(recommendation.qc_event_note)
print(recommendation.out_of_control_recovery)
```

Raises:

- `ValueError` if `residual_risk_target <= 0`.
- `ValueError` if `control_levels` is invalid (propagated from `recommend_sigma_rules`).

## How-To: `dataframe_to_context` (Optional pandas Adapter)

Signature:

```python
dataframe_to_context(
    frame,
    *,
    current_run_id: str,
    across_material: bool = False,
) -> MultiruleContext
```

Use when your QC data starts as a pandas DataFrame and you want to feed it into `evaluate_multirule`.

```python
import pandas as pd
from westgard.adapters import dataframe_to_context

frame = pd.DataFrame(
    [
        {"analyte": "glu", "material": "L1", "run_id": "run-1", "sequence": 1, "z_score": 0.1},
        {"analyte": "glu", "material": "L2", "run_id": "run-1", "sequence": 2, "z_score": -0.1},
        {"analyte": "glu", "material": "L1", "run_id": "run-2", "sequence": 3, "z_score": 2.3},
        {"analyte": "glu", "material": "L2", "run_id": "run-2", "sequence": 4, "z_score": 0.2},
    ]
)

context = dataframe_to_context(frame, current_run_id="run-2", across_material=True)
print(len(context.current_run))  # 2
```

Required columns:

- `analyte`
- `material`
- `run_id`
- `sequence`
- `z_score`

Raises:

- `ImportError` if pandas is not installed.
- `TypeError` if `frame` is not a pandas DataFrame.
- `ValueError` if required columns are missing.
- `ValueError` if no rows match `current_run_id`.

## How-To: `EvaluationResult.to_violation` (Method)

Signature:

```python
EvaluationResult.to_violation() -> RuleViolation | None
```

Use when you want a normalized violation payload for downstream reporting.

```python
result = evaluate_rule(
    RuleName.ONE_3S,
    (obs("run-009", "L1", 1, 3.5),),
    scope=EvaluationScope.WITHIN_RUN,
    stream_name="current_run:run-009",
)
violation = result.to_violation()
print(violation.rule.value if violation else None)
```

Behavior notes:

- Returns `None` when `result.violated` is `False`.
- Returns `RuleViolation` with rule/scope/stream and triggering observation IDs when violated.

## Design Principles

- `modern` interpretation is the default for computerized evaluation.
- `classic` interpretation is supported explicitly and preserves `1_2s` warning-gate behavior.
- `R_4s` is enforced as within-run only.
- Cross-run and cross-material logic are explicit inputs to the engine. The package does not infer run boundaries from timestamps.

## Documentation

- Canonical reference: [master reference](https://github.com/jouno53/Westgard-Python/blob/main/docs/westgard-master-reference.md)
- Release runbook: [publishing guide](https://github.com/jouno53/Westgard-Python/blob/main/docs/publishing.md)
- Package docs: MkDocs Material site under `docs/`.

## Local Release Validation

Run the strict local deployability gate with:

```bash
python scripts/validate_release.py
```

This command runs linting, typing, tests with branch coverage gate (`>=95%`), build, twine checks, and wheel smoke install. It writes a local summary report to `reports/test-results-summary.md`.

## License

Apache-2.0
