| @@ -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) | |||
| }) | |||
| @@ -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 | |||
| } | |||
| @@ -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) { | |||