From 6e2693312ea22bd50b6109db0c6ee8a26c003536 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 3 Aug 2026 10:55:50 +0200 Subject: [PATCH 1/3] fix(api): ship the support dump as a zip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dump exists to be handed to somebody else. Windows and every chat client open a zip with no extra tooling; a .tar.gz asks the person whose help you need to go find one first. Same contents, same size — manifest, redacted config, driver health, recent logs and an hour of telemetry, about 6 kB on a two-driver install. Deflate per entry rather than gzip over the whole stream, so an entry can also be read without unpacking everything. The test opens the response with archive/zip and reads every entry, so a renamed tarball would fail rather than pass on the extension alone. Co-Authored-By: Claude Opus 5 --- .changeset/support-dump-zip.md | 10 +++ go/internal/api/api_drivers_debug.go | 46 ++++++++------ go/internal/api/api_support_dump_zip_test.go | 64 ++++++++++++++++++++ web/diagnostics-modal.js | 2 +- 4 files changed, 101 insertions(+), 21 deletions(-) create mode 100644 .changeset/support-dump-zip.md create mode 100644 go/internal/api/api_support_dump_zip_test.go diff --git a/.changeset/support-dump-zip.md b/.changeset/support-dump-zip.md new file mode 100644 index 00000000..3db01573 --- /dev/null +++ b/.changeset/support-dump-zip.md @@ -0,0 +1,10 @@ +--- +"ftw": patch +--- + +Support dump is now a `.zip` instead of a `.tar.gz`. The file's whole purpose +is to be handed to somebody else, and Windows and every chat client open a zip +without a second tool — a `.tar.gz` asks the person you need help from to go +find one first. Contents and size are unchanged: manifest, redacted config, +driver health, recent logs and an hour of telemetry, around 6 kB on a +two-driver install. diff --git a/go/internal/api/api_drivers_debug.go b/go/internal/api/api_drivers_debug.go index aded6677..d3792a92 100644 --- a/go/internal/api/api_drivers_debug.go +++ b/go/internal/api/api_drivers_debug.go @@ -7,8 +7,7 @@ package api import ( - "archive/tar" - "compress/gzip" + "archive/zip" "context" "encoding/json" "fmt" @@ -318,11 +317,17 @@ func (s *Server) handleGlobalLogs(w http.ResponseWriter, r *http.Request) { }) } -// GET /api/support/dump — gzipped tarball with everything a developer -// needs to triage a support incident: redacted config, full driver -// health JSON, recent global + per-driver logs, last 1 h of TS samples -// per (driver, metric), and a manifest. SQLite is NOT included; the -// dump is intended to be small enough to email or paste-link. +// GET /api/support/dump — zip archive with everything a developer needs +// to triage a support incident: redacted config, full driver health JSON, +// recent global + per-driver logs, last 1 h of TS samples per +// (driver, metric), and a manifest. SQLite is NOT included; the dump is +// intended to be small enough to attach to a chat message — measured at +// ~6 kB on a two-driver install. +// +// Zip rather than tar.gz because this file's whole purpose is to be +// handed to somebody else. Windows and every chat client open a zip +// without a second tool; a .tar.gz asks the person you need help from to +// go find one first. func (s *Server) handleSupportDump(w http.ResponseWriter, r *http.Request) { if s.deps.LogRing == nil { writeJSON(w, 503, map[string]string{"error": "log ring not configured"}) @@ -330,24 +335,25 @@ func (s *Server) handleSupportDump(w http.ResponseWriter, r *http.Request) { } now := time.Now().UTC() stamp := now.Format("20060102-150405") - w.Header().Set("Content-Type", "application/gzip") - w.Header().Set("Content-Disposition", `attachment; filename="ftw-support-`+stamp+`.tar.gz"`) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="ftw-support-`+stamp+`.zip"`) w.Header().Set("Cache-Control", "no-store") - gz := gzip.NewWriter(w) - defer gz.Close() - tw := tar.NewWriter(gz) - defer tw.Close() + zw := zip.NewWriter(w) + defer zw.Close() addFile := func(name string, body []byte) { - hdr := &tar.Header{ - Name: "ftw-support-" + stamp + "/" + name, - Mode: 0o644, - Size: int64(len(body)), - ModTime: now, + hdr := &zip.FileHeader{ + Name: "ftw-support-" + stamp + "/" + name, + Method: zip.Deflate, + Modified: now, + } + hdr.SetMode(0o644) + f, err := zw.CreateHeader(hdr) + if err != nil { + return } - _ = tw.WriteHeader(hdr) - _, _ = tw.Write(body) + _, _ = f.Write(body) } // Manifest first so a curious recipient can `tar -xOzf … manifest.json` diff --git a/go/internal/api/api_support_dump_zip_test.go b/go/internal/api/api_support_dump_zip_test.go new file mode 100644 index 00000000..9e77d18f --- /dev/null +++ b/go/internal/api/api_support_dump_zip_test.go @@ -0,0 +1,64 @@ +package api + +import ( + "archive/zip" + "bytes" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// The dump exists to be handed to somebody else, so the archive has to +// open with no extra tooling. This asserts it is a real zip, not just a +// renamed one. +func TestSupportDumpIsAReadableZip(t *testing.T) { + tel := telemetry.NewStore() + tel.DriverHealthMut("meter").RecordSuccess() + ring := telemetry.NewLogRing() + ring.Append(telemetry.LogEntry{Level: "WARN", Msg: "something to log"}) + srv := New(&Deps{ + Ctrl: control.NewState(0, 50, "meter"), CtrlMu: &sync.Mutex{}, + Tel: tel, LogRing: ring, Version: "test", + }) + + req := httptest.NewRequest(http.MethodGet, "/api/support/dump", nil) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/support/dump = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/zip" { + t.Errorf("Content-Type = %q, want application/zip", ct) + } + if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, ".zip") { + t.Errorf("Content-Disposition = %q, want a .zip filename", cd) + } + + body := rec.Body.Bytes() + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + t.Fatalf("archive does not open as a zip: %v", err) + } + var names []string + for _, f := range zr.File { + names = append(names, f.Name) + rc, err := f.Open() + if err != nil { + t.Errorf("entry %s will not open: %v", f.Name, err) + continue + } + rc.Close() + } + joined := strings.Join(names, " ") + for _, want := range []string{"manifest.json", "drivers.json", "logs/global.log"} { + if !strings.Contains(joined, want) { + t.Errorf("archive is missing %s; has %v", want, names) + } + } +} diff --git a/web/diagnostics-modal.js b/web/diagnostics-modal.js index ee106f7d..258a9dbb 100644 --- a/web/diagnostics-modal.js +++ b/web/diagnostics-modal.js @@ -501,7 +501,7 @@ "/api/support/dump", "Preparing support bundle", "Collecting logs, redacted config, driver health, and recent telemetry.", - "ftw-support.tar.gz" + "ftw-support.zip" ); }); state.bodyEl.querySelector('[data-role="research"]').addEventListener("click", function () { From fc96e161d8bfe4bd4d98892af2aa21e7e4c0a333 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 3 Aug 2026 11:36:51 +0200 Subject: [PATCH 2/3] fix(api): one file to send when asking for help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There were two downloads and no way for a user to know which one we wanted: the help report from the plan card, the log bundle from a driver's Diagnose modal. They would send one and we would ask for the other, which is the round-trip the report existed to remove. The dump now leads with the report as 00-help-report.md — named to sort first, because it is the only file most recipients need to read — and the plan card's button downloads that archive. Everything else in there is what you reach for when the report does not settle the question. Zip rather than tar.gz for the same reason the report is one file: it is meant to be handed to somebody else, and Windows and every chat client open a zip with no extra tooling. Deflate per entry, so a single file can be read without unpacking the rest. About 10 kB on a two-driver install. GET /api/support/report still returns the bare Markdown for anyone who wants only the text. The test opens the response with archive/zip, asserts the report sorts first and actually contains Findings, and reads every entry — so neither a renamed tarball nor an empty placeholder passes. Co-Authored-By: Claude Opus 5 --- .changeset/support-dump-zip.md | 21 ++++++++++++----- go/internal/api/api_drivers_debug.go | 11 +++++++-- go/internal/api/api_support_dump_zip_test.go | 24 +++++++++++++++++++- web/help-report.test.mjs | 9 +++++--- web/index.html | 2 +- web/plan.js | 16 +++++++++---- 6 files changed, 65 insertions(+), 18 deletions(-) diff --git a/.changeset/support-dump-zip.md b/.changeset/support-dump-zip.md index 3db01573..9a45baf9 100644 --- a/.changeset/support-dump-zip.md +++ b/.changeset/support-dump-zip.md @@ -2,9 +2,18 @@ "ftw": patch --- -Support dump is now a `.zip` instead of a `.tar.gz`. The file's whole purpose -is to be handed to somebody else, and Windows and every chat client open a zip -without a second tool — a `.tar.gz` asks the person you need help from to go -find one first. Contents and size are unchanged: manifest, redacted config, -driver health, recent logs and an hour of telemetry, around 6 kB on a -two-driver install. +One button, one file when asking for help. The plan card's "Something looks +wrong?" button now downloads `ftw-help-.zip` — the help report as +`00-help-report.md`, sorted first, with the redacted config, driver health, +recent logs and an hour of telemetry behind it. + +Before this there were two downloads and the user had to guess which one we +wanted: the report from the plan card, the log bundle from a driver's Diagnose +modal. They would send one and we would ask for the other. + +The archive is a zip rather than a `.tar.gz` because its whole purpose is to be +handed to somebody else, and Windows and every chat client open a zip without a +second tool. Around 10 kB on a two-driver install. + +`GET /api/support/report` still returns the bare Markdown for anyone who wants +only the text. diff --git a/go/internal/api/api_drivers_debug.go b/go/internal/api/api_drivers_debug.go index d3792a92..a9e82b5a 100644 --- a/go/internal/api/api_drivers_debug.go +++ b/go/internal/api/api_drivers_debug.go @@ -356,8 +356,13 @@ func (s *Server) handleSupportDump(w http.ResponseWriter, r *http.Request) { _, _ = f.Write(body) } - // Manifest first so a curious recipient can `tar -xOzf … manifest.json` - // and see what they've got without unpacking the whole bundle. + // The help report goes in first and is named to sort first, because it + // is the only file in here most recipients need to read. Everything + // below it is what you reach for when the report does not settle the + // question. Shipping them together means the person asking for help + // sends one file and never has to pick the right one. + addFile("00-help-report.md", []byte(s.buildSupportReport(r.Context(), time.Now()))) + manifest := map[string]any{ "generated_at": now.Format(time.RFC3339), "version": s.deps.Version, @@ -365,7 +370,9 @@ func (s *Server) handleSupportDump(w http.ResponseWriter, r *http.Request) { "goos": runtime.GOOS, "goarch": runtime.GOARCH, "hostname": hostnameOrEmpty(), + "read_first": "00-help-report.md", "contents": []string{ + "00-help-report.md", "config.redacted.yaml", "drivers.json", "logs/global.log", diff --git a/go/internal/api/api_support_dump_zip_test.go b/go/internal/api/api_support_dump_zip_test.go index 9e77d18f..554945a1 100644 --- a/go/internal/api/api_support_dump_zip_test.go +++ b/go/internal/api/api_support_dump_zip_test.go @@ -56,7 +56,29 @@ func TestSupportDumpIsAReadableZip(t *testing.T) { rc.Close() } joined := strings.Join(names, " ") - for _, want := range []string{"manifest.json", "drivers.json", "logs/global.log"} { + // The report is the point of the archive for most recipients, so it + // has to be in there and has to sort first. + if len(zr.File) == 0 || !strings.HasSuffix(zr.File[0].Name, "00-help-report.md") { + t.Errorf("first entry is %q, want the help report", names[0]) + } + var reportBody string + for _, f := range zr.File { + if strings.HasSuffix(f.Name, "00-help-report.md") { + rc, _ := f.Open() + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(rc) + rc.Close() + reportBody = buf.String() + } + } + if !strings.Contains(reportBody, "# FTW help report") { + t.Error("the embedded report is not a help report") + } + if !strings.Contains(reportBody, "## Findings") { + t.Error("the embedded report is missing Findings") + } + + for _, want := range []string{"00-help-report.md", "manifest.json", "drivers.json", "logs/global.log"} { if !strings.Contains(joined, want) { t.Errorf("archive is missing %s; has %v", want, names) } diff --git a/web/help-report.test.mjs b/web/help-report.test.mjs index 7a2402bd..4457c18b 100644 --- a/web/help-report.test.mjs +++ b/web/help-report.test.mjs @@ -14,10 +14,13 @@ test("the plan card offers a help report", () => { assert.match(index, /Something looks wrong\?/); }); -test("the help-report button downloads from the report endpoint", () => { +// One button, one file. The archive leads with the readable report and +// carries the logs behind it, so a user asking for help never has to work +// out which of two downloads we meant. +test("the help-report button downloads the bundle, not just the text", () => { assert.match(plan, /getElementById\('plan-help-report'\)/); - assert.match(plan, /apiFetch\('\/api\/support\/report'\)/); - assert.match(plan, /a\.download = 'ftw-help-'/); + assert.match(plan, /apiFetch\('\/api\/support\/dump'\)/); + assert.match(plan, /a\.download = 'ftw-help-' \+ stamp \+ '\.zip'/); }); test("the help-report button reports failure instead of failing silently", () => { diff --git a/web/index.html b/web/index.html index 567aa68a..4256c5d0 100644 --- a/web/index.html +++ b/web/index.html @@ -577,7 +577,7 @@

