| @@ -62,6 +62,7 @@ Edit `config.yaml` (autosave goes to `config.autosave.yaml`). | |||
| - `pipeline.goals.*` -- declarative target/intent layer for future autonomous operation | |||
| - `intent` | |||
| - `monitor_start_hz` / `monitor_end_hz` / `monitor_span_hz` | |||
| - `monitor_windows` -- optional list of monitor windows (each window uses `start_hz` + `end_hz` or `center_hz` + `span_hz`) | |||
| - `signal_priorities` | |||
| - `auto_record_classes` | |||
| - `auto_decode_classes` | |||
| @@ -121,6 +122,8 @@ Phase-3 status (Wave 3E): | |||
| The long-term target is that you describe *what the system should do* (for example broad-span monitoring intent, preferred signal families, auto-record/decode priorities), while the engine decides *how* to allocate surveillance, refinement and decoding budgets. | |||
| Phase-4 groundwork: `monitor_windows` allows multi-span gating within a single capture span; it is used to accept/reject candidates and inform refinement plans without changing the acquisition center/rate. | |||
| **CFAR modes:** `OFF`, `CA`, `OS`, `GOSCA`, `CASO` | |||
| --- | |||
| @@ -149,6 +149,7 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime | |||
| "monitor_start_hz": policy.MonitorStartHz, | |||
| "monitor_end_hz": policy.MonitorEndHz, | |||
| "monitor_span_hz": policy.MonitorSpanHz, | |||
| "monitor_windows": policy.MonitorWindows, | |||
| "signal_priorities": policy.SignalPriorities, | |||
| "auto_record_classes": policy.AutoRecordClasses, | |||
| "auto_decode_classes": policy.AutoDecodeClasses, | |||
| @@ -813,6 +813,17 @@ func spanForPolicy(policy pipeline.Policy, fallback float64) float64 { | |||
| if policy.MonitorSpanHz > 0 { | |||
| return policy.MonitorSpanHz | |||
| } | |||
| if len(policy.MonitorWindows) > 0 { | |||
| maxSpan := 0.0 | |||
| for _, w := range policy.MonitorWindows { | |||
| if w.SpanHz > maxSpan { | |||
| maxSpan = w.SpanHz | |||
| } | |||
| } | |||
| if maxSpan > 0 { | |||
| return maxSpan | |||
| } | |||
| } | |||
| if policy.MonitorStartHz != 0 && policy.MonitorEndHz != 0 && policy.MonitorEndHz > policy.MonitorStartHz { | |||
| return policy.MonitorEndHz - policy.MonitorStartHz | |||
| } | |||
| @@ -15,6 +15,14 @@ type Band struct { | |||
| EndHz float64 `yaml:"end_hz" json:"end_hz"` | |||
| } | |||
| type MonitorWindow struct { | |||
| Label string `yaml:"label" json:"label"` | |||
| StartHz float64 `yaml:"start_hz" json:"start_hz"` | |||
| EndHz float64 `yaml:"end_hz" json:"end_hz"` | |||
| CenterHz float64 `yaml:"center_hz" json:"center_hz"` | |||
| SpanHz float64 `yaml:"span_hz" json:"span_hz"` | |||
| } | |||
| type DetectorConfig struct { | |||
| ThresholdDb float64 `yaml:"threshold_db" json:"threshold_db"` | |||
| MinDurationMs int `yaml:"min_duration_ms" json:"min_duration_ms"` | |||
| @@ -72,13 +80,14 @@ type DecoderConfig struct { | |||
| } | |||
| type PipelineGoalConfig struct { | |||
| Intent string `yaml:"intent" json:"intent"` | |||
| MonitorStartHz float64 `yaml:"monitor_start_hz" json:"monitor_start_hz"` | |||
| MonitorEndHz float64 `yaml:"monitor_end_hz" json:"monitor_end_hz"` | |||
| MonitorSpanHz float64 `yaml:"monitor_span_hz" json:"monitor_span_hz"` | |||
| SignalPriorities []string `yaml:"signal_priorities" json:"signal_priorities"` | |||
| AutoRecordClasses []string `yaml:"auto_record_classes" json:"auto_record_classes"` | |||
| AutoDecodeClasses []string `yaml:"auto_decode_classes" json:"auto_decode_classes"` | |||
| Intent string `yaml:"intent" json:"intent"` | |||
| MonitorStartHz float64 `yaml:"monitor_start_hz" json:"monitor_start_hz"` | |||
| MonitorEndHz float64 `yaml:"monitor_end_hz" json:"monitor_end_hz"` | |||
| MonitorSpanHz float64 `yaml:"monitor_span_hz" json:"monitor_span_hz"` | |||
| MonitorWindows []MonitorWindow `yaml:"monitor_windows" json:"monitor_windows"` | |||
| SignalPriorities []string `yaml:"signal_priorities" json:"signal_priorities"` | |||
| AutoRecordClasses []string `yaml:"auto_record_classes" json:"auto_record_classes"` | |||
| AutoDecodeClasses []string `yaml:"auto_decode_classes" json:"auto_decode_classes"` | |||
| } | |||
| type PipelineConfig struct { | |||
| @@ -1,6 +1,105 @@ | |||
| package pipeline | |||
| import "sdr-wideband-suite/internal/config" | |||
| func NormalizeMonitorWindows(goals config.PipelineGoalConfig, centerHz float64) []MonitorWindow { | |||
| if len(goals.MonitorWindows) > 0 { | |||
| windows := make([]MonitorWindow, 0, len(goals.MonitorWindows)) | |||
| for _, raw := range goals.MonitorWindows { | |||
| if win, ok := normalizeGoalWindow(raw, centerHz); ok { | |||
| windows = append(windows, win) | |||
| } | |||
| } | |||
| if len(windows) > 0 { | |||
| return windows | |||
| } | |||
| } | |||
| if goals.MonitorStartHz > 0 && goals.MonitorEndHz > goals.MonitorStartHz { | |||
| start := goals.MonitorStartHz | |||
| end := goals.MonitorEndHz | |||
| span := end - start | |||
| return []MonitorWindow{{ | |||
| Label: "primary", | |||
| StartHz: start, | |||
| EndHz: end, | |||
| CenterHz: (start + end) / 2, | |||
| SpanHz: span, | |||
| Source: "goals:bounds", | |||
| }} | |||
| } | |||
| if goals.MonitorSpanHz > 0 && centerHz != 0 { | |||
| half := goals.MonitorSpanHz / 2 | |||
| start := centerHz - half | |||
| end := centerHz + half | |||
| return []MonitorWindow{{ | |||
| Label: "primary", | |||
| StartHz: start, | |||
| EndHz: end, | |||
| CenterHz: centerHz, | |||
| SpanHz: goals.MonitorSpanHz, | |||
| Source: "goals:span", | |||
| }} | |||
| } | |||
| return nil | |||
| } | |||
| func MonitorWindowBounds(windows []MonitorWindow) (float64, float64, bool) { | |||
| minStart := 0.0 | |||
| maxEnd := 0.0 | |||
| ok := false | |||
| for _, w := range windows { | |||
| if w.StartHz <= 0 || w.EndHz <= 0 || w.EndHz <= w.StartHz { | |||
| continue | |||
| } | |||
| if !ok || w.StartHz < minStart { | |||
| minStart = w.StartHz | |||
| } | |||
| if !ok || w.EndHz > maxEnd { | |||
| maxEnd = w.EndHz | |||
| } | |||
| ok = true | |||
| } | |||
| return minStart, maxEnd, ok | |||
| } | |||
| func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (MonitorWindow, bool) { | |||
| if raw.StartHz > 0 && raw.EndHz > raw.StartHz { | |||
| span := raw.EndHz - raw.StartHz | |||
| return MonitorWindow{ | |||
| Label: raw.Label, | |||
| StartHz: raw.StartHz, | |||
| EndHz: raw.EndHz, | |||
| CenterHz: (raw.StartHz + raw.EndHz) / 2, | |||
| SpanHz: span, | |||
| Source: "goals:window:start_end", | |||
| }, true | |||
| } | |||
| center := raw.CenterHz | |||
| if center == 0 { | |||
| center = fallbackCenter | |||
| } | |||
| if center != 0 && raw.SpanHz > 0 { | |||
| half := raw.SpanHz / 2 | |||
| source := "goals:window:center_span" | |||
| if raw.CenterHz == 0 { | |||
| source = "goals:window:span_default" | |||
| } | |||
| return MonitorWindow{ | |||
| Label: raw.Label, | |||
| StartHz: center - half, | |||
| EndHz: center + half, | |||
| CenterHz: center, | |||
| SpanHz: raw.SpanHz, | |||
| Source: source, | |||
| }, true | |||
| } | |||
| return MonitorWindow{}, false | |||
| } | |||
| func monitorBounds(policy Policy) (float64, float64, bool) { | |||
| if len(policy.MonitorWindows) > 0 { | |||
| return MonitorWindowBounds(policy.MonitorWindows) | |||
| } | |||
| start := policy.MonitorStartHz | |||
| end := policy.MonitorEndHz | |||
| if start != 0 && end != 0 && end > start { | |||
| @@ -14,15 +113,32 @@ func monitorBounds(policy Policy) (float64, float64, bool) { | |||
| } | |||
| func candidateInMonitor(policy Policy, candidate Candidate) bool { | |||
| if len(policy.MonitorWindows) > 0 { | |||
| left, right := candidateBounds(candidate) | |||
| for _, win := range policy.MonitorWindows { | |||
| if win.StartHz <= 0 || win.EndHz <= 0 || win.EndHz <= win.StartHz { | |||
| continue | |||
| } | |||
| if right >= win.StartHz && left <= win.EndHz { | |||
| return true | |||
| } | |||
| } | |||
| return false | |||
| } | |||
| start, end, ok := monitorBounds(policy) | |||
| if !ok { | |||
| return true | |||
| } | |||
| left, right := candidateBounds(candidate) | |||
| return right >= start && left <= end | |||
| } | |||
| func candidateBounds(candidate Candidate) (float64, float64) { | |||
| left := candidate.CenterHz | |||
| right := candidate.CenterHz | |||
| if candidate.BandwidthHz > 0 { | |||
| left = candidate.CenterHz - candidate.BandwidthHz/2 | |||
| right = candidate.CenterHz + candidate.BandwidthHz/2 | |||
| } | |||
| return right >= start && left <= end | |||
| return left, right | |||
| } | |||
| @@ -0,0 +1,56 @@ | |||
| package pipeline | |||
| import ( | |||
| "testing" | |||
| "sdr-wideband-suite/internal/config" | |||
| ) | |||
| func TestNormalizeMonitorWindows(t *testing.T) { | |||
| goals := config.PipelineGoalConfig{ | |||
| MonitorWindows: []config.MonitorWindow{ | |||
| {Label: "a", StartHz: 100, EndHz: 200}, | |||
| {Label: "b", CenterHz: 500, SpanHz: 50}, | |||
| }, | |||
| } | |||
| windows := NormalizeMonitorWindows(goals, 0) | |||
| if len(windows) != 2 { | |||
| t.Fatalf("expected 2 windows, got %d", len(windows)) | |||
| } | |||
| if windows[0].StartHz != 100 || windows[0].EndHz != 200 { | |||
| t.Fatalf("unexpected first window: %+v", windows[0]) | |||
| } | |||
| if windows[1].CenterHz != 500 || windows[1].SpanHz != 50 { | |||
| t.Fatalf("unexpected second window: %+v", windows[1]) | |||
| } | |||
| } | |||
| func TestMonitorWindowBounds(t *testing.T) { | |||
| windows := []MonitorWindow{ | |||
| {StartHz: 100, EndHz: 200}, | |||
| {StartHz: 50, EndHz: 90}, | |||
| {StartHz: 500, EndHz: 800}, | |||
| } | |||
| start, end, ok := MonitorWindowBounds(windows) | |||
| if !ok { | |||
| t.Fatalf("expected bounds") | |||
| } | |||
| if start != 50 || end != 800 { | |||
| t.Fatalf("unexpected bounds: %.0f %.0f", start, end) | |||
| } | |||
| } | |||
| func TestCandidateInMonitorWindows(t *testing.T) { | |||
| policy := Policy{ | |||
| MonitorWindows: []MonitorWindow{ | |||
| {StartHz: 100, EndHz: 200}, | |||
| {StartHz: 300, EndHz: 400}, | |||
| }, | |||
| } | |||
| if !candidateInMonitor(policy, Candidate{CenterHz: 150}) { | |||
| t.Fatalf("expected candidate inside window") | |||
| } | |||
| if candidateInMonitor(policy, Candidate{CenterHz: 250}) { | |||
| t.Fatalf("expected candidate outside windows") | |||
| } | |||
| } | |||
| @@ -63,6 +63,7 @@ type RefinementPlan struct { | |||
| MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` | |||
| MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` | |||
| MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` | |||
| MonitorWindows []MonitorWindow `json:"monitor_windows,omitempty"` | |||
| DroppedByMonitor int `json:"dropped_by_monitor"` | |||
| DroppedBySNR int `json:"dropped_by_snr"` | |||
| DroppedByBudget int `json:"dropped_by_budget"` | |||
| @@ -10,6 +10,7 @@ type Policy struct { | |||
| MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` | |||
| MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` | |||
| MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` | |||
| MonitorWindows []MonitorWindow `json:"monitor_windows,omitempty"` | |||
| SignalPriorities []string `json:"signal_priorities,omitempty"` | |||
| AutoRecordClasses []string `json:"auto_record_classes,omitempty"` | |||
| AutoDecodeClasses []string `json:"auto_decode_classes,omitempty"` | |||
| @@ -72,6 +73,16 @@ func PolicyFromConfig(cfg config.Config) Policy { | |||
| } | |||
| p.RefinementStrategy, _ = refinementStrategy(p) | |||
| p.SurveillanceDetection = SurveillanceDetectionPolicyFromPolicy(p) | |||
| p.MonitorWindows = NormalizeMonitorWindows(cfg.Pipeline.Goals, cfg.CenterHz) | |||
| if len(p.MonitorWindows) > 0 { | |||
| if start, end, ok := MonitorWindowBounds(p.MonitorWindows); ok { | |||
| p.MonitorStartHz = start | |||
| p.MonitorEndHz = end | |||
| if end > start { | |||
| p.MonitorSpanHz = end - start | |||
| } | |||
| } | |||
| } | |||
| if p.MonitorSpanHz <= 0 && p.MonitorStartHz != 0 && p.MonitorEndHz != 0 && p.MonitorEndHz > p.MonitorStartHz { | |||
| p.MonitorSpanHz = p.MonitorEndHz - p.MonitorStartHz | |||
| } | |||
| @@ -76,3 +76,24 @@ func TestPolicyFromConfig(t *testing.T) { | |||
| t.Fatalf("expected refinement strategy to be set") | |||
| } | |||
| } | |||
| func TestPolicyFromConfigMonitorWindows(t *testing.T) { | |||
| cfg := config.Default() | |||
| cfg.Pipeline.Goals.MonitorWindows = []config.MonitorWindow{ | |||
| {Label: "fm", StartHz: 88e6, EndHz: 108e6}, | |||
| {Label: "air", CenterHz: 120e6, SpanHz: 2e6}, | |||
| } | |||
| cfg.Pipeline.Goals.MonitorSpanHz = 0 | |||
| cfg.Pipeline.Goals.MonitorStartHz = 0 | |||
| cfg.Pipeline.Goals.MonitorEndHz = 0 | |||
| p := PolicyFromConfig(cfg) | |||
| if len(p.MonitorWindows) != 2 { | |||
| t.Fatalf("expected monitor windows to be set") | |||
| } | |||
| if p.MonitorSpanHz <= 0 { | |||
| t.Fatalf("expected monitor span to be derived from windows") | |||
| } | |||
| if p.MonitorStartHz == 0 || p.MonitorEndHz == 0 || p.MonitorEndHz <= p.MonitorStartHz { | |||
| t.Fatalf("expected monitor bounds to be derived") | |||
| } | |||
| } | |||
| @@ -109,6 +109,9 @@ func BuildRefinementPlanWithBudget(candidates []Candidate, policy Policy, budget | |||
| plan.MonitorSpanHz = end - start | |||
| } | |||
| } | |||
| if len(policy.MonitorWindows) > 0 { | |||
| plan.MonitorWindows = append([]MonitorWindow(nil), policy.MonitorWindows...) | |||
| } | |||
| if len(candidates) == 0 { | |||
| return plan | |||
| } | |||
| @@ -29,6 +29,16 @@ type LevelEvidence struct { | |||
| Provenance string `json:"provenance,omitempty"` | |||
| } | |||
| // MonitorWindow describes a monitoring window to gate candidates. | |||
| type MonitorWindow struct { | |||
| Label string `json:"label,omitempty"` | |||
| StartHz float64 `json:"start_hz,omitempty"` | |||
| EndHz float64 `json:"end_hz,omitempty"` | |||
| CenterHz float64 `json:"center_hz,omitempty"` | |||
| SpanHz float64 `json:"span_hz,omitempty"` | |||
| Source string `json:"source,omitempty"` | |||
| } | |||
| // RefinementWindow describes the local analysis span that refinement should use. | |||
| type RefinementWindow struct { | |||
| CenterHz float64 `json:"center_hz"` | |||
| @@ -2,6 +2,7 @@ package runtime | |||
| import ( | |||
| "errors" | |||
| "fmt" | |||
| "math" | |||
| "strings" | |||
| "sync" | |||
| @@ -10,15 +11,16 @@ import ( | |||
| ) | |||
| type PipelineUpdate struct { | |||
| Mode *string `json:"mode"` | |||
| Profile *string `json:"profile"` | |||
| Intent *string `json:"intent"` | |||
| MonitorStartHz *float64 `json:"monitor_start_hz"` | |||
| MonitorEndHz *float64 `json:"monitor_end_hz"` | |||
| MonitorSpanHz *float64 `json:"monitor_span_hz"` | |||
| SignalPriorities *[]string `json:"signal_priorities"` | |||
| AutoRecordClasses *[]string `json:"auto_record_classes"` | |||
| AutoDecodeClasses *[]string `json:"auto_decode_classes"` | |||
| Mode *string `json:"mode"` | |||
| Profile *string `json:"profile"` | |||
| Intent *string `json:"intent"` | |||
| MonitorStartHz *float64 `json:"monitor_start_hz"` | |||
| MonitorEndHz *float64 `json:"monitor_end_hz"` | |||
| MonitorSpanHz *float64 `json:"monitor_span_hz"` | |||
| MonitorWindows *[]config.MonitorWindow `json:"monitor_windows"` | |||
| SignalPriorities *[]string `json:"signal_priorities"` | |||
| AutoRecordClasses *[]string `json:"auto_record_classes"` | |||
| AutoDecodeClasses *[]string `json:"auto_decode_classes"` | |||
| } | |||
| type SurveillanceUpdate struct { | |||
| @@ -199,6 +201,13 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||
| } | |||
| next.Pipeline.Goals.MonitorSpanHz = *update.Pipeline.MonitorSpanHz | |||
| } | |||
| if update.Pipeline.MonitorWindows != nil { | |||
| windows := *update.Pipeline.MonitorWindows | |||
| if err := validateMonitorWindows(windows); err != nil { | |||
| return m.cfg, err | |||
| } | |||
| next.Pipeline.Goals.MonitorWindows = append([]config.MonitorWindow(nil), windows...) | |||
| } | |||
| if update.Pipeline.SignalPriorities != nil { | |||
| next.Pipeline.Goals.SignalPriorities = append([]string(nil), (*update.Pipeline.SignalPriorities)...) | |||
| } | |||
| @@ -497,6 +506,25 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||
| return m.cfg, nil | |||
| } | |||
| func validateMonitorWindows(windows []config.MonitorWindow) error { | |||
| for i, w := range windows { | |||
| hasStart := w.StartHz != 0 || w.EndHz != 0 | |||
| if hasStart { | |||
| if w.StartHz <= 0 || w.EndHz <= 0 || w.EndHz <= w.StartHz { | |||
| return fmt.Errorf("monitor_windows[%d] requires start_hz < end_hz", i) | |||
| } | |||
| continue | |||
| } | |||
| if w.CenterHz <= 0 { | |||
| return fmt.Errorf("monitor_windows[%d] requires center_hz when start/end not set", i) | |||
| } | |||
| if w.SpanHz <= 0 { | |||
| return fmt.Errorf("monitor_windows[%d] requires span_hz > 0 when start/end not set", i) | |||
| } | |||
| } | |||
| return nil | |||
| } | |||
| func (m *Manager) ApplySettings(update SettingsUpdate) (config.Config, error) { | |||
| m.mu.Lock() | |||
| defer m.mu.Unlock() | |||
| @@ -26,6 +26,10 @@ func TestApplyConfigUpdate(t *testing.T) { | |||
| profile := "wideband-balanced" | |||
| intent := "wideband-surveillance" | |||
| monitorSpan := 500000.0 | |||
| monitorWindows := []config.MonitorWindow{ | |||
| {Label: "primary", StartHz: 100, EndHz: 200}, | |||
| {Label: "secondary", CenterHz: 400, SpanHz: 50}, | |||
| } | |||
| signalPriorities := []string{"digital", "weak"} | |||
| autoRecord := []string{"WFM"} | |||
| autoDecode := []string{"FT8"} | |||
| @@ -49,6 +53,7 @@ func TestApplyConfigUpdate(t *testing.T) { | |||
| Profile: &profile, | |||
| Intent: &intent, | |||
| MonitorSpanHz: &monitorSpan, | |||
| MonitorWindows: &monitorWindows, | |||
| SignalPriorities: &signalPriorities, | |||
| AutoRecordClasses: &autoRecord, | |||
| AutoDecodeClasses: &autoDecode, | |||
| @@ -117,6 +122,9 @@ func TestApplyConfigUpdate(t *testing.T) { | |||
| if updated.Pipeline.Goals.MonitorSpanHz != monitorSpan { | |||
| t.Fatalf("monitor span: %v", updated.Pipeline.Goals.MonitorSpanHz) | |||
| } | |||
| if len(updated.Pipeline.Goals.MonitorWindows) != len(monitorWindows) { | |||
| t.Fatalf("monitor windows not applied") | |||
| } | |||
| if len(updated.Pipeline.Goals.SignalPriorities) != len(signalPriorities) { | |||
| t.Fatalf("signal priorities not applied") | |||
| } | |||