# setu — Line metrics specification

How setu turns raw counter pulses and barcode scans into line metrics.
Served at `/line-metrics.md`; the code source of truth is the module
docstring of `api/mes_metrics.py`, pinned by `tests/test_mes_metrics.py`.

## 1. The counting model

A line is an ordered chain of **stages**. A stage is **counted** if and only
if a machine (sensor) is attached — stage kind never gates counting.
Sensor-less stages are shown but report no numbers.

- **PLC stage** — the sensor posts per-second pulse arrays on two
  **disjoint** channels: `good` (GOOD_COUNT) and `bad` (BAD_QUANTITY).
  `good` = sum of channel 0, `bad` = sum of channel 1,
  `total = good + bad` — a `[1,1]` second is two units, one good and one
  bad. *(Corrected 2026-07-11: channel 0 was previously misread as "every
  unit, bad ticks both"; historical numbers shifted up by their bad counts
  when the correction deployed.)*
- **Scanner stage** — each decoded barcode is one unit entering the stage.
  `total` = scans, `bad` = 0. A scanner counts; it doesn't judge quality,
  so its per-stage yield is null and FPY skips it.
- **Repair stage** — sits *off* the main flow. It receives rejects from the
  other stages, returns fixed units to the line (its `good`) and scraps the
  rest (its `bad`). It never forms the input/output boundary, whatever its
  position in the sequence, and its yield stays out of FPY — a repaired
  unit is by definition not first-pass.

## 2. Boundary counts

Every metric is computed over a window — a shift or a factory-local day,
clamped to now. **Flow stages** are the counted stages excluding repair.

| Count    | Definition                  | Where it's measured                  |
| -------- | --------------------------- | ------------------------------------ |
| Input    | Units fed into the line     | `total` at the **first** flow stage  |
| Good     | Units that made it out      | `good` at the **last** flow stage    |
| Defects  | First-pass rejects          | Σ `bad` over all flow stages         |
| Repaired | Rejects fixed and returned  | Σ `good` over repair stages          |
| WIP      | Units still in the line     | The residual below                   |

```
wip = input − good − defects + repaired
```

WIP is a residual on purpose: it absorbs counter drift and the repair
queue, and it can dip negative while the line drains faster than it feeds.
It is shown as-is, never clamped — the mass balance must always close
visibly.

The flow view also localizes WIP per gap: **queue before stage i** =
`good` at the previous flow stage − `total` at stage i — units dispatched
downstream but not yet worked. The biggest queue sits in front of the
bottleneck. A stage's own yield always uses its **own** counters
(`good ÷ total`), never the upstream good — otherwise buffered units would
be scored as scrap. Negative queues are timing drift (e.g. a repaired unit
re-entering), shown as-is.

## 3. Rate metrics

| Metric          | Formula              | What it answers |
| --------------- | -------------------- | --------------- |
| Yield / Quality | `good ÷ input`       | How much of what we fed in came out good. End-over-start: it catches loss at uncounted mid-line stages and stays honest when the output stage is a scanner (which would otherwise pin quality at 100%). Repaired units that make it out count as good. |
| FPY             | `∏ (goodᵢ ÷ totalᵢ)` | *Where* loss happens, stage by stage — first-pass only. Product over flow stages; scanner yields (null) and repair yields are excluded. |
| Availability    | `present ÷ expected` | Heartbeat presence: distinct (machine, 5-minute window) cells with any data, over all counted machines — repair included; a dark repair PLC is as down as any other. Conflates machine-off with telemetry-down — accepted. |
| OEE             | `availability × yield` | The headline. No Performance factor yet — it joins when plans carry a trusted ideal rate. |
| UPH             | `good ÷ elapsed hours` | Realized rate at the output boundary, over the elapsed part of the window. |

## 4. Worked example

The Sim Line over one day (machine → operator → scanner, plus a rework
bench): input 976, good 887, defects 47, repaired 25, WIP 67.

```
976 = 887 + (47 − 25) + 67        in = out + net scrap + in-flight ✓
```

Yield 90.9%, FPY 95.2% — FPY sits *above* yield here because the
sensor-less operator stage silently scrapped units that only
end-over-start accounting can see. When the two numbers diverge, the gap
itself is the finding.

