diff --git a/cmd/sdrd/dsp_loop.go b/cmd/sdrd/dsp_loop.go index 2560dbf..52dc8f9 100644 --- a/cmd/sdrd/dsp_loop.go +++ b/cmd/sdrd/dsp_loop.go @@ -111,7 +111,7 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * } var debugInfo *SpectrumDebug plan := state.refinement.Input.Plan - windowSummary := buildWindowSummary(plan, state.refinement.Input.Windows, state.surveillance.Candidates) + windowSummary := buildWindowSummary(plan, state.refinement.Input.Windows, state.surveillance.Candidates, state.refinement.Input.WorkItems, state.refinement.Result.Decisions) var windowStats *RefinementWindowStats var monitorSummary []pipeline.MonitorWindowStats if windowSummary != nil { diff --git a/cmd/sdrd/http_handlers.go b/cmd/sdrd/http_handlers.go index 2c8b93e..14c0846 100644 --- a/cmd/sdrd/http_handlers.go +++ b/cmd/sdrd/http_handlers.go @@ -165,7 +165,7 @@ 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() - windowSummary := buildWindowSummary(snap.refinement.Input.Plan, snap.refinement.Input.Windows, snap.surveillance.Candidates) + windowSummary := buildWindowSummary(snap.refinement.Input.Plan, snap.refinement.Input.Windows, snap.surveillance.Candidates, snap.refinement.Input.WorkItems, snap.refinement.Result.Decisions) var windowStats *RefinementWindowStats var monitorSummary []pipeline.MonitorWindowStats if windowSummary != nil { diff --git a/cmd/sdrd/window_summary.go b/cmd/sdrd/window_summary.go index 8361ebc..357394e 100644 --- a/cmd/sdrd/window_summary.go +++ b/cmd/sdrd/window_summary.go @@ -2,6 +2,7 @@ package main import ( "sort" + "strings" "sdr-wideband-suite/internal/pipeline" ) @@ -9,17 +10,84 @@ import ( type WindowSummary struct { Refinement *RefinementWindowStats `json:"refinement,omitempty"` MonitorWindows []pipeline.MonitorWindowStats `json:"monitor_windows,omitempty"` + Outcomes *WindowOutcomeSummary `json:"outcomes,omitempty"` } -func buildWindowSummary(plan pipeline.RefinementPlan, refinementWindows []pipeline.RefinementWindow, candidates []pipeline.Candidate) *WindowSummary { +type WindowOutcomeSummary struct { + Windows []MonitorWindowOutcome `json:"windows,omitempty"` + Zones []MonitorZoneOutcome `json:"zones,omitempty"` +} + +type MonitorWindowOutcome struct { + Index int `json:"index"` + Label string `json:"label,omitempty"` + Zone string `json:"zone,omitempty"` + Refinement OutcomeCounts `json:"refinement,omitempty"` + Record OutcomeCounts `json:"record,omitempty"` + Decode OutcomeCounts `json:"decode,omitempty"` +} + +type MonitorZoneOutcome struct { + Zone string `json:"zone"` + Refinement OutcomeCounts `json:"refinement,omitempty"` + Record OutcomeCounts `json:"record,omitempty"` + Decode OutcomeCounts `json:"decode,omitempty"` +} + +type OutcomeCounts struct { + Admit int `json:"admit,omitempty"` + Hold int `json:"hold,omitempty"` + Displace int `json:"displace,omitempty"` + Defer int `json:"defer,omitempty"` + Drop int `json:"drop,omitempty"` + Enabled int `json:"enabled,omitempty"` +} + +func (o *OutcomeCounts) addClass(class string) { + switch class { + case pipeline.AdmissionClassAdmit: + o.Admit++ + case pipeline.AdmissionClassHold: + o.Hold++ + case pipeline.AdmissionClassDisplace: + o.Displace++ + case pipeline.AdmissionClassDefer: + o.Defer++ + case pipeline.AdmissionClassDrop: + o.Drop++ + } +} + +func (o *OutcomeCounts) addEnabled(enabled bool) { + if enabled { + o.Enabled++ + } +} + +func (o OutcomeCounts) hasAny() bool { + return o.Admit > 0 || o.Hold > 0 || o.Displace > 0 || o.Defer > 0 || o.Drop > 0 || o.Enabled > 0 +} + +func (o *OutcomeCounts) addTotals(in OutcomeCounts) { + o.Admit += in.Admit + o.Hold += in.Hold + o.Displace += in.Displace + o.Defer += in.Defer + o.Drop += in.Drop + o.Enabled += in.Enabled +} + +func buildWindowSummary(plan pipeline.RefinementPlan, refinementWindows []pipeline.RefinementWindow, candidates []pipeline.Candidate, workItems []pipeline.RefinementWorkItem, decisions []pipeline.SignalDecision) *WindowSummary { refinementStats := buildWindowStats(refinementWindows) monitorSummary := buildMonitorWindowSummary(plan.MonitorWindows, plan.MonitorWindowStats, candidates) - if refinementStats == nil && len(monitorSummary) == 0 { + outcomes := buildWindowOutcomeSummary(plan.MonitorWindows, plan.MonitorWindowStats, workItems, decisions) + if refinementStats == nil && len(monitorSummary) == 0 && outcomes == nil { return nil } return &WindowSummary{ Refinement: refinementStats, MonitorWindows: monitorSummary, + Outcomes: outcomes, } } @@ -86,6 +154,132 @@ func buildMonitorWindowSummary(windows []pipeline.MonitorWindow, stats []pipelin return summary } +func buildWindowOutcomeSummary(windows []pipeline.MonitorWindow, stats []pipeline.MonitorWindowStats, workItems []pipeline.RefinementWorkItem, decisions []pipeline.SignalDecision) *WindowOutcomeSummary { + base := windows + if len(base) == 0 && len(stats) > 0 { + base = monitorWindowsFromStats(stats) + } + if len(base) == 0 { + return nil + } + outcomes := make([]MonitorWindowOutcome, 0, len(base)) + index := make(map[int]int, len(base)) + for _, win := range base { + outcomes = append(outcomes, MonitorWindowOutcome{ + Index: win.Index, + Label: win.Label, + Zone: win.Zone, + }) + index[win.Index] = len(outcomes) - 1 + } + windowsForMatch := base + if len(workItems) > 0 { + for _, item := range workItems { + class := outcomeClassForWorkItem(item) + if class == "" { + continue + } + matches := item.Candidate.MonitorMatches + if len(matches) == 0 { + matches = pipeline.MonitorWindowMatchesForCandidate(windowsForMatch, item.Candidate) + } + for _, match := range matches { + if idx, ok := index[match.Index]; ok { + outcomes[idx].Refinement.addClass(class) + } + } + } + } + if len(decisions) > 0 { + for _, decision := range decisions { + if match := decisionWindowMatch(decision, "record"); match != nil { + if idx, ok := index[match.Index]; ok { + outcomes[idx].Record.addEnabled(decision.ShouldRecord) + if decision.RecordAdmission != nil { + outcomes[idx].Record.addClass(decision.RecordAdmission.Class) + } + } + } + if match := decisionWindowMatch(decision, "decode"); match != nil { + if idx, ok := index[match.Index]; ok { + outcomes[idx].Decode.addEnabled(decision.ShouldAutoDecode) + if decision.DecodeAdmission != nil { + outcomes[idx].Decode.addClass(decision.DecodeAdmission.Class) + } + } + } + } + } + hasOutcome := false + for i := range outcomes { + if outcomes[i].Refinement.hasAny() || outcomes[i].Record.hasAny() || outcomes[i].Decode.hasAny() { + hasOutcome = true + break + } + } + if !hasOutcome { + return nil + } + sort.Slice(outcomes, func(i, j int) bool { + return outcomes[i].Index < outcomes[j].Index + }) + zoneIndex := map[string]int{} + zones := make([]MonitorZoneOutcome, 0, len(outcomes)) + for _, outcome := range outcomes { + zone := strings.TrimSpace(outcome.Zone) + if zone == "" { + continue + } + idx, ok := zoneIndex[zone] + if !ok { + zones = append(zones, MonitorZoneOutcome{Zone: zone}) + idx = len(zones) - 1 + zoneIndex[zone] = idx + } + zones[idx].Refinement.addTotals(outcome.Refinement) + zones[idx].Record.addTotals(outcome.Record) + zones[idx].Decode.addTotals(outcome.Decode) + } + if len(zones) > 0 { + sort.Slice(zones, func(i, j int) bool { + return zones[i].Zone < zones[j].Zone + }) + } + return &WindowOutcomeSummary{Windows: outcomes, Zones: zones} +} + +func outcomeClassForWorkItem(item pipeline.RefinementWorkItem) string { + if item.Admission != nil && item.Admission.Class != "" { + return item.Admission.Class + } + switch item.Status { + case pipeline.RefinementStatusAdmitted, pipeline.RefinementStatusRunning, pipeline.RefinementStatusCompleted: + return pipeline.AdmissionClassAdmit + case pipeline.RefinementStatusDisplaced: + return pipeline.AdmissionClassDisplace + case pipeline.RefinementStatusSkipped: + return pipeline.AdmissionClassDefer + case pipeline.RefinementStatusDropped: + return pipeline.AdmissionClassDrop + default: + return "" + } +} + +func decisionWindowMatch(decision pipeline.SignalDecision, action string) *pipeline.MonitorWindowMatch { + switch strings.ToLower(strings.TrimSpace(action)) { + case "record": + if decision.RecordWindow != nil { + return decision.RecordWindow + } + case "decode": + if decision.DecodeWindow != nil { + return decision.DecodeWindow + } + } + return decision.MonitorDetail +} + func monitorWindowsFromStats(stats []pipeline.MonitorWindowStats) []pipeline.MonitorWindow { if len(stats) == 0 { return nil diff --git a/cmd/sdrd/window_summary_test.go b/cmd/sdrd/window_summary_test.go index db4d8da..00575eb 100644 --- a/cmd/sdrd/window_summary_test.go +++ b/cmd/sdrd/window_summary_test.go @@ -45,3 +45,94 @@ func TestBuildMonitorWindowSummaryPreservesStatsCounts(t *testing.T) { t.Fatalf("expected planned to stay at 1, got %d", summary[0].Planned) } } + +func TestBuildWindowOutcomeSummaryTracksPressureByWindowAndZone(t *testing.T) { + windows := []pipeline.MonitorWindow{ + {Index: 0, Label: "alpha", Zone: "north", StartHz: 100, EndHz: 200}, + {Index: 1, Label: "beta", Zone: "south", StartHz: 300, EndHz: 400}, + } + match0 := pipeline.MonitorWindowMatch{Index: 0, Label: "alpha", Zone: "north"} + match1 := pipeline.MonitorWindowMatch{Index: 1, Label: "beta", Zone: "south"} + workItems := []pipeline.RefinementWorkItem{ + { + Candidate: pipeline.Candidate{ID: 1, MonitorMatches: []pipeline.MonitorWindowMatch{match0}}, + Admission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassAdmit}, + }, + { + Candidate: pipeline.Candidate{ID: 2, MonitorMatches: []pipeline.MonitorWindowMatch{match0}}, + Admission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassHold}, + }, + { + Candidate: pipeline.Candidate{ID: 3, MonitorMatches: []pipeline.MonitorWindowMatch{match1}}, + Admission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassDisplace}, + }, + { + Candidate: pipeline.Candidate{ID: 4, MonitorMatches: []pipeline.MonitorWindowMatch{match1}}, + Admission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassDefer}, + }, + } + decisions := []pipeline.SignalDecision{ + { + Candidate: pipeline.Candidate{ID: 1}, + ShouldRecord: true, + RecordWindow: &match0, + RecordAdmission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassAdmit}, + }, + { + Candidate: pipeline.Candidate{ID: 2}, + ShouldRecord: false, + RecordWindow: &match0, + RecordAdmission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassDisplace}, + }, + { + Candidate: pipeline.Candidate{ID: 3}, + ShouldAutoDecode: true, + DecodeWindow: &match1, + DecodeAdmission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassHold}, + }, + { + Candidate: pipeline.Candidate{ID: 4}, + ShouldAutoDecode: false, + DecodeWindow: &match1, + DecodeAdmission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassDefer}, + }, + } + summary := buildWindowSummary(pipeline.RefinementPlan{MonitorWindows: windows}, nil, nil, workItems, decisions) + if summary == nil || summary.Outcomes == nil { + t.Fatalf("expected outcome summary to be populated") + } + if len(summary.Outcomes.Windows) != 2 { + t.Fatalf("expected 2 window outcomes, got %d", len(summary.Outcomes.Windows)) + } + win0 := summary.Outcomes.Windows[0] + win1 := summary.Outcomes.Windows[1] + if win0.Refinement.Admit != 1 || win0.Refinement.Hold != 1 { + t.Fatalf("unexpected refinement outcomes for window 0: %+v", win0.Refinement) + } + if win0.Record.Admit != 1 || win0.Record.Displace != 1 || win0.Record.Enabled != 1 { + t.Fatalf("unexpected record outcomes for window 0: %+v", win0.Record) + } + if win1.Refinement.Displace != 1 || win1.Refinement.Defer != 1 { + t.Fatalf("unexpected refinement outcomes for window 1: %+v", win1.Refinement) + } + if win1.Decode.Hold != 1 || win1.Decode.Defer != 1 || win1.Decode.Enabled != 1 { + t.Fatalf("unexpected decode outcomes for window 1: %+v", win1.Decode) + } + if len(summary.Outcomes.Zones) != 2 { + t.Fatalf("expected 2 zone outcomes, got %d", len(summary.Outcomes.Zones)) + } + for _, zone := range summary.Outcomes.Zones { + switch zone.Zone { + case "north": + if zone.Refinement.Admit != 1 || zone.Refinement.Hold != 1 { + t.Fatalf("unexpected north zone refinement: %+v", zone.Refinement) + } + case "south": + if zone.Refinement.Displace != 1 || zone.Refinement.Defer != 1 { + t.Fatalf("unexpected south zone refinement: %+v", zone.Refinement) + } + default: + t.Fatalf("unexpected zone %q", zone.Zone) + } + } +}