| @@ -62,6 +62,7 @@ Edit `config.yaml` (autosave goes to `config.autosave.yaml`). | |||||
| - `pipeline.goals.*` -- declarative target/intent layer for future autonomous operation | - `pipeline.goals.*` -- declarative target/intent layer for future autonomous operation | ||||
| - `intent` | - `intent` | ||||
| - `monitor_start_hz` / `monitor_end_hz` / `monitor_span_hz` | - `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` | - `signal_priorities` | ||||
| - `auto_record_classes` | - `auto_record_classes` | ||||
| - `auto_decode_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. | 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` | **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_start_hz": policy.MonitorStartHz, | ||||
| "monitor_end_hz": policy.MonitorEndHz, | "monitor_end_hz": policy.MonitorEndHz, | ||||
| "monitor_span_hz": policy.MonitorSpanHz, | "monitor_span_hz": policy.MonitorSpanHz, | ||||
| "monitor_windows": policy.MonitorWindows, | |||||
| "signal_priorities": policy.SignalPriorities, | "signal_priorities": policy.SignalPriorities, | ||||
| "auto_record_classes": policy.AutoRecordClasses, | "auto_record_classes": policy.AutoRecordClasses, | ||||
| "auto_decode_classes": policy.AutoDecodeClasses, | "auto_decode_classes": policy.AutoDecodeClasses, | ||||
| @@ -813,6 +813,17 @@ func spanForPolicy(policy pipeline.Policy, fallback float64) float64 { | |||||
| if policy.MonitorSpanHz > 0 { | if policy.MonitorSpanHz > 0 { | ||||
| return policy.MonitorSpanHz | 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 { | if policy.MonitorStartHz != 0 && policy.MonitorEndHz != 0 && policy.MonitorEndHz > policy.MonitorStartHz { | ||||
| return policy.MonitorEndHz - policy.MonitorStartHz | return policy.MonitorEndHz - policy.MonitorStartHz | ||||
| } | } | ||||
| @@ -15,6 +15,14 @@ type Band struct { | |||||
| EndHz float64 `yaml:"end_hz" json:"end_hz"` | 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 { | type DetectorConfig struct { | ||||
| ThresholdDb float64 `yaml:"threshold_db" json:"threshold_db"` | ThresholdDb float64 `yaml:"threshold_db" json:"threshold_db"` | ||||
| MinDurationMs int `yaml:"min_duration_ms" json:"min_duration_ms"` | MinDurationMs int `yaml:"min_duration_ms" json:"min_duration_ms"` | ||||
| @@ -72,13 +80,14 @@ type DecoderConfig struct { | |||||
| } | } | ||||
| type PipelineGoalConfig 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 { | type PipelineConfig struct { | ||||
| @@ -1,6 +1,105 @@ | |||||
| package pipeline | 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) { | func monitorBounds(policy Policy) (float64, float64, bool) { | ||||
| if len(policy.MonitorWindows) > 0 { | |||||
| return MonitorWindowBounds(policy.MonitorWindows) | |||||
| } | |||||
| start := policy.MonitorStartHz | start := policy.MonitorStartHz | ||||
| end := policy.MonitorEndHz | end := policy.MonitorEndHz | ||||
| if start != 0 && end != 0 && end > start { | 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 { | 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) | start, end, ok := monitorBounds(policy) | ||||
| if !ok { | if !ok { | ||||
| return true | return true | ||||
| } | } | ||||
| left, right := candidateBounds(candidate) | |||||
| return right >= start && left <= end | |||||
| } | |||||
| func candidateBounds(candidate Candidate) (float64, float64) { | |||||
| left := candidate.CenterHz | left := candidate.CenterHz | ||||
| right := candidate.CenterHz | right := candidate.CenterHz | ||||
| if candidate.BandwidthHz > 0 { | if candidate.BandwidthHz > 0 { | ||||
| left = candidate.CenterHz - candidate.BandwidthHz/2 | left = candidate.CenterHz - candidate.BandwidthHz/2 | ||||
| right = 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"` | MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` | ||||
| MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` | MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` | ||||
| MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` | MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` | ||||
| MonitorWindows []MonitorWindow `json:"monitor_windows,omitempty"` | |||||
| DroppedByMonitor int `json:"dropped_by_monitor"` | DroppedByMonitor int `json:"dropped_by_monitor"` | ||||
| DroppedBySNR int `json:"dropped_by_snr"` | DroppedBySNR int `json:"dropped_by_snr"` | ||||
| DroppedByBudget int `json:"dropped_by_budget"` | DroppedByBudget int `json:"dropped_by_budget"` | ||||
| @@ -10,6 +10,7 @@ type Policy struct { | |||||
| MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` | MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` | ||||
| MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` | MonitorEndHz float64 `json:"monitor_end_hz,omitempty"` | ||||
| MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` | MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"` | ||||
| MonitorWindows []MonitorWindow `json:"monitor_windows,omitempty"` | |||||
| SignalPriorities []string `json:"signal_priorities,omitempty"` | SignalPriorities []string `json:"signal_priorities,omitempty"` | ||||
| AutoRecordClasses []string `json:"auto_record_classes,omitempty"` | AutoRecordClasses []string `json:"auto_record_classes,omitempty"` | ||||
| AutoDecodeClasses []string `json:"auto_decode_classes,omitempty"` | AutoDecodeClasses []string `json:"auto_decode_classes,omitempty"` | ||||
| @@ -72,6 +73,16 @@ func PolicyFromConfig(cfg config.Config) Policy { | |||||
| } | } | ||||
| p.RefinementStrategy, _ = refinementStrategy(p) | p.RefinementStrategy, _ = refinementStrategy(p) | ||||
| p.SurveillanceDetection = SurveillanceDetectionPolicyFromPolicy(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 { | if p.MonitorSpanHz <= 0 && p.MonitorStartHz != 0 && p.MonitorEndHz != 0 && p.MonitorEndHz > p.MonitorStartHz { | ||||
| p.MonitorSpanHz = 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") | 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 | plan.MonitorSpanHz = end - start | ||||
| } | } | ||||
| } | } | ||||
| if len(policy.MonitorWindows) > 0 { | |||||
| plan.MonitorWindows = append([]MonitorWindow(nil), policy.MonitorWindows...) | |||||
| } | |||||
| if len(candidates) == 0 { | if len(candidates) == 0 { | ||||
| return plan | return plan | ||||
| } | } | ||||
| @@ -29,6 +29,16 @@ type LevelEvidence struct { | |||||
| Provenance string `json:"provenance,omitempty"` | 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. | // RefinementWindow describes the local analysis span that refinement should use. | ||||
| type RefinementWindow struct { | type RefinementWindow struct { | ||||
| CenterHz float64 `json:"center_hz"` | CenterHz float64 `json:"center_hz"` | ||||
| @@ -2,6 +2,7 @@ package runtime | |||||
| import ( | import ( | ||||
| "errors" | "errors" | ||||
| "fmt" | |||||
| "math" | "math" | ||||
| "strings" | "strings" | ||||
| "sync" | "sync" | ||||
| @@ -10,15 +11,16 @@ import ( | |||||
| ) | ) | ||||
| type PipelineUpdate struct { | 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 { | type SurveillanceUpdate struct { | ||||
| @@ -199,6 +201,13 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||||
| } | } | ||||
| next.Pipeline.Goals.MonitorSpanHz = *update.Pipeline.MonitorSpanHz | 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 { | if update.Pipeline.SignalPriorities != nil { | ||||
| next.Pipeline.Goals.SignalPriorities = append([]string(nil), (*update.Pipeline.SignalPriorities)...) | 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 | 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) { | func (m *Manager) ApplySettings(update SettingsUpdate) (config.Config, error) { | ||||
| m.mu.Lock() | m.mu.Lock() | ||||
| defer m.mu.Unlock() | defer m.mu.Unlock() | ||||
| @@ -26,6 +26,10 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| profile := "wideband-balanced" | profile := "wideband-balanced" | ||||
| intent := "wideband-surveillance" | intent := "wideband-surveillance" | ||||
| monitorSpan := 500000.0 | monitorSpan := 500000.0 | ||||
| monitorWindows := []config.MonitorWindow{ | |||||
| {Label: "primary", StartHz: 100, EndHz: 200}, | |||||
| {Label: "secondary", CenterHz: 400, SpanHz: 50}, | |||||
| } | |||||
| signalPriorities := []string{"digital", "weak"} | signalPriorities := []string{"digital", "weak"} | ||||
| autoRecord := []string{"WFM"} | autoRecord := []string{"WFM"} | ||||
| autoDecode := []string{"FT8"} | autoDecode := []string{"FT8"} | ||||
| @@ -49,6 +53,7 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| Profile: &profile, | Profile: &profile, | ||||
| Intent: &intent, | Intent: &intent, | ||||
| MonitorSpanHz: &monitorSpan, | MonitorSpanHz: &monitorSpan, | ||||
| MonitorWindows: &monitorWindows, | |||||
| SignalPriorities: &signalPriorities, | SignalPriorities: &signalPriorities, | ||||
| AutoRecordClasses: &autoRecord, | AutoRecordClasses: &autoRecord, | ||||
| AutoDecodeClasses: &autoDecode, | AutoDecodeClasses: &autoDecode, | ||||
| @@ -117,6 +122,9 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| if updated.Pipeline.Goals.MonitorSpanHz != monitorSpan { | if updated.Pipeline.Goals.MonitorSpanHz != monitorSpan { | ||||
| t.Fatalf("monitor span: %v", updated.Pipeline.Goals.MonitorSpanHz) | 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) { | if len(updated.Pipeline.Goals.SignalPriorities) != len(signalPriorities) { | ||||
| t.Fatalf("signal priorities not applied") | t.Fatalf("signal priorities not applied") | ||||
| } | } | ||||