From 402a772fe342a2c175bcd087af9f7265b9c18c56 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sun, 22 Mar 2026 15:00:34 +0100 Subject: [PATCH] Consolidate monitor window summary in debug outputs --- cmd/sdrd/decision_compact.go | 2 + cmd/sdrd/dsp_loop.go | 23 +++++-- cmd/sdrd/http_handlers.go | 17 ++++- cmd/sdrd/types.go | 2 + cmd/sdrd/window_summary.go | 113 ++++++++++++++++++++++++++++++++ cmd/sdrd/window_summary_test.go | 47 +++++++++++++ internal/pipeline/decisions.go | 2 + internal/pipeline/scheduler.go | 2 + web/app.js | 5 +- 9 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 cmd/sdrd/window_summary.go create mode 100644 cmd/sdrd/window_summary_test.go diff --git a/cmd/sdrd/decision_compact.go b/cmd/sdrd/decision_compact.go index 5265668..daa4926 100644 --- a/cmd/sdrd/decision_compact.go +++ b/cmd/sdrd/decision_compact.go @@ -10,6 +10,7 @@ type compactDecision struct { Reason string `json:"reason,omitempty"` MonitorBias float64 `json:"monitor_bias,omitempty"` MonitorDetail *pipeline.MonitorWindowMatch `json:"monitor_detail,omitempty"` + MonitorWindow *pipeline.MonitorWindowMatch `json:"monitor_window,omitempty"` RecordWindow *pipeline.MonitorWindowMatch `json:"record_window,omitempty"` DecodeWindow *pipeline.MonitorWindowMatch `json:"decode_window,omitempty"` RecordAdmission *pipeline.PriorityAdmission `json:"record_admission,omitempty"` @@ -28,6 +29,7 @@ func compactDecisions(decisions []pipeline.SignalDecision) []compactDecision { Reason: d.Reason, MonitorBias: d.MonitorBias, MonitorDetail: d.MonitorDetail, + MonitorWindow: d.MonitorWindow, RecordWindow: d.RecordWindow, DecodeWindow: d.DecodeWindow, RecordAdmission: d.RecordAdmission, diff --git a/cmd/sdrd/dsp_loop.go b/cmd/sdrd/dsp_loop.go index 31a5a50..2560dbf 100644 --- a/cmd/sdrd/dsp_loop.go +++ b/cmd/sdrd/dsp_loop.go @@ -13,6 +13,7 @@ import ( "sdr-wideband-suite/internal/config" "sdr-wideband-suite/internal/detector" "sdr-wideband-suite/internal/dsp" + "sdr-wideband-suite/internal/pipeline" "sdr-wideband-suite/internal/recorder" ) @@ -110,7 +111,13 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * } var debugInfo *SpectrumDebug plan := state.refinement.Input.Plan - windowStats := buildWindowStats(state.refinement.Input.Windows) + windowSummary := buildWindowSummary(plan, state.refinement.Input.Windows, state.surveillance.Candidates) + var windowStats *RefinementWindowStats + var monitorSummary []pipeline.MonitorWindowStats + if windowSummary != nil { + windowStats = windowSummary.Refinement + monitorSummary = windowSummary.MonitorWindows + } hasPlan := plan.TotalCandidates > 0 || plan.Budget > 0 || plan.DroppedBySNR > 0 || plan.DroppedByBudget > 0 hasWindows := windowStats != nil && windowStats.Count > 0 if len(thresholds) > 0 || len(displaySignals) > 0 || noiseFloor != 0 || hasPlan || hasWindows { @@ -149,8 +156,11 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * if len(candidateWindows) > 0 { debugInfo.CandidateWindows = candidateWindows } - if len(plan.MonitorWindowStats) > 0 { - debugInfo.MonitorWindowStats = plan.MonitorWindowStats + if len(monitorSummary) > 0 { + debugInfo.MonitorWindowStats = monitorSummary + } + if windowSummary != nil { + debugInfo.WindowSummary = windowSummary } if hasPlan { debugInfo.RefinementPlan = &plan @@ -167,8 +177,11 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * if hasWindows { refinementDebug.Windows = windowStats } - if len(plan.MonitorWindowStats) > 0 { - refinementDebug.MonitorWindowStats = plan.MonitorWindowStats + if len(monitorSummary) > 0 { + refinementDebug.MonitorWindowStats = monitorSummary + } + if windowSummary != nil { + refinementDebug.WindowSummary = windowSummary } refinementDebug.Arbitration = buildArbitrationSnapshot(state.refinement, state.arbitration) debugInfo.Refinement = refinementDebug diff --git a/cmd/sdrd/http_handlers.go b/cmd/sdrd/http_handlers.go index d4d6033..2c8b93e 100644 --- a/cmd/sdrd/http_handlers.go +++ b/cmd/sdrd/http_handlers.go @@ -165,7 +165,19 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime mux.HandleFunc("/api/refinement", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") snap := phaseSnap.Snapshot() - windowStats := buildWindowStats(snap.refinement.Input.Windows) + windowSummary := buildWindowSummary(snap.refinement.Input.Plan, snap.refinement.Input.Windows, snap.surveillance.Candidates) + var windowStats *RefinementWindowStats + var monitorSummary []pipeline.MonitorWindowStats + if windowSummary != nil { + windowStats = windowSummary.Refinement + monitorSummary = windowSummary.MonitorWindows + } + if windowStats == nil { + windowStats = buildWindowStats(snap.refinement.Input.Windows) + } + if len(monitorSummary) == 0 && len(snap.refinement.Input.Plan.MonitorWindowStats) > 0 { + monitorSummary = snap.refinement.Input.Plan.MonitorWindowStats + } arbitration := buildArbitrationSnapshot(snap.refinement, snap.arbitration) levelSet := snap.surveillance.LevelSet spectraBins := map[string]int{} @@ -184,6 +196,7 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime "plan": snap.refinement.Input.Plan, "windows": snap.refinement.Input.Windows, "window_stats": windowStats, + "window_summary": windowSummary, "request": snap.refinement.Input.Request, "context": snap.refinement.Input.Context, "detail_level": snap.refinement.Input.Detail, @@ -220,7 +233,7 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime "candidate_evidence_states": candidateEvidenceStates, "candidate_windows": candidateWindows, "monitor_windows": snap.refinement.Input.Plan.MonitorWindows, - "monitor_window_stats": snap.refinement.Input.Plan.MonitorWindowStats, + "monitor_window_stats": monitorSummary, "display_level": snap.surveillance.DisplayLevel, "refinement_level": snap.refinement.Input.Level, "presentation_level": snap.presentation, diff --git a/cmd/sdrd/types.go b/cmd/sdrd/types.go index c0655ce..c96e5c6 100644 --- a/cmd/sdrd/types.go +++ b/cmd/sdrd/types.go @@ -22,6 +22,7 @@ type SpectrumDebug struct { CandidateEvidenceStates *CandidateEvidenceStateSummary `json:"candidate_evidence_states,omitempty"` CandidateWindows []CandidateWindowSummary `json:"candidate_windows,omitempty"` MonitorWindowStats []pipeline.MonitorWindowStats `json:"monitor_window_stats,omitempty"` + WindowSummary *WindowSummary `json:"window_summary,omitempty"` RefinementPlan *pipeline.RefinementPlan `json:"refinement_plan,omitempty"` Windows *RefinementWindowStats `json:"refinement_windows,omitempty"` Refinement *RefinementDebug `json:"refinement,omitempty"` @@ -42,6 +43,7 @@ type RefinementDebug struct { WorkItems []pipeline.RefinementWorkItem `json:"work_items,omitempty"` Windows *RefinementWindowStats `json:"windows,omitempty"` MonitorWindowStats []pipeline.MonitorWindowStats `json:"monitor_window_stats,omitempty"` + WindowSummary *WindowSummary `json:"window_summary,omitempty"` Arbitration *ArbitrationSnapshot `json:"arbitration,omitempty"` } diff --git a/cmd/sdrd/window_summary.go b/cmd/sdrd/window_summary.go new file mode 100644 index 0000000..8361ebc --- /dev/null +++ b/cmd/sdrd/window_summary.go @@ -0,0 +1,113 @@ +package main + +import ( + "sort" + + "sdr-wideband-suite/internal/pipeline" +) + +type WindowSummary struct { + Refinement *RefinementWindowStats `json:"refinement,omitempty"` + MonitorWindows []pipeline.MonitorWindowStats `json:"monitor_windows,omitempty"` +} + +func buildWindowSummary(plan pipeline.RefinementPlan, refinementWindows []pipeline.RefinementWindow, candidates []pipeline.Candidate) *WindowSummary { + refinementStats := buildWindowStats(refinementWindows) + monitorSummary := buildMonitorWindowSummary(plan.MonitorWindows, plan.MonitorWindowStats, candidates) + if refinementStats == nil && len(monitorSummary) == 0 { + return nil + } + return &WindowSummary{ + Refinement: refinementStats, + MonitorWindows: monitorSummary, + } +} + +func buildMonitorWindowSummary(windows []pipeline.MonitorWindow, stats []pipeline.MonitorWindowStats, candidates []pipeline.Candidate) []pipeline.MonitorWindowStats { + var summary []pipeline.MonitorWindowStats + switch { + case len(stats) > 0: + summary = append([]pipeline.MonitorWindowStats(nil), stats...) + case len(windows) > 0: + summary = make([]pipeline.MonitorWindowStats, 0, len(windows)) + for _, win := range windows { + summary = append(summary, pipeline.MonitorWindowStats{ + Index: win.Index, + Label: win.Label, + Zone: win.Zone, + Source: win.Source, + StartHz: win.StartHz, + EndHz: win.EndHz, + CenterHz: win.CenterHz, + SpanHz: win.SpanHz, + Priority: win.Priority, + PriorityBias: win.PriorityBias, + RecordBias: win.RecordBias, + DecodeBias: win.DecodeBias, + AutoRecord: win.AutoRecord, + AutoDecode: win.AutoDecode, + }) + } + default: + return nil + } + + if len(candidates) > 0 && len(summary) > 0 { + windowsForMatch := windows + if len(windowsForMatch) == 0 { + windowsForMatch = monitorWindowsFromStats(summary) + } + if len(windowsForMatch) > 0 { + counts := map[int]int{} + total := 0 + for _, cand := range candidates { + matches := cand.MonitorMatches + if len(matches) == 0 { + matches = pipeline.MonitorWindowMatchesForCandidate(windowsForMatch, cand) + } + for _, match := range matches { + counts[match.Index]++ + total++ + } + } + if total > 0 { + for i := range summary { + if summary[i].Candidates == 0 { + summary[i].Candidates = counts[summary[i].Index] + } + } + } + } + } + + sort.Slice(summary, func(i, j int) bool { + return summary[i].Index < summary[j].Index + }) + return summary +} + +func monitorWindowsFromStats(stats []pipeline.MonitorWindowStats) []pipeline.MonitorWindow { + if len(stats) == 0 { + return nil + } + windows := make([]pipeline.MonitorWindow, 0, len(stats)) + for _, stat := range stats { + windows = append(windows, pipeline.MonitorWindow{ + Index: stat.Index, + Label: stat.Label, + Zone: stat.Zone, + Source: stat.Source, + StartHz: stat.StartHz, + EndHz: stat.EndHz, + CenterHz: stat.CenterHz, + SpanHz: stat.SpanHz, + Priority: stat.Priority, + PriorityBias: stat.PriorityBias, + RecordBias: stat.RecordBias, + DecodeBias: stat.DecodeBias, + AutoRecord: stat.AutoRecord, + AutoDecode: stat.AutoDecode, + }) + } + return windows +} diff --git a/cmd/sdrd/window_summary_test.go b/cmd/sdrd/window_summary_test.go new file mode 100644 index 0000000..db4d8da --- /dev/null +++ b/cmd/sdrd/window_summary_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "testing" + + "sdr-wideband-suite/internal/pipeline" +) + +func TestBuildMonitorWindowSummaryCountsCandidates(t *testing.T) { + windows := []pipeline.MonitorWindow{ + {Index: 0, Label: "primary", StartHz: 100, EndHz: 200, CenterHz: 150, SpanHz: 100}, + {Index: 1, Label: "secondary", StartHz: 300, EndHz: 400, CenterHz: 350, SpanHz: 100}, + } + candidates := []pipeline.Candidate{ + {ID: 1, CenterHz: 150, BandwidthHz: 20}, + {ID: 2, CenterHz: 320, BandwidthHz: 10}, + } + summary := buildMonitorWindowSummary(windows, nil, candidates) + if len(summary) != 2 { + t.Fatalf("expected 2 window summaries, got %d", len(summary)) + } + if summary[0].Candidates != 1 || summary[1].Candidates != 1 { + t.Fatalf("unexpected candidate counts: %+v", summary) + } +} + +func TestBuildMonitorWindowSummaryPreservesStatsCounts(t *testing.T) { + stats := []pipeline.MonitorWindowStats{ + {Index: 0, Label: "primary", StartHz: 100, EndHz: 200, CenterHz: 150, SpanHz: 100, Candidates: 2, Planned: 1}, + } + windows := []pipeline.MonitorWindow{ + {Index: 0, Label: "primary", StartHz: 100, EndHz: 200, CenterHz: 150, SpanHz: 100}, + } + candidates := []pipeline.Candidate{ + {ID: 1, CenterHz: 150, BandwidthHz: 20}, + } + summary := buildMonitorWindowSummary(windows, stats, candidates) + if len(summary) != 1 { + t.Fatalf("expected 1 window summary, got %d", len(summary)) + } + if summary[0].Candidates != 2 { + t.Fatalf("expected candidates to stay at 2, got %d", summary[0].Candidates) + } + if summary[0].Planned != 1 { + t.Fatalf("expected planned to stay at 1, got %d", summary[0].Planned) + } +} diff --git a/internal/pipeline/decisions.go b/internal/pipeline/decisions.go index 6551e1e..45ca8cb 100644 --- a/internal/pipeline/decisions.go +++ b/internal/pipeline/decisions.go @@ -16,6 +16,7 @@ type SignalDecision struct { RecordBias float64 `json:"record_bias,omitempty"` DecodeBias float64 `json:"decode_bias,omitempty"` MonitorDetail *MonitorWindowMatch `json:"monitor_detail,omitempty"` + MonitorWindow *MonitorWindowMatch `json:"monitor_window,omitempty"` RecordWindow *MonitorWindowMatch `json:"record_window,omitempty"` DecodeWindow *MonitorWindowMatch `json:"decode_window,omitempty"` RecordAdmission *PriorityAdmission `json:"record_admission,omitempty"` @@ -89,6 +90,7 @@ func DecideSignalAction(policy Policy, candidate Candidate, cls *classifier.Clas } if monitorDetail != nil { decision.MonitorDetail = monitorDetail + decision.MonitorWindow = monitorDetail } return decision } diff --git a/internal/pipeline/scheduler.go b/internal/pipeline/scheduler.go index 0f7cff6..a6f31e4 100644 --- a/internal/pipeline/scheduler.go +++ b/internal/pipeline/scheduler.go @@ -30,6 +30,7 @@ type RefinementScoreDetails struct { PolicyBoost float64 `json:"policy_boost"` MonitorBias float64 `json:"monitor_bias,omitempty"` MonitorDetail *MonitorWindowMatch `json:"monitor_detail,omitempty"` + MonitorWindow *MonitorWindowMatch `json:"monitor_window,omitempty"` EvidenceScore float64 `json:"evidence_score"` EvidenceDetail *EvidenceScoreDetails `json:"evidence_detail,omitempty"` } @@ -201,6 +202,7 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget PolicyBoost: policyBoost, MonitorBias: monitorBias, MonitorDetail: monitorDetail, + MonitorWindow: monitorDetail, EvidenceScore: evidenceScore, EvidenceDetail: &evidenceDetail, }, diff --git a/web/app.js b/web/app.js index 22b7c84..76a656a 100644 --- a/web/app.js +++ b/web/app.js @@ -849,7 +849,8 @@ function updateHeroMetrics() { ? `CFAR ${showDebugOverlay ? 'on' : 'hidden'} · noise ${(Number.isFinite(debug.noise_floor) ? debug.noise_floor.toFixed(1) : 'n/a')} dB` : `CFAR off · noise ${(Number.isFinite(debug.noise_floor) ? debug.noise_floor.toFixed(1) : 'n/a')} dB`; const plan = debug.refinement_plan || null; - const windows = debug.refinement_windows || null; + const windowSummary = debug.window_summary || null; + const windows = (windowSummary && windowSummary.refinement) || debug.refinement_windows || null; const refineInfo = plan && showDebugOverlay ? `refine ${plan.selected?.length || 0}/${plan.budget || 0} drop ${plan.dropped_by_snr || 0}/${plan.dropped_by_budget || 0}` : ''; @@ -881,7 +882,7 @@ function updateHeroMetrics() { healthRefinePlan.textContent = `${plan.selected?.length || 0}/${plan.budget || 0} · drop ${plan.dropped_by_snr || 0}/${plan.dropped_by_budget || 0} · rec ${recOn} dec ${decOn} ${queueText} ${reasonText}`; } if (healthRefineWindows) { - const stats = refinementInfo.window_stats || null; + const stats = refinementInfo.window_summary?.refinement || refinementInfo.window_stats || null; if (stats && stats.count) { const levelSet = refinementInfo.surveillance_level_set || {}; const primary = levelSet.primary || refinementInfo.surveillance_level;