Plan

- One file describing what FTW is doing and why. Share it when asking for help. + One file describing what FTW is doing and why, with the logs behind it. Share it when asking for help. diff --git a/web/plan.js b/web/plan.js index ab891bed..02efd617 100644 --- a/web/plan.js +++ b/web/plan.js @@ -1028,9 +1028,15 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. if (reportBtn) reportBtn.addEventListener('click', downloadHelpReport); } - // Pulls GET /api/support/report and saves it. Kept here rather than in - // the driver Diagnose modal because the question it answers ("why is it - // doing that?") is asked while looking at the plan, not at a device. + // Pulls GET /api/support/dump and saves it. That archive leads with + // 00-help-report.md — the readable answer to "why is it doing that?" — + // and carries the logs, config and telemetry behind it. One button and + // one file, because a user asking for help should not have to work out + // which of two downloads we wanted. The bare report is still at + // /api/support/report for anyone who wants only the text. + // + // Lives here rather than in the driver Diagnose modal because the + // question is asked while looking at the plan, not at a device. function downloadHelpReport() { const btn = document.getElementById('plan-help-report'); if (!btn || btn.disabled) return; @@ -1044,7 +1050,7 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. btn.textContent = original; }, 4000); }; - apiFetch('/api/support/report') + apiFetch('/api/support/dump') .then(function (resp) { if (!resp.ok) throw new Error('HTTP ' + resp.status); return resp.blob().then(function (blob) { @@ -1052,7 +1058,7 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = 'ftw-help-' + stamp + '.md'; + a.download = 'ftw-help-' + stamp + '.zip'; document.body.appendChild(a); a.click(); a.remove(); From 9fb668bfee8fd9810ca7eefc00bdccad5cf9f175 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 3 Aug 2026 12:09:42 +0200 Subject: [PATCH 3/3] feat(api): put the slot's energy books in the help report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A report could show a plan asking for 4.5 kW and a live target of 0 W and give no way to tell the two cases apart: the plan never reached dispatch, or dispatch finished the slot's energy early and is coasting. Both look identical from outside, and both keep arriving as support threads. control.State now exposes SlotEnergy(): what the plan asked for this slot, what the fleet has actually moved (counted on every tick and every path), and what the energy-allocation path believes it delivered. The last one only moves while that path executes, so a real plan figure beside a zero there says a reactive path is driving instead of the plan — a distinction the report previously could not make at all. Alongside the numbers, a finding: once a quarter of the slot has passed and delivery is under half the rate the plan needs, say so. Pace rather than plain energy, because a slot is allowed to start slowly and catch up; what is not normal is being a quarter through having moved nothing. The ratio is signed, so a slot moving energy the wrong way is always a shortfall. The finding names the three explanations it cannot separate — a safety limit, a charge ceiling, a device that cannot follow — and points at the dispatch table and the log, which can. Better to say what is undecided than to guess at a cause. Co-Authored-By: Claude Opus 5 --- .changeset/support-dump-zip.md | 8 ++ go/internal/api/api_support_report.go | 106 ++++++++++++++++++- go/internal/api/api_support_report_test.go | 115 +++++++++++++++++++-- go/internal/control/dispatch.go | 46 +++++++++ 4 files changed, 265 insertions(+), 10 deletions(-) diff --git a/.changeset/support-dump-zip.md b/.changeset/support-dump-zip.md index 9a45baf9..0518652e 100644 --- a/.changeset/support-dump-zip.md +++ b/.changeset/support-dump-zip.md @@ -17,3 +17,11 @@ second tool. Around 10 kB on a two-driver install. `GET /api/support/report` still returns the bare Markdown for anyone who wants only the text. + +The report also now carries the slot's energy books — what the plan asked for, +what the batteries actually moved, and what the energy-allocation path thinks +it delivered — plus a finding when a slot is a quarter of the way through and +delivery is under half the rate the plan needs. That is the shape of the +reports that keep arriving: a plan card reading "charge 4.5 kW, now", a live +target of 0 W, and nothing in between to show whether the plan reached +dispatch at all. diff --git a/go/internal/api/api_support_report.go b/go/internal/api/api_support_report.go index c57d80a6..c467904c 100644 --- a/go/internal/api/api_support_report.go +++ b/go/internal/api/api_support_report.go @@ -63,6 +63,21 @@ const forecastMissRatio = 1.5 // the comparison to mean anything. const forecastMissFloorW = 500 +// slotEnergyIdleWh matches the dispatcher's own idle gate: below this the +// slot is not asking the battery for anything and cannot fall behind. +const slotEnergyIdleWh = 50 + +// slotPaceMinElapsed is how much of a slot must have passed before its +// delivery rate says anything. A slot is allowed to start slowly and catch +// up; a quarter of the way through is not. +const slotPaceMinElapsed = 0.25 + +// slotPaceFloor is the fraction of the required rate below which delivery +// counts as not happening. Half is generous — a slot tracking its plan at +// all clears it easily — so what trips this is a slot doing essentially +// nothing, or moving energy the wrong way. +const slotPaceFloor = 0.5 + // loadmodelWarmBucketsForTrust is the point past which the weekly load // pattern is filled in enough that its predictions carry the plan. Below // it the planner is guessing, which is worth saying out loud before @@ -163,6 +178,7 @@ func (s *Server) buildSupportReport(ctx context.Context, now time.Time) string { s.deps.CtrlMu.Lock() ctrl := *s.deps.Ctrl targets := append([]control.DispatchTarget{}, s.deps.Ctrl.LastTargets...) + slotEnergy := s.deps.Ctrl.SlotEnergy() s.deps.CtrlMu.Unlock() snap := s.liveNow(ctrl, now) @@ -178,12 +194,12 @@ func (s *Server) buildSupportReport(ctx context.Context, now time.Time) string { } health := s.deps.Tel.AllHealth() - findings := s.collectFindings(ctrl, snap, plan, activeSlot, targets, health, now) + findings := s.collectFindings(ctrl, snap, plan, activeSlot, targets, health, slotEnergy, now) var b strings.Builder writeReportHeader(&b, s.deps.Version, now) writeFindings(&b, findings) - writeRightNow(&b, ctrl, snap, activeSlot, targets, now) + writeRightNow(&b, ctrl, snap, activeSlot, targets, slotEnergy, now) writePlanSection(&b, plan, lastReplanAt, lastReplanReason, now) writeForecastSection(&b, s, snap, activeSlot, now) writeDeviceSection(&b, health, now) @@ -232,6 +248,7 @@ func writeRightNow( snap liveSnapshot, activeSlot *mpc.Action, targets []control.DispatchTarget, + slotEnergy control.SlotEnergySnapshot, now time.Time, ) { b.WriteString("## Right now\n\n") @@ -308,6 +325,26 @@ func writeRightNow( "the log for clamp messages.\n\n") } + // The slot's energy books. Without these a report can show a plan + // asking for 4.5 kW and a target of 0 W and give no way to tell + // whether the plan never reached dispatch, or dispatch already + // finished the slot's energy early and is coasting. + if slotEnergy.HasSlot && !slotEnergy.SlotEnd.IsZero() { + elapsed := now.Sub(slotEnergy.SlotStart) + remaining := slotEnergy.SlotEnd.Sub(now) + fmt.Fprintf(b, "Energy booked for this slot: plan asked for **%s**, "+ + "the batteries have moved **%s** so far, %s elapsed and %s left.\n\n", + fmtReportWh(slotEnergy.PlannedWh), fmtReportWh(slotEnergy.ActualWh), + fmtReportAge(elapsed), fmtReportAge(remaining)) + if slotEnergy.EnergyPathWh != 0 || slotEnergy.PlannedWh != 0 { + fmt.Fprintf(b, "The energy-allocation path counts %s delivered. "+ + "That figure only moves while that path is executing, so a "+ + "real plan figure beside a zero here means the slot is being "+ + "run by a reactive path instead.\n\n", + fmtReportWh(slotEnergy.EnergyPathWh)) + } + } + st := ctrl.SlotDeliveryStats if st.OverDeliveryCount+st.UnderDeliveryCount+st.SignMismatchCount > 0 { fmt.Fprintf(b, "Slot delivery misses since start: %d over, %d under, "+ @@ -598,6 +635,7 @@ func (s *Server) collectFindings( activeSlot *mpc.Action, targets []control.DispatchTarget, health map[string]telemetry.DriverHealth, + slotEnergy control.SlotEnergySnapshot, now time.Time, ) []finding { var out []finding @@ -674,6 +712,26 @@ func (s *Server) collectFindings( } } + // The slot is asking for energy and the batteries are not moving it. + // This is the shape of the reports that keep arriving: a plan card + // reading "charge 4.5 kW, now", a live target of 0 W, and no way from + // the outside to tell whether the plan reached dispatch at all. + if pace, ok := slotPaceShortfall(slotEnergy, now); ok { + detail := fmt.Sprintf("This slot asked for %s and the batteries have "+ + "moved %s with %s of it gone — about %.0f%% of the rate the plan "+ + "needs.", + fmtReportWh(slotEnergy.PlannedWh), fmtReportWh(slotEnergy.ActualWh), + fmtReportAge(now.Sub(slotEnergy.SlotStart)), pace*100) + if slotEnergy.EnergyPathWh == 0 && slotEnergy.PlannedWh != 0 { + detail += " The energy-allocation path has delivered nothing this " + + "slot, so a reactive path is driving instead of the plan." + } + detail += " Safety limits, a charge ceiling and a device that cannot " + + "follow the command all look like this from here — the dispatch " + + "table and the log below separate them." + out = append(out, finding{sevProblem, "The slot's energy is not being delivered", detail}) + } + var clamped []string for _, t := range targets { if t.Clamped { @@ -801,8 +859,52 @@ func forecastMiss(predicted, actual float64) bool { return hi/lo >= forecastMissRatio } +// slotPaceShortfall reports how far behind the slot's required rate the +// batteries actually are, as a fraction of it, and whether that is worth +// saying. Returns (pace, true) only when the slot has a real energy ask, +// enough of it has passed to judge, and delivery is meaningfully behind. +// +// Pace rather than a plain energy comparison, because a slot is allowed +// to be behind early and catch up. What is not normal is being a quarter +// of the way through having moved almost nothing. +func slotPaceShortfall(e control.SlotEnergySnapshot, now time.Time) (float64, bool) { + if !e.HasSlot || e.SlotEnd.IsZero() { + return 0, false + } + if math.Abs(e.PlannedWh) < slotEnergyIdleWh { + return 0, false // an idle slot cannot fall behind + } + total := e.SlotEnd.Sub(e.SlotStart) + elapsed := now.Sub(e.SlotStart) + if total <= 0 || elapsed <= 0 || elapsed > total { + return 0, false + } + fraction := elapsed.Seconds() / total.Seconds() + if fraction < slotPaceMinElapsed { + return 0, false // too early to tell + } + expected := e.PlannedWh * fraction + if expected == 0 { + return 0, false + } + // Signed ratio: wrong-direction delivery lands negative and is + // therefore always a shortfall, which is what it should be. + pace := e.ActualWh / expected + if pace >= slotPaceFloor { + return 0, false + } + return pace, true +} + // ---- helpers ---- +func fmtReportWh(v float64) string { + if math.Abs(v) >= 1000 { + return fmt.Sprintf("%.2f kWh", v/1000) + } + return fmt.Sprintf("%.0f Wh", v) +} + func activeAction(plan *mpc.Plan, now time.Time) *mpc.Action { if plan == nil { return nil diff --git a/go/internal/api/api_support_report_test.go b/go/internal/api/api_support_report_test.go index b500a489..695c823d 100644 --- a/go/internal/api/api_support_report_test.go +++ b/go/internal/api/api_support_report_test.go @@ -94,7 +94,7 @@ func TestSupportReportFlagsLoadForecastMiss(t *testing.T) { LoadW: 7900, PredictedLd: 383, } - findings := srv.collectFindings(*ctrl, snap, nil, nil, nil, nil, now) + findings := srv.collectFindings(*ctrl, snap, nil, nil, nil, nil, control.SlotEnergySnapshot{}, now) var got *finding for i := range findings { @@ -196,7 +196,7 @@ func TestSupportReportMarksTheActiveSlot(t *testing.T) { // And the live section should state the active slot's intent in prose. var live strings.Builder - writeRightNow(&live, *ctrl, liveSnapshot{HaveGrid: true, BatW: -7500}, &plan.Actions[0], nil, now) + writeRightNow(&live, *ctrl, liveSnapshot{HaveGrid: true, BatW: -7500}, &plan.Actions[0], nil, control.SlotEnergySnapshot{}, now) if !strings.Contains(live.String(), "-8.40 kW") { t.Errorf("active-slot intent missing from Right now:\n%s", live.String()) } @@ -214,7 +214,7 @@ func TestSupportReportFlagsFallbackSolver(t *testing.T) { } findings := srv.collectFindings(*ctrl, liveSnapshot{HaveGrid: true, LoadW: 1000, PredictedLd: 1000}, - plan, nil, nil, nil, time.Now()) + plan, nil, nil, nil, control.SlotEnergySnapshot{}, time.Now()) found := false for _, f := range findings { @@ -239,7 +239,7 @@ func TestSupportReportFlagsOfflineAndFaultedDevices(t *testing.T) { } findings := srv.collectFindings(*ctrl, liveSnapshot{HaveGrid: true, LoadW: 1000, PredictedLd: 1000}, - nil, nil, nil, health, time.Now()) + nil, nil, nil, health, control.SlotEnergySnapshot{}, time.Now()) var sawOffline, sawFault bool for _, f := range findings { @@ -356,7 +356,7 @@ func TestRepeatedWarningBecomesAFinding(t *testing.T) { findings := srv.collectFindings(*st, liveSnapshot{HaveGrid: true, LoadW: 1000, PredictedLd: 1000}, - nil, nil, nil, nil, time.Now()) + nil, nil, nil, nil, control.SlotEnergySnapshot{}, time.Now()) var got *finding for i := range findings { @@ -383,8 +383,7 @@ func TestDispatchTableDisclaimsSiteLevelClamps(t *testing.T) { var b strings.Builder writeRightNow(&b, *st, liveSnapshot{HaveGrid: true}, nil, - []control.DispatchTarget{{Driver: "ferroamp", TargetW: 6400, Clamped: false}}, - time.Now()) + []control.DispatchTarget{{Driver: "ferroamp", TargetW: 6400, Clamped: false}}, control.SlotEnergySnapshot{}, time.Now()) out := b.String() if !strings.Contains(out, "per-device limits") { t.Errorf("dispatch table lacks its scope caveat:\n%s", out) @@ -409,7 +408,7 @@ func TestForecastMissIsANoteWhileTheModelIsStillLearning(t *testing.T) { findings := srv.collectFindings(*st, liveSnapshot{HaveGrid: true, LoadW: 3650, PredictedLd: 1470}, - nil, nil, nil, nil, time.Now()) + nil, nil, nil, nil, control.SlotEnergySnapshot{}, time.Now()) var got *finding for i := range findings { @@ -431,3 +430,103 @@ func TestForecastMissIsANoteWhileTheModelIsStillLearning(t *testing.T) { t.Error("a reset must not be recommended to someone whose model is still filling in") } } + +// Björn's second report: plan card reading "Charge battery at 4.5 kW · +// Now, until 15:00", live target 0 W, 4 kW going out to the grid. The +// report showed the plan and the target and gave no way to tell whether +// the plan reached dispatch at all. +func TestSlotEnergyShortfallIsAFinding(t *testing.T) { + srv, ctrl, _ := reportTestServer(t) + now := time.Now() + slot := control.SlotEnergySnapshot{ + HasSlot: true, + PlannedWh: 1125, // 4.5 kW across a 15-minute slot + ActualWh: 20, // the batteries are doing nothing + SlotStart: now.Add(-8 * time.Minute), + SlotEnd: now.Add(7 * time.Minute), + } + findings := srv.collectFindings(*ctrl, + liveSnapshot{HaveGrid: true, LoadW: 700, PredictedLd: 700}, + nil, nil, nil, nil, slot, now) + + var got *finding + for i := range findings { + if strings.Contains(findings[i].Title, "energy is not being delivered") { + got = &findings[i] + } + } + if got == nil { + t.Fatalf("a slot delivering nothing produced no finding: %+v", findings) + } + if got.Severity != sevProblem { + t.Errorf("severity = %q, want %q", got.Severity, sevProblem) + } + if !strings.Contains(got.Detail, "1.12 kWh") { + t.Errorf("detail should quote the planned energy, got %q", got.Detail) + } + if !strings.Contains(got.Detail, "energy-allocation path has delivered nothing") { + t.Errorf("detail should call out the idle energy path, got %q", got.Detail) + } +} + +func TestSlotPaceShortfall(t *testing.T) { + now := time.Now() + slot := func(planned, actual float64, elapsed, total time.Duration) control.SlotEnergySnapshot { + return control.SlotEnergySnapshot{ + HasSlot: true, PlannedWh: planned, ActualWh: actual, + SlotStart: now.Add(-elapsed), SlotEnd: now.Add(total - elapsed), + } + } + cases := []struct { + name string + in control.SlotEnergySnapshot + want bool + }{ + {"delivering nothing against a real ask", + slot(1125, 20, 8*time.Minute, 15*time.Minute), true}, + {"moving energy the wrong way", + slot(1125, -300, 8*time.Minute, 15*time.Minute), true}, + {"tracking the plan", + slot(1125, 560, 8*time.Minute, 15*time.Minute), false}, + {"a little behind but working", + slot(1125, 400, 8*time.Minute, 15*time.Minute), false}, + // A slot is allowed to start slowly and catch up. + {"barely started", + slot(1125, 0, 30*time.Second, 15*time.Minute), false}, + // An idle slot asks for nothing and cannot fall behind. + {"idle slot", + slot(10, 0, 8*time.Minute, 15*time.Minute), false}, + {"discharge slot delivering", + slot(-1125, -560, 8*time.Minute, 15*time.Minute), false}, + {"discharge slot doing nothing", + slot(-1125, -20, 8*time.Minute, 15*time.Minute), true}, + {"no slot in flight", control.SlotEnergySnapshot{}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, got := slotPaceShortfall(tc.in, now) + if got != tc.want { + t.Errorf("slotPaceShortfall = %v, want %v", got, tc.want) + } + }) + } +} + +// The books have to be in the report even when nothing is wrong — that is +// what lets somebody else check the reasoning rather than trust a verdict. +func TestSlotEnergyBooksAreInTheReport(t *testing.T) { + _, ctrl, _ := reportTestServer(t) + now := time.Now() + var b strings.Builder + writeRightNow(&b, *ctrl, liveSnapshot{HaveGrid: true}, nil, nil, + control.SlotEnergySnapshot{ + HasSlot: true, PlannedWh: 1125, ActualWh: 560, EnergyPathWh: 545, + SlotStart: now.Add(-8 * time.Minute), SlotEnd: now.Add(7 * time.Minute), + }, now) + out := b.String() + for _, want := range []string{"1.12 kWh", "560 Wh", "545 Wh", "Energy booked for this slot"} { + if !strings.Contains(out, want) { + t.Errorf("Right now is missing %q:\n%s", want, out) + } + } +} diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 7da1879b..f200ffca 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -648,6 +648,52 @@ func (s *State) GetBatteryManualHold(now time.Time) (BatteryManualHold, bool) { return s.ManualHold, true } +// SlotEnergySnapshot is the per-slot energy accounting, exposed for +// diagnostics. Both accumulators are here because they answer different +// questions and can disagree — which is itself the interesting signal. +// +// ActualWh updates on every dispatch tick regardless of which path ran, +// so it is the honest record of what the fleet moved. EnergyPathWh is +// what the energy-allocation path believes it delivered, and only moves +// while that path is executing. A slot with a real PlannedWh, an +// EnergyPathWh of zero and an ActualWh going nowhere means the plan is +// not reaching the hardware — a case a support report otherwise cannot +// distinguish from "the plan asked for nothing". +type SlotEnergySnapshot struct { + HasSlot bool + // PlannedWh is the plan's BatteryEnergyWh for the slot in flight. + // Site-signed: positive charges. + PlannedWh float64 + // ActualWh is what the fleet has moved since the slot began, counted + // on every tick and every path. + ActualWh float64 + // EnergyPathWh is the energy path's own delivered count. Zero when + // that path has not run this slot. + EnergyPathWh float64 + SlotStart time.Time + SlotEnd time.Time +} + +// SlotEnergy returns the current slot's energy accounting. Caller must +// hold the outer ctrlMu. +func (s *State) SlotEnergy() SlotEnergySnapshot { + out := SlotEnergySnapshot{ + PlannedWh: s.slotActualPlannedWh, + ActualWh: s.slotActualWh, + EnergyPathWh: s.slotDelivered, + SlotStart: s.slotActualSlotStart, + SlotEnd: s.currentDirective.SlotEnd, + } + // The path-agnostic accumulator carries the authoritative slot start; + // the energy path's directive carries the end. Either being unset + // means no slot is in flight yet. + out.HasSlot = !out.SlotStart.IsZero() + if out.SlotEnd.IsZero() && !s.currentDirective.SlotStart.IsZero() { + out.SlotEnd = s.currentDirective.SlotStart + } + return out +} + // PVManualHold is an operator-installed PV curtail override. See // State.ManualPVHold for invariants. //