Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
A dosing loop set to auto doses by itself once a minute, inside the same limits, and tests prove it never doses when it must not #621
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
smith/arm:smith/619-a-dosing-loop-set-to-auto-doses-by-itsel → main 1 commit fork
5 files
changed +77
−0
Software/so101/README.md +2
| @@ -285,6 +285,8 @@ A person runs this at the arm while nobody drives: `bun bench/limits-search.ts h | ||
| 285 | 285 | |
| 286 | 286 | `src/doser.ts` is the dose controller: three loops (nutrient A, nutrient B, pH down), each `off`, `suggest` (default) or `auto`, with per-dose and per-day ml limits, a ≥10 min mixing wait, no dose on a stale (>30 min), uncalibrated or already-used reading, and a halt until the owner resumes when a reading is out of physical bounds or a dose did not move it. Every dose goes in the log book with its cause (rule, crew, owner). `topOff` runs the fresh-water pump until the high float or a time cap. Sensor and pump drivers are separate pulls and plug in as `Reading` sources and `Pump`s. |
| 287 | 287 | |
| 288 | **Auto pumps unattended.** The relay runs the controller once a minute (`doseEveryMs`, 60 s by default): every loop in `auto` that has something to add doses then, with no one tapping, and every limit still holds on that path: the per-dose and per-day ml, the mixing wait, fresh and calibrated readings, one dose per reading, and the stop. Loops still ship in `suggest` and `off` loops and `suggest` loops never move a pump on a tick. The log book records an automatic dose with cause `rule`. | |
| 289 | ||
| 288 | 290 | **Using it today.** The app builds the controller at boot over the Pi's real pumps (below); readings are the newest measurements in the log book. State (the day's doses, the stop, each loop's mode) is kept in `dosing.json` under `FORGE_APP_DATA` and survives a restart. |
| 289 | 291 | - **Page:** the owner sees a Dosing card on `/care`: each loop, its mode, what it would add, a **Do it** button, a mode picker, and a red "Dosing stopped" banner with **Resume dosing**. Guests and non-owners see nothing. |
| 290 | 292 | - **Terminal:** `/care dosing`, `/care dose <nutrientA|nutrientB|phDown> [ml]`, `/care mode <loop> <off|suggest|auto>`, `/care resume`. The server decides who may; all four are the owner's. |
Software/so101/src/doser.test.ts +51
| @@ -171,3 +171,54 @@ describe("top-off", () => { | ||
| 171 | 171 | expect(f.calls).toEqual([]); |
| 172 | 172 | }); |
| 173 | 173 | }); |
| 174 | ||
| 175 | describe("the automatic tick", () => { | |
| 176 | test("a loop in auto doses once on a tick and the log book records it as the rule's", async () => { | |
| 177 | const r = rig(); | |
| 178 | const out = await r.doser.tick(); | |
| 179 | expect(out.phDown).toEqual({ ok: true, ml: 4 }); | |
| 180 | expect(r.ran.phDown).toEqual([4]); | |
| 181 | expect(r.log.entries().filter((e) => e.type === "action")).toMatchObject([{ what: "phdown", amount: 4, cause: "rule" }]); | |
| 182 | }); | |
| 183 | ||
| 184 | test("suggest and off loops never move a pump on a tick", async () => { | |
| 185 | for (const mode of ["suggest", "off"] as const) { | |
| 186 | const r = rig({}, "suggest"); | |
| 187 | for (const l of LOOPS) r.doser.setMode(l, mode, "owner"); | |
| 188 | expect(await r.doser.tick()).toEqual({}); | |
| 189 | expect(r.ran).toEqual({}); | |
| 190 | expect(r.log.entries().filter((e) => e.type === "action")).toEqual([]); | |
| 191 | } | |
| 192 | }); | |
| 193 | ||
| 194 | test("a halted doser never doses on a tick", async () => { | |
| 195 | const r = rig(); | |
| 196 | await r.doser.tick(); | |
| 197 | r.at(MIN_WAIT_MS + 1); | |
| 198 | r.read("ph", 7.2); // the dose did not move it: halts | |
| 199 | await r.doser.tick(); | |
| 200 | expect(r.doser.halted).toContain("did not move"); | |
| 201 | r.at(3 * MIN_WAIT_MS); | |
| 202 | r.read("ph", 7.0); | |
| 203 | expect(await r.doser.tick()).toEqual({}); | |
| 204 | expect(r.ran.phDown).toEqual([4]); | |
| 205 | }); | |
| 206 | ||
| 207 | test("two ticks on the same reading give one dose", async () => { | |
| 208 | const r = rig(); | |
| 209 | await r.doser.tick(); | |
| 210 | r.at(MIN_WAIT_MS + 1); | |
| 211 | const again = await r.doser.tick(); | |
| 212 | expect(Object.values(again).every((o) => !o.ok)).toBe(true); | |
| 213 | expect(r.ran.phDown).toEqual([4]); | |
| 214 | }); | |
| 215 | ||
| 216 | test("a tick inside the mixing wait does not dose", async () => { | |
| 217 | const r = rig(); | |
| 218 | await r.doser.tick(); | |
| 219 | r.at(60_000); | |
| 220 | r.read("ph", 7.2); | |
| 221 | await r.doser.tick(); | |
| 222 | expect(r.ran.phDown).toEqual([4]); | |
| 223 | }); | |
| 224 | }); |
Software/so101/src/doser.ts +2
| @@ -299,6 +299,8 @@ export class Doser { | ||
| 299 | 299 | if (this.loops[l].mode !== "auto") continue; |
| 300 | 300 | const s = this.suggest(l); |
| 301 | 301 | if ("ml" in s) out[l] = await this.dose(l, "rule", s.ml); |
| 302 | // a suggestion only reads; the stop on a bad or unmoved reading is raised by a dose attempt, which adds nothing here | |
| 303 | else if (s.reason.startsWith("stopped:") && !this.s.halted) await this.dose(l, "rule"); | |
| 302 | 304 | } |
| 303 | 305 | return out; |
| 304 | 306 | } |
Software/so101/src/relay.test.ts +17
| @@ -15,6 +15,8 @@ import { Lobby } from "./lobby.ts"; | ||
| 15 | 15 | import { ClockSync } from "./ui/clock-sync.js"; |
| 16 | 16 | import { OFFLINE_FRAME } from "./offline-frame.ts"; |
| 17 | 17 | import { parseRemote, sameOrigin, startRelay, type Remote, type RunningRelay } from "./relay.ts"; |
| 18 | import { CareLog } from "./care-log.ts"; | |
| 19 | import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts"; | |
| 18 | 20 | import { startServer, type RunningServer } from "./server.ts"; |
| 19 | 21 | /** A frame as the arm sees it, without the STOP epoch the relay stamps on it. */ |
| 20 | 22 | const unstamped = (frame: string) => JSON.stringify(Object.fromEntries(Object.entries(JSON.parse(frame) as object).filter(([key]) => key !== "epoch" && key !== "sentAt"))); |
| @@ -603,3 +605,18 @@ describe("relay", () => { | ||
| 603 | 605 | expect(await link(`http://127.0.0.1:${relay.port}`)).toEqual({ mode: "simulated", host: null, problem: 'ARM_SERVER "ftp://x" must start with http://' }); |
| 604 | 606 | }); |
| 605 | 607 | }); |
| 608 | ||
| 609 | describe("the dosing timer", () => { | |
| 610 | test("the relay ticks the doser every doseEveryMs and stops on close", async () => { | |
| 611 | let ticks = 0; | |
| 612 | const doser = { tick: async () => void ticks++ } as unknown as import("./doser.ts").Doser; | |
| 613 | const care = { log: new CareLog(), profile: loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")!, doser }; | |
| 614 | const r = await startRelay({ port: 0, simulation: simBase, care, doseEveryMs: 20 }); | |
| 615 | await new Promise((ok) => setTimeout(ok, 130)); | |
| 616 | await r.close(); | |
| 617 | const seen = ticks; | |
| 618 | expect(seen).toBeGreaterThanOrEqual(2); | |
| 619 | await new Promise((ok) => setTimeout(ok, 80)); | |
| 620 | expect(ticks).toBe(seen); | |
| 621 | }); | |
| 622 | }); |
Software/so101/src/relay.ts +5
| @@ -171,6 +171,8 @@ export interface RelayOptions { | ||
| 171 | 171 | care?: { log: CareLog; profile: Profile; doser?: Doser; dosingFile?: string }; |
| 172 | 172 | /** How often the Pi's pH/EC probes are read into the care log, ms; unset, they are not sampled (the page still shows them and walks the calibration). */ |
| 173 | 173 | probeEveryMs?: number; |
| 174 | /** How often the doser's automation runs (Doser.tick), ms; one minute by default. A loop in auto doses then, inside the same limits as the owner's tap. */ | |
| 175 | doseEveryMs?: number; | |
| 174 | 176 | /** The grow box's stills, log and how often a still is taken (src/grow-stills.ts); without it nothing is taken and the card shows no timelapse. */ |
| 175 | 177 | grow?: { stills: GrowStills; log: GrowLog; everyMs: number }; |
| 176 | 178 | /** The key the arm server's /grow routes need (src/grow-key.ts), sent only on the grow requests this relay has already gated; without it the arm server refuses them. */ |
| @@ -1136,6 +1138,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1136 | 1138 | stillTimer?.unref?.(); |
| 1137 | 1139 | const probeTimer = options.probeEveryMs ? setInterval(() => void sampleProbes(care.log, probeCache).catch((err) => console.error(`probes: ${reason(err)}`)), options.probeEveryMs) : undefined; |
| 1138 | 1140 | probeTimer?.unref?.(); |
| 1141 | const doseTimer = setInterval(() => void doser.tick().catch((err) => console.error(`dosing: ${reason(err)}`)), options.doseEveryMs ?? 60_000); | |
| 1142 | doseTimer.unref?.(); | |
| 1139 | 1143 | |
| 1140 | 1144 | const http = createServer(async (req, res) => { |
| 1141 | 1145 | for (const [name, value] of Object.entries(FLOOR)) res.setHeader(name, value); |
| @@ -1641,6 +1645,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1641 | 1645 | clearInterval(sampleTimer); |
| 1642 | 1646 | clearInterval(stillTimer); |
| 1643 | 1647 | clearInterval(probeTimer); |
| 1648 | clearInterval(doseTimer); | |
| 1644 | 1649 | clearInterval(sensorTimer); |
| 1645 | 1650 | readBacks?.(); |
| 1646 | 1651 | arrived?.(); |