# Acceptance recipes

# Acceptance recipes

Use a **recipe** to pass or fail a unit. It runs on a [setup](https://phloem.ai/docs/trellis/), steps it
through a profile, takes measurements and records a verdict per bound. Each time
you run it is a **run**.

## Setup, recipe, run

| | What it is | Holds |
|---|---|---|
| **`setup(...)`** | your test setup: the document you **Deploy** | channels, [stop conditions](https://phloem.ai/docs/trellis/checks/), [interlocks](https://phloem.ai/docs/trellis/interlocks/), safe values, the [page](https://phloem.ai/docs/trellis/page/) |
| **`recipe(...)`** | a procedure that runs *on* a setup | steps, measurements, bounds |
| **run** | one execution of a recipe | measurements, verdicts, the event log. Recorded, never edited |

A file with neither `setup(...)` nor `recipe(...)` first is a
[notebook](https://phloem.ai/docs/trellis/notebook/).

Safety lives in the setup. A recipe can't declare an interlock or a stop
condition, so it can't weaken its setup.

Write the setup first and **Deploy** it. The recipe names it in `setup=` and
reads only channels the setup declares. If that setup isn't deployed, **Start
run** offers to deploy it. One setup runs at a time; a recipe for a different
setup offers to **replace** the deployed one and start.

Replacing a setup **stops the running one first**:

1. A confirmation shows what will move, before you agree.
2. Every output it controls goes to its safe value, as with Stop.
3. The new setup deploys once Computer confirms those safe values.

If Computer never confirms, nothing is replaced. The old setup stays stopped,
sending safe values, and the message names the outputs to check before you
press Reset. A new revision of the *same* setup that keeps all its outputs
stops nothing.

## A recipe

```python
recipe("unit_accept", setup="load_test", unit="serial")

ch = channels()
output = ch["/dut/output_v"]
temp   = ch["/dut/temp_c"]

# Bounds — each declares WHAT is judged, then HOW.
output_v = value("output_at_load", unit="V")
require(output_v.within(11.8, 12.2))

warm = value("temp_after_load", unit="degC")
require(warm.below(60))

# Steps — actions first, measurements after.
step("load",
     set(ch["/dut/enable"], True),
     set(ch["/dut/load_a"], 2.0),
     hold("30s"))

step("verify",
     measure(output_v, output),
     measure(warm, temp))

step("stow", set(ch["/dut/load_a"], 0.0), set(ch["/dut/enable"], False))

plan(steps=["load", "verify", "stow"])

page("Unit acceptance",
     layout=col(row(readout(output), readout(temp)),
                row(verifications("Bounds"), col(alarms(), log()))))
```

| | |
|---|---|
| **`recipe(name, setup=, unit=)`** | the header. `setup=` names the setup it runs on; `unit=` names what identifies the unit under test (`"serial"`) |
| **`ch["/path"]`** | a channel the setup declares, by its path on **Live** |
| **`value(name, unit=)`** | a named measurement the run records |
| **`require(v.below(x))`** | the bound that judges it: `above`, `below`, `within(lo, hi)`, `outside(lo, hi)` |
| **`step(name, ...actions)`** | one ordered step: `set`, `ramp`, `wait`, `hold`, `wait_until`, `confirm`, `exec`, then `measure` |
| **`measure(v, channel)`** | capture the channel's reading into the value, during that step |
| **`plan(steps=[...])`** | the order the steps run in |
| **`page(title, layout=)`** | the run's live panel: a `readout` per measured channel, `verifications()` for the bounds as they are judged, `alarms()`, `log()`. Without one, the panel is empty |

## Actions

| Action | Does |
|---|---|
| **`set(ch["/out/enable"], True)`** | command an output the setup declared with `ch.write` |
| **`ramp(ch["/x"], from_=0, to=100, over="20s")`** | move an output smoothly. `from_` is required: a ramp never guesses where it starts |
| **`wait("30s")`** | let time pass. Nothing is checked meanwhile |
| **`hold("2m")`** | stay at the current commands. Recorded as part of the test, not dead time |
| **`wait_until(lambda: ch["/p"] > 900, timeout="30s")`** | wait for a condition. `timeout=` is required; if it expires, the step faults |
| **`confirm("Complete the manual action", timeout="10m")`** | a sign-off gate; the run keeps the signature. `hold=True` waits with no limit |
| **`exec("python3 read.py --source running")`** | run a script and wait (see [Run your own scripts](https://phloem.ai/docs/trellis/scripts/)) |

Durations are strings: `"250ms"`, `"30s"`, `"2m"`.

## Verdicts

A bound judges a **measurement**, never the setup. A failed bound fails that
measurement, and the run reports it. It does not stop the test; that is the
setup's [interlock](https://phloem.ai/docs/trellis/interlocks/).

| The run says | Meaning | Is the unit scrap? |
|---|---|---|
| **pass** | every bound held | no |
| **fail** | a measurement was outside its bound | yes |
| **unavailable** | a measurement couldn't be taken: its channel published nothing | no, but not a pass either |
| **passed_config_pending** | only a `persists=True` bound failed. The unit is fine; a change didn't survive its save or power cycle | no. Redo the save |

`value(..., persists=True)` marks a bound that checks whether a change *survived*,
not whether the unit is good. Usually you put two bounds on one quantity: one
read live, one read after a power cycle.

## Change, then measure, in separate steps

A step that *acts* (`exec`, `set`, `ramp`) measures nothing; it either completes
or is interrupted. The verdict comes from a later step that reads the unit
independently.

```python
recipe("unit_configure_and_verify", setup="load_test", unit="serial")

ch = channels()
setting = ch["/dut/setting"]
drift   = ch["/dut/config_diff_lines"]

applied = value("setting_applied")
require(applied.within(41.5, 42.5))

clean = value("config_drift", unit="lines")
require(clean.below(0.5))

kept = value("config_drift_after_restart", unit="lines", persists=True)
require(kept.below(0.5))

# The change. Nothing is measured here.
step("configure", exec("python3 configure.py --setting 42"))

# An independent read of what is live now, and the verdict.
step("verify",
     exec("python3 read.py --source running"),
     measure(applied, setting),
     measure(clean, drift))

# Did it survive: someone restarts the unit, then the read repeats.
step("restart",
     confirm("Restart the unit, then continue", hold=True),
     wait("30s"),
     exec("python3 read.py --source startup"),
     measure(kept, drift))

plan(steps=["configure", "verify", "restart"])
```

> **Note:** A bound on a channel nothing publishes still compiles, but every run
> reports **unavailable**. Name only channels the setup declares and that
> something is sending. Check the list on **Live**.
