diff --git a/README.md b/README.md index 957e52a..e8c6c7e 100644 --- a/README.md +++ b/README.md @@ -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` --- diff --git a/cmd/sdrd/http_handlers.go b/cmd/sdrd/http_handlers.go index 872a99b..15e7073 100644 --- a/cmd/sdrd/http_handlers.go +++ b/cmd/sdrd/http_handlers.go @@ -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, diff --git a/cmd/sdrd/pipeline_runtime.go b/cmd/sdrd/pipeline_runtime.go index f6231af..5f5305b 100644 --- a/cmd/sdrd/pipeline_runtime.go +++ b/cmd/sdrd/pipeline_runtime.go @@ -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 } diff --git a/internal/config/config.go b/internal/config/config.go index c93f3a4..2c0c5aa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/pipeline/monitor_rules.go b/internal/pipeline/monitor_rules.go index aa3c6d3..128dde5 100644 --- a/internal/pipeline/monitor_rules.go +++ b/internal/pipeline/monitor_rules.go @@ -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 } diff --git a/internal/pipeline/monitor_rules_test.go b/internal/pipeline/monitor_rules_test.go new file mode 100644 index 0000000..4fc272e --- /dev/null +++ b/internal/pipeline/monitor_rules_test.go @@ -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") + } +} diff --git a/internal/pipeline/phases.go b/internal/pipeline/phases.go index 8c41163..5739187 100644 --- a/internal/pipeline/phases.go +++ b/internal/pipeline/phases.go @@ -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"` diff --git a/internal/pipeline/policy.go b/internal/pipeline/policy.go index 272ec5b..3ca79c8 100644 --- a/internal/pipeline/policy.go +++ b/internal/pipeline/policy.go @@ -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 } diff --git a/internal/pipeline/policy_test.go b/internal/pipeline/policy_test.go index 432460c..093f13c 100644 --- a/internal/pipeline/policy_test.go +++ b/internal/pipeline/policy_test.go @@ -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") + } +} diff --git a/internal/pipeline/scheduler.go b/internal/pipeline/scheduler.go index 0e34ea0..3441c4b 100644 --- a/internal/pipeline/scheduler.go +++ b/internal/pipeline/scheduler.go @@ -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 } diff --git a/internal/pipeline/types.go b/internal/pipeline/types.go index 70889e6..e41a51f 100644 --- a/internal/pipeline/types.go +++ b/internal/pipeline/types.go @@ -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"` diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 5d34d8d..a1955fc 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -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() diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index aa8e6f9..5419e75 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -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") }