diff --git a/cmd/sdrd/http_handlers.go b/cmd/sdrd/http_handlers.go index 8eeca71..a8eac35 100644 --- a/cmd/sdrd/http_handlers.go +++ b/cmd/sdrd/http_handlers.go @@ -165,6 +165,7 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime snap := phaseSnap.Snapshot() windowStats := buildWindowStats(snap.refinement.Input.Windows) arbitration := buildArbitrationSnapshot(snap.refinement, snap.arbitration) + levelSet := snap.surveillance.LevelSet spectraBins := map[string]int{} for _, spec := range snap.surveillance.Spectra { if len(spec.Spectrum) == 0 { @@ -172,25 +173,46 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime } spectraBins[spec.Level.Name] = len(spec.Spectrum) } + levelSummaries := buildSurveillanceLevelSummaries(levelSet, snap.surveillance.Spectra) + candidateSources := buildCandidateSourceSummary(snap.surveillance.Candidates) + candidateEvidence := buildCandidateEvidenceSummary(snap.surveillance.Candidates) out := map[string]any{ - "plan": snap.refinement.Input.Plan, - "windows": snap.refinement.Input.Windows, - "window_stats": windowStats, - "request": snap.refinement.Input.Request, - "context": snap.refinement.Input.Context, - "detail_level": snap.refinement.Input.Detail, - "arbitration": arbitration, - "work_items": snap.refinement.Input.WorkItems, - "candidates": len(snap.refinement.Input.Candidates), - "scheduled": len(snap.refinement.Input.Scheduled), - "signals": len(snap.refinement.Result.Signals), - "decisions": len(snap.refinement.Result.Decisions), - "surveillance_level": snap.surveillance.Level, - "surveillance_levels": snap.surveillance.Levels, - "surveillance_spectra_bins": spectraBins, - "display_level": snap.surveillance.DisplayLevel, - "refinement_level": snap.refinement.Input.Level, - "presentation_level": snap.presentation, + "plan": snap.refinement.Input.Plan, + "windows": snap.refinement.Input.Windows, + "window_stats": windowStats, + "request": snap.refinement.Input.Request, + "context": snap.refinement.Input.Context, + "detail_level": snap.refinement.Input.Detail, + "arbitration": arbitration, + "work_items": snap.refinement.Input.WorkItems, + "candidates": len(snap.refinement.Input.Candidates), + "scheduled": len(snap.refinement.Input.Scheduled), + "signals": len(snap.refinement.Result.Signals), + "decisions": len(snap.refinement.Result.Decisions), + "surveillance_level": snap.surveillance.Level, + "surveillance_levels": snap.surveillance.Levels, + "surveillance_level_set": levelSet, + "surveillance_active_levels": func() []pipeline.AnalysisLevel { + if len(levelSet.All) > 0 { + return levelSet.All + } + active := make([]pipeline.AnalysisLevel, 0, len(snap.surveillance.Levels)+1) + if snap.surveillance.Level.Name != "" { + active = append(active, snap.surveillance.Level) + } + active = append(active, snap.surveillance.Levels...) + if snap.surveillance.DisplayLevel.Name != "" { + active = append(active, snap.surveillance.DisplayLevel) + } + return active + }(), + "surveillance_level_summary": levelSummaries, + "surveillance_spectra_bins": spectraBins, + "candidate_sources": candidateSources, + "candidate_evidence": candidateEvidence, + "display_level": snap.surveillance.DisplayLevel, + "refinement_level": snap.refinement.Input.Level, + "presentation_level": snap.presentation, } _ = json.NewEncoder(w).Encode(out) }) diff --git a/cmd/sdrd/level_summary.go b/cmd/sdrd/level_summary.go new file mode 100644 index 0000000..045ea0a --- /dev/null +++ b/cmd/sdrd/level_summary.go @@ -0,0 +1,135 @@ +package main + +import ( + "sort" + + "sdr-wideband-suite/internal/pipeline" +) + +type SurveillanceLevelSummary struct { + Name string `json:"name"` + Role string `json:"role,omitempty"` + Truth string `json:"truth,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` + FFTSize int `json:"fft_size,omitempty"` + BinHz float64 `json:"bin_hz,omitempty"` + Decimation int `json:"decimation,omitempty"` + SpanHz float64 `json:"span_hz,omitempty"` + CenterHz float64 `json:"center_hz,omitempty"` + Source string `json:"source,omitempty"` + SpectrumBins int `json:"spectrum_bins,omitempty"` +} + +type CandidateEvidenceSummary struct { + Level string `json:"level"` + Provenance string `json:"provenance,omitempty"` + Count int `json:"count"` +} + +func buildSurveillanceLevelSummaries(set pipeline.SurveillanceLevelSet, spectra []pipeline.SurveillanceLevelSpectrum) map[string]SurveillanceLevelSummary { + if set.Primary.Name == "" && len(set.Derived) == 0 && set.Presentation.Name == "" && len(set.All) == 0 { + return nil + } + bins := map[string]int{} + for _, spec := range spectra { + if spec.Level.Name == "" || len(spec.Spectrum) == 0 { + continue + } + bins[spec.Level.Name] = len(spec.Spectrum) + } + levels := set.All + if len(levels) == 0 { + if set.Primary.Name != "" { + levels = append(levels, set.Primary) + } + if len(set.Derived) > 0 { + levels = append(levels, set.Derived...) + } + if set.Presentation.Name != "" { + levels = append(levels, set.Presentation) + } + } + out := make(map[string]SurveillanceLevelSummary, len(levels)) + for _, level := range levels { + name := level.Name + if name == "" { + continue + } + binHz := level.BinHz + if binHz == 0 && level.SampleRate > 0 && level.FFTSize > 0 { + binHz = float64(level.SampleRate) / float64(level.FFTSize) + } + out[name] = SurveillanceLevelSummary{ + Name: name, + Role: level.Role, + Truth: level.Truth, + SampleRate: level.SampleRate, + FFTSize: level.FFTSize, + BinHz: binHz, + Decimation: level.Decimation, + SpanHz: level.SpanHz, + CenterHz: level.CenterHz, + Source: level.Source, + SpectrumBins: bins[name], + } + } + if len(out) == 0 { + return nil + } + return out +} + +func buildCandidateSourceSummary(candidates []pipeline.Candidate) map[string]int { + if len(candidates) == 0 { + return nil + } + out := map[string]int{} + for _, cand := range candidates { + if cand.Source == "" { + continue + } + out[cand.Source]++ + } + if len(out) == 0 { + return nil + } + return out +} + +func buildCandidateEvidenceSummary(candidates []pipeline.Candidate) []CandidateEvidenceSummary { + if len(candidates) == 0 { + return nil + } + type key struct { + level string + provenance string + } + counts := map[key]int{} + for _, cand := range candidates { + for _, ev := range cand.Evidence { + name := ev.Level.Name + if name == "" { + name = "unknown" + } + k := key{level: name, provenance: ev.Provenance} + counts[k]++ + } + } + if len(counts) == 0 { + return nil + } + out := make([]CandidateEvidenceSummary, 0, len(counts)) + for k, v := range counts { + out = append(out, CandidateEvidenceSummary{Level: k.level, Provenance: k.provenance, Count: v}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count == out[j].Count { + if out[i].Level == out[j].Level { + return out[i].Provenance < out[j].Provenance + } + return out[i].Level < out[j].Level + } + return out[i].Count > out[j].Count + }) + return out +} diff --git a/web/app.js b/web/app.js index 2f5c649..22b7c84 100644 --- a/web/app.js +++ b/web/app.js @@ -771,7 +771,11 @@ function formatLevelSummary(level) { const name = level.name || 'level'; const fft = level.fft_size ? `${level.fft_size} bins` : 'bins n/a'; const span = level.span_hz ? fmtHz(level.span_hz) : 'span n/a'; - return `${name} · ${fft} · ${span}`; + const binHz = level.bin_hz || ((level.sample_rate && level.fft_size) ? (level.sample_rate / level.fft_size) : 0); + const binText = binHz ? `${binHz.toFixed(1)} Hz/bin` : 'bin n/a'; + const decim = level.decimation && level.decimation > 1 ? `decim ${level.decimation}` : ''; + const source = level.source ? `src ${level.source}` : ''; + return [name, fft, span, binText, decim, source].filter(Boolean).join(' · '); } function queueConfigUpdate(partial) { @@ -879,8 +883,12 @@ function updateHeroMetrics() { if (healthRefineWindows) { const stats = refinementInfo.window_stats || null; if (stats && stats.count) { - const levels = refinementInfo.surveillance_level ? ` · ${formatLevelSummary(refinementInfo.surveillance_level)}` : ''; - healthRefineWindows.textContent = `${fmtHz(stats.min_span_hz || 0)}–${fmtHz(stats.max_span_hz || 0)}${levels}`; + const levelSet = refinementInfo.surveillance_level_set || {}; + const primary = levelSet.primary || refinementInfo.surveillance_level; + const presentation = levelSet.presentation || refinementInfo.display_level || null; + const primaryText = primary ? ` · primary ${formatLevelSummary(primary)}` : ''; + const presentationText = presentation ? ` · display ${formatLevelSummary(presentation)}` : ''; + healthRefineWindows.textContent = `${fmtHz(stats.min_span_hz || 0)}–${fmtHz(stats.max_span_hz || 0)}${primaryText}${presentationText}`; } else { const windows = refinementInfo.windows || []; if (!Array.isArray(windows) || windows.length === 0) {