diff --git a/cmd/sdrd/level_summary.go b/cmd/sdrd/level_summary.go index 80f4ed1..0a2bd7b 100644 --- a/cmd/sdrd/level_summary.go +++ b/cmd/sdrd/level_summary.go @@ -46,6 +46,7 @@ type CandidateEvidenceStateSummary struct { type CandidateWindowSummary struct { Index int `json:"index"` Label string `json:"label,omitempty"` + Zone string `json:"zone,omitempty"` Source string `json:"source,omitempty"` StartHz float64 `json:"start_hz,omitempty"` EndHz float64 `json:"end_hz,omitempty"` @@ -53,6 +54,8 @@ type CandidateWindowSummary struct { SpanHz float64 `json:"span_hz,omitempty"` Priority float64 `json:"priority,omitempty"` PriorityBias float64 `json:"priority_bias,omitempty"` + RecordBias float64 `json:"record_bias,omitempty"` + DecodeBias float64 `json:"decode_bias,omitempty"` AutoRecord bool `json:"auto_record,omitempty"` AutoDecode bool `json:"auto_decode,omitempty"` Candidates int `json:"candidates"` @@ -230,6 +233,7 @@ func buildCandidateWindowSummary(candidates []pipeline.Candidate, windows []pipe entry := CandidateWindowSummary{ Index: win.Index, Label: win.Label, + Zone: win.Zone, Source: win.Source, StartHz: win.StartHz, EndHz: win.EndHz, @@ -237,6 +241,8 @@ func buildCandidateWindowSummary(candidates []pipeline.Candidate, windows []pipe SpanHz: win.SpanHz, Priority: win.Priority, PriorityBias: win.PriorityBias, + RecordBias: win.RecordBias, + DecodeBias: win.DecodeBias, AutoRecord: win.AutoRecord, AutoDecode: win.AutoDecode, } diff --git a/internal/config/config.go b/internal/config/config.go index cd591b2..60991eb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Band struct { type MonitorWindow struct { Label string `yaml:"label" json:"label"` + Zone string `yaml:"zone" json:"zone"` StartHz float64 `yaml:"start_hz" json:"start_hz"` EndHz float64 `yaml:"end_hz" json:"end_hz"` CenterHz float64 `yaml:"center_hz" json:"center_hz"` diff --git a/internal/pipeline/decision_queue.go b/internal/pipeline/decision_queue.go index 7292ae7..b8fa50e 100644 --- a/internal/pipeline/decision_queue.go +++ b/internal/pipeline/decision_queue.go @@ -46,6 +46,7 @@ type queuedDecision struct { Hint string Class string WindowTag string + WindowZone string WindowBias float64 FirstSeen time.Time LastSeen time.Time @@ -65,6 +66,7 @@ type queueSelection struct { familyRanks map[int64]int tierFloors map[int64]string windowTags map[int64]string + windowZones map[int64]string minScore float64 maxScore float64 cutoff float64 @@ -109,8 +111,9 @@ func (dq *decisionQueues) Apply(decisions []SignalDecision, budget BudgetModel, qd.SNRDb = decisions[i].Candidate.SNRDb qd.Hint = decisions[i].Candidate.Hint qd.Class = decisions[i].Class - qd.WindowTag = windowTagForDecision(decisions[i]) - qd.WindowBias = decisions[i].MonitorBias + qd.WindowTag = windowTagForDecision(decisions[i], "record") + qd.WindowZone = windowZoneForDecision(decisions[i], "record") + qd.WindowBias = decisions[i].MonitorBias + decisions[i].RecordBias qd.LastSeen = now recSeen[id] = true } @@ -123,8 +126,9 @@ func (dq *decisionQueues) Apply(decisions []SignalDecision, budget BudgetModel, qd.SNRDb = decisions[i].Candidate.SNRDb qd.Hint = decisions[i].Candidate.Hint qd.Class = decisions[i].Class - qd.WindowTag = windowTagForDecision(decisions[i]) - qd.WindowBias = decisions[i].MonitorBias + qd.WindowTag = windowTagForDecision(decisions[i], "decode") + qd.WindowZone = windowZoneForDecision(decisions[i], "decode") + qd.WindowBias = decisions[i].MonitorBias + decisions[i].DecodeBias qd.LastSeen = now decSeen[id] = true } @@ -238,6 +242,7 @@ func selectQueued(queueName string, queue map[int64]*queuedDecision, hold map[in familyRanks: map[int64]int{}, tierFloors: map[int64]string{}, windowTags: map[int64]string{}, + windowZones: map[int64]string{}, } if len(queue) == 0 { return selection @@ -268,6 +273,9 @@ func selectQueued(queueName string, queue map[int64]*queuedDecision, hold map[in if qd.WindowTag != "" { selection.windowTags[id] = qd.WindowTag } + if qd.WindowZone != "" { + selection.windowZones[id] = qd.WindowZone + } score := qd.SNRDb + boost + policyBoost + qd.WindowBias selection.scores[id] = score if len(scoredList) == 0 || score < selection.minScore { @@ -412,12 +420,14 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p return nil } windowTag := selection.windowTags[id] + windowZone := selection.windowZones[id] admission := &PriorityAdmission{ Basis: queueName, Score: score, Cutoff: selection.cutoff, Tier: selection.tiers[id], } + zoneTag := windowZoneTag(windowZone) admission.TierFloor = selection.tierFloors[id] admission.Family = selection.families[id] admission.FamilyRank = familyRankForOutput(selection.familyRanks[id]) @@ -428,6 +438,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p if windowTag != "" { extras = append(extras, windowTag) } + if zoneTag != "" { + extras = append(extras, zoneTag) + } if _, ok := selection.protected[id]; ok { extras = append(extras, ReasonTagHoldProtected) } @@ -438,6 +451,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p if windowTag != "" { extras = append(extras, windowTag) } + if zoneTag != "" { + extras = append(extras, zoneTag) + } if _, ok := selection.opportunistic[id]; ok { extras = append(extras, "pressure:hold", ReasonTagDisplaceOpportunist, ReasonTagDisplaceTier, ReasonTagHoldDisplaced) } @@ -451,6 +467,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p if windowTag != "" { extras = append(extras, windowTag) } + if zoneTag != "" { + extras = append(extras, zoneTag) + } admission.Reason = admissionReason("queue:"+queueName+":displace", policy, holdPolicy, extras...) return admission } @@ -460,6 +479,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p if windowTag != "" { extras = append(extras, windowTag) } + if zoneTag != "" { + extras = append(extras, zoneTag) + } admission.Reason = admissionReason("queue:"+queueName+":displace", policy, holdPolicy, extras...) return admission } @@ -468,6 +490,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p if windowTag != "" { extras = append(extras, windowTag) } + if zoneTag != "" { + extras = append(extras, zoneTag) + } if _, ok := selection.expired[id]; ok { extras = append(extras, ReasonTagHoldExpired) } @@ -475,13 +500,14 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p return admission } -func windowTagForDecision(decision SignalDecision) string { - if decision.MonitorDetail == nil { +func windowTagForDecision(decision SignalDecision, action string) string { + match := windowMatchForDecision(decision, action) + if match == nil { return "" } - label := strings.TrimSpace(decision.MonitorDetail.Label) + label := strings.TrimSpace(match.Label) if label == "" { - label = "index-" + strconv.Itoa(decision.MonitorDetail.Index) + label = "index-" + strconv.Itoa(match.Index) } label = slugToken(label) if label == "" { @@ -490,6 +516,36 @@ func windowTagForDecision(decision SignalDecision) string { return "window:" + label } +func windowZoneForDecision(decision SignalDecision, action string) string { + match := windowMatchForDecision(decision, action) + if match == nil { + return "" + } + return match.Zone +} + +func windowMatchForDecision(decision SignalDecision, action string) *MonitorWindowMatch { + switch strings.ToLower(strings.TrimSpace(action)) { + case "record": + if decision.RecordWindow != nil { + return decision.RecordWindow + } + case "decode": + if decision.DecodeWindow != nil { + return decision.DecodeWindow + } + } + return decision.MonitorDetail +} + +func windowZoneTag(zone string) string { + zone = slugToken(zone) + if zone == "" { + return "" + } + return "window-zone:" + zone +} + func oldestAge(queue map[int64]*queuedDecision, now time.Time) float64 { oldest := 0.0 first := true diff --git a/internal/pipeline/decision_queue_test.go b/internal/pipeline/decision_queue_test.go index cd34505..328e8d2 100644 --- a/internal/pipeline/decision_queue_test.go +++ b/internal/pipeline/decision_queue_test.go @@ -101,6 +101,46 @@ func TestDecisionQueueMonitorWindowBiasSelectsPreferred(t *testing.T) { } } +func TestDecisionQueueWindowZoneBiasSelectsPerAction(t *testing.T) { + arbiter := NewArbiter() + policy := Policy{ + DecisionHoldMs: 250, + AutoRecordClasses: []string{"test"}, + AutoDecodeClasses: []string{"test"}, + MonitorWindows: finalizeMonitorWindows([]MonitorWindow{ + {Label: "record-zone", StartHz: 100, EndHz: 200, SpanHz: 100, Zone: "record", AutoRecord: true, AutoDecode: true}, + {Label: "decode-zone", StartHz: 300, EndHz: 400, SpanHz: 100, Zone: "decode", AutoRecord: true, AutoDecode: true}, + }), + } + budget := BudgetModel{Record: BudgetQueue{Max: 1}, Decode: BudgetQueue{Max: 1}} + now := time.Now() + + decisions := []SignalDecision{ + DecideSignalAction(policy, Candidate{ID: 1, CenterHz: 150, SNRDb: 10, Hint: "test"}, nil), + DecideSignalAction(policy, Candidate{ID: 2, CenterHz: 350, SNRDb: 10, Hint: "test"}, nil), + } + arbiter.ApplyDecisions(decisions, budget, now, policy) + + if !decisions[0].ShouldRecord { + t.Fatalf("expected record-zone candidate to be selected for record") + } + if decisions[1].ShouldRecord { + t.Fatalf("expected decode-zone candidate to be deferred for record") + } + if !decisions[1].ShouldAutoDecode { + t.Fatalf("expected decode-zone candidate to be selected for decode") + } + if decisions[0].ShouldAutoDecode { + t.Fatalf("expected record-zone candidate to be deferred for decode") + } + if decisions[0].RecordAdmission == nil || !strings.Contains(decisions[0].RecordAdmission.Reason, "window-zone:record") { + t.Fatalf("expected record admission to include window-zone tag, got %+v", decisions[0].RecordAdmission) + } + if decisions[1].DecodeAdmission == nil || !strings.Contains(decisions[1].DecodeAdmission.Reason, "window-zone:decode") { + t.Fatalf("expected decode admission to include window-zone tag, got %+v", decisions[1].DecodeAdmission) + } +} + func TestDecisionQueueHoldKeepsSelection(t *testing.T) { arbiter := NewArbiter() policy := Policy{DecisionHoldMs: 500} diff --git a/internal/pipeline/decisions.go b/internal/pipeline/decisions.go index 954eff0..6551e1e 100644 --- a/internal/pipeline/decisions.go +++ b/internal/pipeline/decisions.go @@ -13,6 +13,8 @@ type SignalDecision struct { ShouldAutoDecode bool `json:"should_auto_decode"` Reason string `json:"reason,omitempty"` MonitorBias float64 `json:"monitor_bias,omitempty"` + RecordBias float64 `json:"record_bias,omitempty"` + DecodeBias float64 `json:"decode_bias,omitempty"` MonitorDetail *MonitorWindowMatch `json:"monitor_detail,omitempty"` RecordWindow *MonitorWindowMatch `json:"record_window,omitempty"` DecodeWindow *MonitorWindowMatch `json:"decode_window,omitempty"` @@ -50,19 +52,29 @@ func DecideSignalAction(policy Policy, candidate Candidate, cls *classifier.Clas } } recordMatch := bestMonitorActionMatch(candidate.MonitorMatches, true, false) - if !decision.ShouldRecord && recordMatch != nil { - decision.ShouldRecord = true - decision.RecordWindow = recordMatch - if decision.Reason == "" { - decision.Reason = DecisionReasonRecordWindow + if recordMatch != nil { + decision.RecordBias = recordMatch.RecordBias + if decision.RecordWindow == nil { + decision.RecordWindow = recordMatch + } + if !decision.ShouldRecord { + decision.ShouldRecord = true + if decision.Reason == "" { + decision.Reason = DecisionReasonRecordWindow + } } } decodeMatch := bestMonitorActionMatch(candidate.MonitorMatches, false, true) - if !decision.ShouldAutoDecode && decodeMatch != nil { - decision.ShouldAutoDecode = true - decision.DecodeWindow = decodeMatch - if decision.Reason == "" { - decision.Reason = DecisionReasonDecodeWindow + if decodeMatch != nil { + decision.DecodeBias = decodeMatch.DecodeBias + if decision.DecodeWindow == nil { + decision.DecodeWindow = decodeMatch + } + if !decision.ShouldAutoDecode { + decision.ShouldAutoDecode = true + if decision.Reason == "" { + decision.Reason = DecisionReasonDecodeWindow + } } } if decision.Reason == "" && candidate.Hint != "" { diff --git a/internal/pipeline/monitor_rules.go b/internal/pipeline/monitor_rules.go index 006d2f4..ff6750f 100644 --- a/internal/pipeline/monitor_rules.go +++ b/internal/pipeline/monitor_rules.go @@ -2,11 +2,13 @@ package pipeline import ( "math" + "strings" "sdr-wideband-suite/internal/config" ) const maxMonitorWindowBias = 0.2 +const maxMonitorWindowZoneBias = 0.15 func NormalizeMonitorWindows(goals config.PipelineGoalConfig, centerHz float64) []MonitorWindow { if len(goals.MonitorWindows) > 0 { @@ -61,6 +63,7 @@ func finalizeMonitorWindows(windows []MonitorWindow) []MonitorWindow { } for i := range windows { windows[i].Index = i + windows[i].Zone = normalizeMonitorZone(windows[i].Zone) priority := normalizeMonitorPriority(windows[i].Priority) windows[i].Priority = priority spanBias := 0.0 @@ -78,6 +81,9 @@ func finalizeMonitorWindows(windows []MonitorWindow) []MonitorWindow { totalBias = -maxMonitorWindowBias } windows[i].PriorityBias = totalBias + recordBias, decodeBias := monitorWindowZoneBias(windows[i].Zone) + windows[i].RecordBias = recordBias + windows[i].DecodeBias = decodeBias } return windows } @@ -102,10 +108,12 @@ func MonitorWindowBounds(windows []MonitorWindow) (float64, float64, bool) { } func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (MonitorWindow, bool) { + zone := normalizeMonitorZone(raw.Zone) if raw.StartHz > 0 && raw.EndHz > raw.StartHz { span := raw.EndHz - raw.StartHz return MonitorWindow{ Label: raw.Label, + Zone: zone, StartHz: raw.StartHz, EndHz: raw.EndHz, CenterHz: (raw.StartHz + raw.EndHz) / 2, @@ -128,6 +136,7 @@ func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (Moni } return MonitorWindow{ Label: raw.Label, + Zone: zone, StartHz: center - half, EndHz: center + half, CenterHz: center, @@ -154,6 +163,39 @@ func normalizeMonitorPriority(priority float64) float64 { return priority } +func normalizeMonitorZone(raw string) string { + zone := strings.ToLower(strings.TrimSpace(raw)) + switch zone { + case "", "neutral", "monitor", "default": + return "" + case "focus", "priority", "hot": + return "focus" + case "record", "recording", "record-only": + return "record" + case "decode", "decoding", "decode-only": + return "decode" + case "background", "bg", "defer": + return "background" + default: + return "" + } +} + +func monitorWindowZoneBias(zone string) (float64, float64) { + switch normalizeMonitorZone(zone) { + case "focus": + return maxMonitorWindowZoneBias, maxMonitorWindowZoneBias + case "record": + return maxMonitorWindowZoneBias, 0 + case "decode": + return 0, maxMonitorWindowZoneBias + case "background": + return -maxMonitorWindowZoneBias, -maxMonitorWindowZoneBias + default: + return 0, 0 + } +} + func monitorBounds(policy Policy) (float64, float64, bool) { if len(policy.MonitorWindows) > 0 { return MonitorWindowBounds(policy.MonitorWindows) @@ -263,9 +305,12 @@ func MonitorWindowMatchesForCandidate(windows []MonitorWindow, candidate Candida } distance := math.Abs(candidate.CenterHz - center) bias := win.PriorityBias * coverage + recordBias := win.RecordBias * coverage + decodeBias := win.DecodeBias * coverage matches = append(matches, MonitorWindowMatch{ Index: win.Index, Label: win.Label, + Zone: win.Zone, Source: win.Source, StartHz: win.StartHz, EndHz: win.EndHz, @@ -275,6 +320,8 @@ func MonitorWindowMatchesForCandidate(windows []MonitorWindow, candidate Candida Coverage: coverage, DistanceHz: distance, Bias: bias, + RecordBias: recordBias, + DecodeBias: decodeBias, AutoRecord: win.AutoRecord, AutoDecode: win.AutoDecode, }) diff --git a/internal/pipeline/monitor_rules_test.go b/internal/pipeline/monitor_rules_test.go index 3673953..9c32ff2 100644 --- a/internal/pipeline/monitor_rules_test.go +++ b/internal/pipeline/monitor_rules_test.go @@ -119,3 +119,42 @@ func TestMonitorWindowPriorityBiasUsesPriority(t *testing.T) { t.Fatalf("expected high priority bias > low priority bias, got %.3f vs %.3f", high.PriorityBias, low.PriorityBias) } } + +func TestMonitorWindowZoneBiases(t *testing.T) { + goals := config.PipelineGoalConfig{ + MonitorWindows: []config.MonitorWindow{ + {Label: "record", StartHz: 100, EndHz: 200, Zone: "record"}, + {Label: "decode", StartHz: 300, EndHz: 400, Zone: "decode"}, + }, + } + policy := Policy{MonitorWindows: NormalizeMonitorWindows(goals, 0)} + if len(policy.MonitorWindows) != 2 { + t.Fatalf("expected 2 windows, got %d", len(policy.MonitorWindows)) + } + var recordWin, decodeWin *MonitorWindow + for i := range policy.MonitorWindows { + win := &policy.MonitorWindows[i] + switch win.Label { + case "record": + recordWin = win + case "decode": + decodeWin = win + } + } + if recordWin == nil || decodeWin == nil { + t.Fatalf("expected both window entries") + } + if recordWin.RecordBias <= 0 || recordWin.DecodeBias != 0 { + t.Fatalf("unexpected record window biases: %+v", recordWin) + } + if decodeWin.DecodeBias <= 0 || decodeWin.RecordBias != 0 { + t.Fatalf("unexpected decode window biases: %+v", decodeWin) + } + matches := MonitorWindowMatches(policy, Candidate{CenterHz: 150, BandwidthHz: 0}) + if len(matches) != 1 { + t.Fatalf("expected 1 match, got %d", len(matches)) + } + if matches[0].RecordBias <= 0 || matches[0].DecodeBias != 0 { + t.Fatalf("unexpected match biases: %+v", matches[0]) + } +} diff --git a/internal/pipeline/scheduler.go b/internal/pipeline/scheduler.go index d7638bb..0f7cff6 100644 --- a/internal/pipeline/scheduler.go +++ b/internal/pipeline/scheduler.go @@ -415,6 +415,7 @@ func buildMonitorWindowStats(windows []MonitorWindow) []MonitorWindowStats { stats = append(stats, MonitorWindowStats{ Index: win.Index, Label: win.Label, + Zone: win.Zone, Source: win.Source, StartHz: win.StartHz, EndHz: win.EndHz, @@ -422,6 +423,8 @@ func buildMonitorWindowStats(windows []MonitorWindow) []MonitorWindowStats { SpanHz: win.SpanHz, Priority: win.Priority, PriorityBias: win.PriorityBias, + RecordBias: win.RecordBias, + DecodeBias: win.DecodeBias, AutoRecord: win.AutoRecord, AutoDecode: win.AutoDecode, }) diff --git a/internal/pipeline/types.go b/internal/pipeline/types.go index ff54ea7..5eeea8f 100644 --- a/internal/pipeline/types.go +++ b/internal/pipeline/types.go @@ -34,6 +34,7 @@ type LevelEvidence struct { type MonitorWindow struct { Index int `json:"index,omitempty"` Label string `json:"label,omitempty"` + Zone string `json:"zone,omitempty"` StartHz float64 `json:"start_hz,omitempty"` EndHz float64 `json:"end_hz,omitempty"` CenterHz float64 `json:"center_hz,omitempty"` @@ -41,6 +42,8 @@ type MonitorWindow struct { Source string `json:"source,omitempty"` Priority float64 `json:"priority,omitempty"` PriorityBias float64 `json:"priority_bias,omitempty"` + RecordBias float64 `json:"record_bias,omitempty"` + DecodeBias float64 `json:"decode_bias,omitempty"` AutoRecord bool `json:"auto_record,omitempty"` AutoDecode bool `json:"auto_decode,omitempty"` } @@ -49,6 +52,7 @@ type MonitorWindow struct { type MonitorWindowMatch struct { Index int `json:"index"` Label string `json:"label,omitempty"` + Zone string `json:"zone,omitempty"` Source string `json:"source,omitempty"` StartHz float64 `json:"start_hz,omitempty"` EndHz float64 `json:"end_hz,omitempty"` @@ -58,6 +62,8 @@ type MonitorWindowMatch struct { Coverage float64 `json:"coverage,omitempty"` DistanceHz float64 `json:"distance_hz,omitempty"` Bias float64 `json:"bias,omitempty"` + RecordBias float64 `json:"record_bias,omitempty"` + DecodeBias float64 `json:"decode_bias,omitempty"` AutoRecord bool `json:"auto_record,omitempty"` AutoDecode bool `json:"auto_decode,omitempty"` } @@ -66,6 +72,7 @@ type MonitorWindowMatch struct { type MonitorWindowStats struct { Index int `json:"index"` Label string `json:"label,omitempty"` + Zone string `json:"zone,omitempty"` Source string `json:"source,omitempty"` StartHz float64 `json:"start_hz,omitempty"` EndHz float64 `json:"end_hz,omitempty"` @@ -73,6 +80,8 @@ type MonitorWindowStats struct { SpanHz float64 `json:"span_hz,omitempty"` Priority float64 `json:"priority,omitempty"` PriorityBias float64 `json:"priority_bias,omitempty"` + RecordBias float64 `json:"record_bias,omitempty"` + DecodeBias float64 `json:"decode_bias,omitempty"` AutoRecord bool `json:"auto_record,omitempty"` AutoDecode bool `json:"auto_decode,omitempty"` Candidates int `json:"candidates,omitempty"` diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 0ece7ee..61f6369 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -508,6 +508,9 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { func validateMonitorWindows(windows []config.MonitorWindow) error { for i, w := range windows { + if !isValidMonitorZone(w.Zone) { + return fmt.Errorf("monitor_windows[%d] zone is invalid", i) + } if math.IsNaN(w.Priority) || math.IsInf(w.Priority, 0) || w.Priority < -1 || w.Priority > 1 { return fmt.Errorf("monitor_windows[%d] priority must be between -1 and 1", i) } @@ -528,6 +531,22 @@ func validateMonitorWindows(windows []config.MonitorWindow) error { return nil } +func isValidMonitorZone(zone string) bool { + zone = strings.ToLower(strings.TrimSpace(zone)) + if zone == "" { + return true + } + switch zone { + case "neutral", "monitor", "default", "focus", "priority", "hot", + "record", "recording", "record-only", + "decode", "decoding", "decode-only", + "background", "bg", "defer": + return true + default: + return false + } +} + func (m *Manager) ApplySettings(update SettingsUpdate) (config.Config, error) { m.mu.Lock() defer m.mu.Unlock()