| @@ -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, | |||
| @@ -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 | |||
| @@ -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, | |||
| @@ -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"` | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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, | |||
| }, | |||
| @@ -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; | |||