## 5. Decisions & assumptions

- **Counters carry no unit identity.** Attribution to models/customers goes
  through plan windows; when plans share a window the actual is split by
  planned quantity and flagged *estimated*.
- **Rework loops don't re-count.** Repaired units are assumed to re-enter
  downstream of the counter that rejected them. If a fixed unit re-crosses
  a stage counter, it double-counts — modelled as acceptable noise for now.
- **Time buckets are origin-aligned.** Factory-local shifts are :30-offset
  in UTC for IST lines; every hourly/5-minute bucket uses the window start
  as origin so buckets match the shift, not the UTC clock.
- **Variance reasons are inferred, overridable.** Machine (a stage sensor
  dark ≥ 10 min of the hour) → quality (> 5% bad at output) → manpower
  (< 90% of plan) — a manual override always wins.

## 6. Rollup metrics (company → plant → line)

Lines hang off an **org tree** (company → plant → line). Any node rolls its
**whole descendant line set** up over one common UTC window `[from, to)`.
Aggregation is deliberately *not* a mean of per-line numbers — that would let a
tiny line swing the plant. The rules:

| Rollup metric | How it aggregates | Why |
| ------------- | ----------------- | --- |
| Counts (input, good, defects, repaired, WIP, output) | Plain **Σ** across lines | Units are additive. |
| **Availability** | Recomputed from the **union of counted machines** across all lines, same 5-min heartbeat-cell formula — **machine-weighted**, a machine shared by two lines counts **once** | A busy 10-machine line and an idle 1-machine line must not weigh equally; presence is a property of machines, not lines. Naive mean-of-line-availabilities double-counts shared machines and over-weights small lines. |
| **Quality / Yield** | **Σ good ÷ Σ input** (**volume-weighted**) | End-over-start over the whole set. |
| **OEE** | aggregated availability × aggregated quality | Same headline identity as a line. |
| **FPY** | **input-weighted mean** of the per-line FPYs | Parallel lines aren't in series, so FPYs don't multiply; weighting by input keeps a high-volume line dominant. Per-line FPY still ships in the rows. |
| **UPH** | Σ good ÷ elapsed hours | Realized set-wide rate. |

Worked check (the test that pins it): two lines under a plant with per-line
heartbeat presence 96, 96 and 48 cells over 96 windows each (three machines,
one shared) give machine-weighted availability `(96 + 96 + 48) ÷ (3 × 96) =
0.8333` — **not** the naive line mean `0.75`. The gap is the whole point.

The `/org` dashboard renders the company rollup as its KPI row, each plant child
as a sub-rollup card (re-sliced from the same per-line pass — no re-query), and
each line as a row. `org_unit.tz` is nullable; the **effective tz** (first
non-null walking up ancestors, else `Asia/Kolkata`) sets the dashboard's "today"
window. Availability needs one common UTC window, so org metrics take `from`/`to`
in UTC exactly like line metrics.

### Dimensional breakdown across the tree

The org **breakdown** ("how much Model Y across factories") sums each model /
customer across every descendant line, optionally split per plant (a model ×
plant matrix). It reuses the per-line attribution and carries the same caveats:

- **Attribution is inferred from plan windows, not measured.** Counters carry no
  model identity; a unit's model is read from which plan covers its window. When
  two models truly run in the same window on one line, the actual is split
  **proportionally by planned quantity** and flagged `estimated` (`~`).
- **Unplanned production is surfaced, not dropped.** Counts with no overlapping
  plan appear as an explicit `model: null` "unattributed" row so attribution
  gaps stay visible.
- Exact per-unit model attribution needs an execution-level *production run*
  entity (line + interval + model actually running) — a future MES Execute step
  the org tree is forward-compatible with (a run table would replace plan
  windows as the attribution source).

## 7. Known gaps

- **No manual defect entry.** `bad` only exists where a PLC reports it.
  Paper-tally QC rejects and defect *reasons* are invisible — the next big
  visibility win.
- **No Performance factor in OEE.** Planned UPH exists but isn't yet
  trusted as an ideal rate.
- **Availability can't tell off from unreachable.** A machine that is down
  and one whose network dropped look identical.
