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

Merged opened this

Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.

Merged

smith/arm:smith/619-a-dosing-loop-set-to-auto-doses-by-itsel → main 1 commit fork

  • Merged. smith/619-a-dosing-loop-set-to-auto-doses-by-itsel was merged into main as 49a9eb7.

Review

Sign in to review this change.
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
285285
286286 `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.
287287
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
288290 **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.
289291 - **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.
290292 - **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", () => {
171171 expect(f.calls).toEqual([]);
172172 });
173173 });
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 {
299299 if (this.loops[l].mode !== "auto") continue;
300300 const s = this.suggest(l);
301301 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");
302304 }
303305 return out;
304306 }
Software/so101/src/relay.test.ts +17
@@ -15,6 +15,8 @@ import { Lobby } from "./lobby.ts";
1515 import { ClockSync } from "./ui/clock-sync.js";
1616 import { OFFLINE_FRAME } from "./offline-frame.ts";
1717 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";
1820 import { startServer, type RunningServer } from "./server.ts";
1921 /** A frame as the arm sees it, without the STOP epoch the relay stamps on it. */
2022 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", () => {
603605 expect(await link(`http://127.0.0.1:${relay.port}`)).toEqual({ mode: "simulated", host: null, problem: 'ARM_SERVER "ftp://x" must start with http://' });
604606 });
605607 });
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 {
171171 care?: { log: CareLog; profile: Profile; doser?: Doser; dosingFile?: string };
172172 /** 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). */
173173 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;
174176 /** 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. */
175177 grow?: { stills: GrowStills; log: GrowLog; everyMs: number };
176178 /** 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> {
11361138 stillTimer?.unref?.();
11371139 const probeTimer = options.probeEveryMs ? setInterval(() => void sampleProbes(care.log, probeCache).catch((err) => console.error(`probes: ${reason(err)}`)), options.probeEveryMs) : undefined;
11381140 probeTimer?.unref?.();
1141 const doseTimer = setInterval(() => void doser.tick().catch((err) => console.error(`dosing: ${reason(err)}`)), options.doseEveryMs ?? 60_000);
1142 doseTimer.unref?.();
11391143
11401144 const http = createServer(async (req, res) => {
11411145 for (const [name, value] of Object.entries(FLOOR)) res.setHeader(name, value);
@@ -1641,6 +1645,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
16411645 clearInterval(sampleTimer);
16421646 clearInterval(stillTimer);
16431647 clearInterval(probeTimer);
1648 clearInterval(doseTimer);
16441649 clearInterval(sensorTimer);
16451650 readBacks?.();
16461651 arrived?.();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs