| @@ -46,6 +46,7 @@ type CandidateEvidenceStateSummary struct { | |||||
| type CandidateWindowSummary struct { | type CandidateWindowSummary struct { | ||||
| Index int `json:"index"` | Index int `json:"index"` | ||||
| Label string `json:"label,omitempty"` | Label string `json:"label,omitempty"` | ||||
| Zone string `json:"zone,omitempty"` | |||||
| Source string `json:"source,omitempty"` | Source string `json:"source,omitempty"` | ||||
| StartHz float64 `json:"start_hz,omitempty"` | StartHz float64 `json:"start_hz,omitempty"` | ||||
| EndHz float64 `json:"end_hz,omitempty"` | EndHz float64 `json:"end_hz,omitempty"` | ||||
| @@ -53,6 +54,8 @@ type CandidateWindowSummary struct { | |||||
| SpanHz float64 `json:"span_hz,omitempty"` | SpanHz float64 `json:"span_hz,omitempty"` | ||||
| Priority float64 `json:"priority,omitempty"` | Priority float64 `json:"priority,omitempty"` | ||||
| PriorityBias float64 `json:"priority_bias,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"` | AutoRecord bool `json:"auto_record,omitempty"` | ||||
| AutoDecode bool `json:"auto_decode,omitempty"` | AutoDecode bool `json:"auto_decode,omitempty"` | ||||
| Candidates int `json:"candidates"` | Candidates int `json:"candidates"` | ||||
| @@ -230,6 +233,7 @@ func buildCandidateWindowSummary(candidates []pipeline.Candidate, windows []pipe | |||||
| entry := CandidateWindowSummary{ | entry := CandidateWindowSummary{ | ||||
| Index: win.Index, | Index: win.Index, | ||||
| Label: win.Label, | Label: win.Label, | ||||
| Zone: win.Zone, | |||||
| Source: win.Source, | Source: win.Source, | ||||
| StartHz: win.StartHz, | StartHz: win.StartHz, | ||||
| EndHz: win.EndHz, | EndHz: win.EndHz, | ||||
| @@ -237,6 +241,8 @@ func buildCandidateWindowSummary(candidates []pipeline.Candidate, windows []pipe | |||||
| SpanHz: win.SpanHz, | SpanHz: win.SpanHz, | ||||
| Priority: win.Priority, | Priority: win.Priority, | ||||
| PriorityBias: win.PriorityBias, | PriorityBias: win.PriorityBias, | ||||
| RecordBias: win.RecordBias, | |||||
| DecodeBias: win.DecodeBias, | |||||
| AutoRecord: win.AutoRecord, | AutoRecord: win.AutoRecord, | ||||
| AutoDecode: win.AutoDecode, | AutoDecode: win.AutoDecode, | ||||
| } | } | ||||
| @@ -17,6 +17,7 @@ type Band struct { | |||||
| type MonitorWindow struct { | type MonitorWindow struct { | ||||
| Label string `yaml:"label" json:"label"` | Label string `yaml:"label" json:"label"` | ||||
| Zone string `yaml:"zone" json:"zone"` | |||||
| StartHz float64 `yaml:"start_hz" json:"start_hz"` | StartHz float64 `yaml:"start_hz" json:"start_hz"` | ||||
| EndHz float64 `yaml:"end_hz" json:"end_hz"` | EndHz float64 `yaml:"end_hz" json:"end_hz"` | ||||
| CenterHz float64 `yaml:"center_hz" json:"center_hz"` | CenterHz float64 `yaml:"center_hz" json:"center_hz"` | ||||
| @@ -46,6 +46,7 @@ type queuedDecision struct { | |||||
| Hint string | Hint string | ||||
| Class string | Class string | ||||
| WindowTag string | WindowTag string | ||||
| WindowZone string | |||||
| WindowBias float64 | WindowBias float64 | ||||
| FirstSeen time.Time | FirstSeen time.Time | ||||
| LastSeen time.Time | LastSeen time.Time | ||||
| @@ -65,6 +66,7 @@ type queueSelection struct { | |||||
| familyRanks map[int64]int | familyRanks map[int64]int | ||||
| tierFloors map[int64]string | tierFloors map[int64]string | ||||
| windowTags map[int64]string | windowTags map[int64]string | ||||
| windowZones map[int64]string | |||||
| minScore float64 | minScore float64 | ||||
| maxScore float64 | maxScore float64 | ||||
| cutoff float64 | cutoff float64 | ||||
| @@ -109,8 +111,9 @@ func (dq *decisionQueues) Apply(decisions []SignalDecision, budget BudgetModel, | |||||
| qd.SNRDb = decisions[i].Candidate.SNRDb | qd.SNRDb = decisions[i].Candidate.SNRDb | ||||
| qd.Hint = decisions[i].Candidate.Hint | qd.Hint = decisions[i].Candidate.Hint | ||||
| qd.Class = decisions[i].Class | 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 | qd.LastSeen = now | ||||
| recSeen[id] = true | recSeen[id] = true | ||||
| } | } | ||||
| @@ -123,8 +126,9 @@ func (dq *decisionQueues) Apply(decisions []SignalDecision, budget BudgetModel, | |||||
| qd.SNRDb = decisions[i].Candidate.SNRDb | qd.SNRDb = decisions[i].Candidate.SNRDb | ||||
| qd.Hint = decisions[i].Candidate.Hint | qd.Hint = decisions[i].Candidate.Hint | ||||
| qd.Class = decisions[i].Class | 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 | qd.LastSeen = now | ||||
| decSeen[id] = true | decSeen[id] = true | ||||
| } | } | ||||
| @@ -238,6 +242,7 @@ func selectQueued(queueName string, queue map[int64]*queuedDecision, hold map[in | |||||
| familyRanks: map[int64]int{}, | familyRanks: map[int64]int{}, | ||||
| tierFloors: map[int64]string{}, | tierFloors: map[int64]string{}, | ||||
| windowTags: map[int64]string{}, | windowTags: map[int64]string{}, | ||||
| windowZones: map[int64]string{}, | |||||
| } | } | ||||
| if len(queue) == 0 { | if len(queue) == 0 { | ||||
| return selection | return selection | ||||
| @@ -268,6 +273,9 @@ func selectQueued(queueName string, queue map[int64]*queuedDecision, hold map[in | |||||
| if qd.WindowTag != "" { | if qd.WindowTag != "" { | ||||
| selection.windowTags[id] = qd.WindowTag | selection.windowTags[id] = qd.WindowTag | ||||
| } | } | ||||
| if qd.WindowZone != "" { | |||||
| selection.windowZones[id] = qd.WindowZone | |||||
| } | |||||
| score := qd.SNRDb + boost + policyBoost + qd.WindowBias | score := qd.SNRDb + boost + policyBoost + qd.WindowBias | ||||
| selection.scores[id] = score | selection.scores[id] = score | ||||
| if len(scoredList) == 0 || score < selection.minScore { | if len(scoredList) == 0 || score < selection.minScore { | ||||
| @@ -412,12 +420,14 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||||
| return nil | return nil | ||||
| } | } | ||||
| windowTag := selection.windowTags[id] | windowTag := selection.windowTags[id] | ||||
| windowZone := selection.windowZones[id] | |||||
| admission := &PriorityAdmission{ | admission := &PriorityAdmission{ | ||||
| Basis: queueName, | Basis: queueName, | ||||
| Score: score, | Score: score, | ||||
| Cutoff: selection.cutoff, | Cutoff: selection.cutoff, | ||||
| Tier: selection.tiers[id], | Tier: selection.tiers[id], | ||||
| } | } | ||||
| zoneTag := windowZoneTag(windowZone) | |||||
| admission.TierFloor = selection.tierFloors[id] | admission.TierFloor = selection.tierFloors[id] | ||||
| admission.Family = selection.families[id] | admission.Family = selection.families[id] | ||||
| admission.FamilyRank = familyRankForOutput(selection.familyRanks[id]) | admission.FamilyRank = familyRankForOutput(selection.familyRanks[id]) | ||||
| @@ -428,6 +438,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||||
| if windowTag != "" { | if windowTag != "" { | ||||
| extras = append(extras, windowTag) | extras = append(extras, windowTag) | ||||
| } | } | ||||
| if zoneTag != "" { | |||||
| extras = append(extras, zoneTag) | |||||
| } | |||||
| if _, ok := selection.protected[id]; ok { | if _, ok := selection.protected[id]; ok { | ||||
| extras = append(extras, ReasonTagHoldProtected) | extras = append(extras, ReasonTagHoldProtected) | ||||
| } | } | ||||
| @@ -438,6 +451,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||||
| if windowTag != "" { | if windowTag != "" { | ||||
| extras = append(extras, windowTag) | extras = append(extras, windowTag) | ||||
| } | } | ||||
| if zoneTag != "" { | |||||
| extras = append(extras, zoneTag) | |||||
| } | |||||
| if _, ok := selection.opportunistic[id]; ok { | if _, ok := selection.opportunistic[id]; ok { | ||||
| extras = append(extras, "pressure:hold", ReasonTagDisplaceOpportunist, ReasonTagDisplaceTier, ReasonTagHoldDisplaced) | extras = append(extras, "pressure:hold", ReasonTagDisplaceOpportunist, ReasonTagDisplaceTier, ReasonTagHoldDisplaced) | ||||
| } | } | ||||
| @@ -451,6 +467,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||||
| if windowTag != "" { | if windowTag != "" { | ||||
| extras = append(extras, windowTag) | extras = append(extras, windowTag) | ||||
| } | } | ||||
| if zoneTag != "" { | |||||
| extras = append(extras, zoneTag) | |||||
| } | |||||
| admission.Reason = admissionReason("queue:"+queueName+":displace", policy, holdPolicy, extras...) | admission.Reason = admissionReason("queue:"+queueName+":displace", policy, holdPolicy, extras...) | ||||
| return admission | return admission | ||||
| } | } | ||||
| @@ -460,6 +479,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||||
| if windowTag != "" { | if windowTag != "" { | ||||
| extras = append(extras, windowTag) | extras = append(extras, windowTag) | ||||
| } | } | ||||
| if zoneTag != "" { | |||||
| extras = append(extras, zoneTag) | |||||
| } | |||||
| admission.Reason = admissionReason("queue:"+queueName+":displace", policy, holdPolicy, extras...) | admission.Reason = admissionReason("queue:"+queueName+":displace", policy, holdPolicy, extras...) | ||||
| return admission | return admission | ||||
| } | } | ||||
| @@ -468,6 +490,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||||
| if windowTag != "" { | if windowTag != "" { | ||||
| extras = append(extras, windowTag) | extras = append(extras, windowTag) | ||||
| } | } | ||||
| if zoneTag != "" { | |||||
| extras = append(extras, zoneTag) | |||||
| } | |||||
| if _, ok := selection.expired[id]; ok { | if _, ok := selection.expired[id]; ok { | ||||
| extras = append(extras, ReasonTagHoldExpired) | extras = append(extras, ReasonTagHoldExpired) | ||||
| } | } | ||||
| @@ -475,13 +500,14 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p | |||||
| return admission | 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 "" | return "" | ||||
| } | } | ||||
| label := strings.TrimSpace(decision.MonitorDetail.Label) | |||||
| label := strings.TrimSpace(match.Label) | |||||
| if label == "" { | if label == "" { | ||||
| label = "index-" + strconv.Itoa(decision.MonitorDetail.Index) | |||||
| label = "index-" + strconv.Itoa(match.Index) | |||||
| } | } | ||||
| label = slugToken(label) | label = slugToken(label) | ||||
| if label == "" { | if label == "" { | ||||
| @@ -490,6 +516,36 @@ func windowTagForDecision(decision SignalDecision) string { | |||||
| return "window:" + label | 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 { | func oldestAge(queue map[int64]*queuedDecision, now time.Time) float64 { | ||||
| oldest := 0.0 | oldest := 0.0 | ||||
| first := true | first := true | ||||
| @@ -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) { | func TestDecisionQueueHoldKeepsSelection(t *testing.T) { | ||||
| arbiter := NewArbiter() | arbiter := NewArbiter() | ||||
| policy := Policy{DecisionHoldMs: 500} | policy := Policy{DecisionHoldMs: 500} | ||||
| @@ -13,6 +13,8 @@ type SignalDecision struct { | |||||
| ShouldAutoDecode bool `json:"should_auto_decode"` | ShouldAutoDecode bool `json:"should_auto_decode"` | ||||
| Reason string `json:"reason,omitempty"` | Reason string `json:"reason,omitempty"` | ||||
| MonitorBias float64 `json:"monitor_bias,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"` | MonitorDetail *MonitorWindowMatch `json:"monitor_detail,omitempty"` | ||||
| RecordWindow *MonitorWindowMatch `json:"record_window,omitempty"` | RecordWindow *MonitorWindowMatch `json:"record_window,omitempty"` | ||||
| DecodeWindow *MonitorWindowMatch `json:"decode_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) | 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) | 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 != "" { | if decision.Reason == "" && candidate.Hint != "" { | ||||
| @@ -2,11 +2,13 @@ package pipeline | |||||
| import ( | import ( | ||||
| "math" | "math" | ||||
| "strings" | |||||
| "sdr-wideband-suite/internal/config" | "sdr-wideband-suite/internal/config" | ||||
| ) | ) | ||||
| const maxMonitorWindowBias = 0.2 | const maxMonitorWindowBias = 0.2 | ||||
| const maxMonitorWindowZoneBias = 0.15 | |||||
| func NormalizeMonitorWindows(goals config.PipelineGoalConfig, centerHz float64) []MonitorWindow { | func NormalizeMonitorWindows(goals config.PipelineGoalConfig, centerHz float64) []MonitorWindow { | ||||
| if len(goals.MonitorWindows) > 0 { | if len(goals.MonitorWindows) > 0 { | ||||
| @@ -61,6 +63,7 @@ func finalizeMonitorWindows(windows []MonitorWindow) []MonitorWindow { | |||||
| } | } | ||||
| for i := range windows { | for i := range windows { | ||||
| windows[i].Index = i | windows[i].Index = i | ||||
| windows[i].Zone = normalizeMonitorZone(windows[i].Zone) | |||||
| priority := normalizeMonitorPriority(windows[i].Priority) | priority := normalizeMonitorPriority(windows[i].Priority) | ||||
| windows[i].Priority = priority | windows[i].Priority = priority | ||||
| spanBias := 0.0 | spanBias := 0.0 | ||||
| @@ -78,6 +81,9 @@ func finalizeMonitorWindows(windows []MonitorWindow) []MonitorWindow { | |||||
| totalBias = -maxMonitorWindowBias | totalBias = -maxMonitorWindowBias | ||||
| } | } | ||||
| windows[i].PriorityBias = totalBias | windows[i].PriorityBias = totalBias | ||||
| recordBias, decodeBias := monitorWindowZoneBias(windows[i].Zone) | |||||
| windows[i].RecordBias = recordBias | |||||
| windows[i].DecodeBias = decodeBias | |||||
| } | } | ||||
| return windows | return windows | ||||
| } | } | ||||
| @@ -102,10 +108,12 @@ func MonitorWindowBounds(windows []MonitorWindow) (float64, float64, bool) { | |||||
| } | } | ||||
| func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (MonitorWindow, bool) { | func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (MonitorWindow, bool) { | ||||
| zone := normalizeMonitorZone(raw.Zone) | |||||
| if raw.StartHz > 0 && raw.EndHz > raw.StartHz { | if raw.StartHz > 0 && raw.EndHz > raw.StartHz { | ||||
| span := raw.EndHz - raw.StartHz | span := raw.EndHz - raw.StartHz | ||||
| return MonitorWindow{ | return MonitorWindow{ | ||||
| Label: raw.Label, | Label: raw.Label, | ||||
| Zone: zone, | |||||
| StartHz: raw.StartHz, | StartHz: raw.StartHz, | ||||
| EndHz: raw.EndHz, | EndHz: raw.EndHz, | ||||
| CenterHz: (raw.StartHz + raw.EndHz) / 2, | CenterHz: (raw.StartHz + raw.EndHz) / 2, | ||||
| @@ -128,6 +136,7 @@ func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (Moni | |||||
| } | } | ||||
| return MonitorWindow{ | return MonitorWindow{ | ||||
| Label: raw.Label, | Label: raw.Label, | ||||
| Zone: zone, | |||||
| StartHz: center - half, | StartHz: center - half, | ||||
| EndHz: center + half, | EndHz: center + half, | ||||
| CenterHz: center, | CenterHz: center, | ||||
| @@ -154,6 +163,39 @@ func normalizeMonitorPriority(priority float64) float64 { | |||||
| return priority | 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) { | func monitorBounds(policy Policy) (float64, float64, bool) { | ||||
| if len(policy.MonitorWindows) > 0 { | if len(policy.MonitorWindows) > 0 { | ||||
| return MonitorWindowBounds(policy.MonitorWindows) | return MonitorWindowBounds(policy.MonitorWindows) | ||||
| @@ -263,9 +305,12 @@ func MonitorWindowMatchesForCandidate(windows []MonitorWindow, candidate Candida | |||||
| } | } | ||||
| distance := math.Abs(candidate.CenterHz - center) | distance := math.Abs(candidate.CenterHz - center) | ||||
| bias := win.PriorityBias * coverage | bias := win.PriorityBias * coverage | ||||
| recordBias := win.RecordBias * coverage | |||||
| decodeBias := win.DecodeBias * coverage | |||||
| matches = append(matches, MonitorWindowMatch{ | matches = append(matches, MonitorWindowMatch{ | ||||
| Index: win.Index, | Index: win.Index, | ||||
| Label: win.Label, | Label: win.Label, | ||||
| Zone: win.Zone, | |||||
| Source: win.Source, | Source: win.Source, | ||||
| StartHz: win.StartHz, | StartHz: win.StartHz, | ||||
| EndHz: win.EndHz, | EndHz: win.EndHz, | ||||
| @@ -275,6 +320,8 @@ func MonitorWindowMatchesForCandidate(windows []MonitorWindow, candidate Candida | |||||
| Coverage: coverage, | Coverage: coverage, | ||||
| DistanceHz: distance, | DistanceHz: distance, | ||||
| Bias: bias, | Bias: bias, | ||||
| RecordBias: recordBias, | |||||
| DecodeBias: decodeBias, | |||||
| AutoRecord: win.AutoRecord, | AutoRecord: win.AutoRecord, | ||||
| AutoDecode: win.AutoDecode, | AutoDecode: win.AutoDecode, | ||||
| }) | }) | ||||
| @@ -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) | 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]) | |||||
| } | |||||
| } | |||||
| @@ -415,6 +415,7 @@ func buildMonitorWindowStats(windows []MonitorWindow) []MonitorWindowStats { | |||||
| stats = append(stats, MonitorWindowStats{ | stats = append(stats, MonitorWindowStats{ | ||||
| Index: win.Index, | Index: win.Index, | ||||
| Label: win.Label, | Label: win.Label, | ||||
| Zone: win.Zone, | |||||
| Source: win.Source, | Source: win.Source, | ||||
| StartHz: win.StartHz, | StartHz: win.StartHz, | ||||
| EndHz: win.EndHz, | EndHz: win.EndHz, | ||||
| @@ -422,6 +423,8 @@ func buildMonitorWindowStats(windows []MonitorWindow) []MonitorWindowStats { | |||||
| SpanHz: win.SpanHz, | SpanHz: win.SpanHz, | ||||
| Priority: win.Priority, | Priority: win.Priority, | ||||
| PriorityBias: win.PriorityBias, | PriorityBias: win.PriorityBias, | ||||
| RecordBias: win.RecordBias, | |||||
| DecodeBias: win.DecodeBias, | |||||
| AutoRecord: win.AutoRecord, | AutoRecord: win.AutoRecord, | ||||
| AutoDecode: win.AutoDecode, | AutoDecode: win.AutoDecode, | ||||
| }) | }) | ||||
| @@ -34,6 +34,7 @@ type LevelEvidence struct { | |||||
| type MonitorWindow struct { | type MonitorWindow struct { | ||||
| Index int `json:"index,omitempty"` | Index int `json:"index,omitempty"` | ||||
| Label string `json:"label,omitempty"` | Label string `json:"label,omitempty"` | ||||
| Zone string `json:"zone,omitempty"` | |||||
| StartHz float64 `json:"start_hz,omitempty"` | StartHz float64 `json:"start_hz,omitempty"` | ||||
| EndHz float64 `json:"end_hz,omitempty"` | EndHz float64 `json:"end_hz,omitempty"` | ||||
| CenterHz float64 `json:"center_hz,omitempty"` | CenterHz float64 `json:"center_hz,omitempty"` | ||||
| @@ -41,6 +42,8 @@ type MonitorWindow struct { | |||||
| Source string `json:"source,omitempty"` | Source string `json:"source,omitempty"` | ||||
| Priority float64 `json:"priority,omitempty"` | Priority float64 `json:"priority,omitempty"` | ||||
| PriorityBias float64 `json:"priority_bias,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"` | AutoRecord bool `json:"auto_record,omitempty"` | ||||
| AutoDecode bool `json:"auto_decode,omitempty"` | AutoDecode bool `json:"auto_decode,omitempty"` | ||||
| } | } | ||||
| @@ -49,6 +52,7 @@ type MonitorWindow struct { | |||||
| type MonitorWindowMatch struct { | type MonitorWindowMatch struct { | ||||
| Index int `json:"index"` | Index int `json:"index"` | ||||
| Label string `json:"label,omitempty"` | Label string `json:"label,omitempty"` | ||||
| Zone string `json:"zone,omitempty"` | |||||
| Source string `json:"source,omitempty"` | Source string `json:"source,omitempty"` | ||||
| StartHz float64 `json:"start_hz,omitempty"` | StartHz float64 `json:"start_hz,omitempty"` | ||||
| EndHz float64 `json:"end_hz,omitempty"` | EndHz float64 `json:"end_hz,omitempty"` | ||||
| @@ -58,6 +62,8 @@ type MonitorWindowMatch struct { | |||||
| Coverage float64 `json:"coverage,omitempty"` | Coverage float64 `json:"coverage,omitempty"` | ||||
| DistanceHz float64 `json:"distance_hz,omitempty"` | DistanceHz float64 `json:"distance_hz,omitempty"` | ||||
| Bias float64 `json:"bias,omitempty"` | Bias float64 `json:"bias,omitempty"` | ||||
| RecordBias float64 `json:"record_bias,omitempty"` | |||||
| DecodeBias float64 `json:"decode_bias,omitempty"` | |||||
| AutoRecord bool `json:"auto_record,omitempty"` | AutoRecord bool `json:"auto_record,omitempty"` | ||||
| AutoDecode bool `json:"auto_decode,omitempty"` | AutoDecode bool `json:"auto_decode,omitempty"` | ||||
| } | } | ||||
| @@ -66,6 +72,7 @@ type MonitorWindowMatch struct { | |||||
| type MonitorWindowStats struct { | type MonitorWindowStats struct { | ||||
| Index int `json:"index"` | Index int `json:"index"` | ||||
| Label string `json:"label,omitempty"` | Label string `json:"label,omitempty"` | ||||
| Zone string `json:"zone,omitempty"` | |||||
| Source string `json:"source,omitempty"` | Source string `json:"source,omitempty"` | ||||
| StartHz float64 `json:"start_hz,omitempty"` | StartHz float64 `json:"start_hz,omitempty"` | ||||
| EndHz float64 `json:"end_hz,omitempty"` | EndHz float64 `json:"end_hz,omitempty"` | ||||
| @@ -73,6 +80,8 @@ type MonitorWindowStats struct { | |||||
| SpanHz float64 `json:"span_hz,omitempty"` | SpanHz float64 `json:"span_hz,omitempty"` | ||||
| Priority float64 `json:"priority,omitempty"` | Priority float64 `json:"priority,omitempty"` | ||||
| PriorityBias float64 `json:"priority_bias,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"` | AutoRecord bool `json:"auto_record,omitempty"` | ||||
| AutoDecode bool `json:"auto_decode,omitempty"` | AutoDecode bool `json:"auto_decode,omitempty"` | ||||
| Candidates int `json:"candidates,omitempty"` | Candidates int `json:"candidates,omitempty"` | ||||
| @@ -508,6 +508,9 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||||
| func validateMonitorWindows(windows []config.MonitorWindow) error { | func validateMonitorWindows(windows []config.MonitorWindow) error { | ||||
| for i, w := range windows { | 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 { | 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) | 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 | 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) { | func (m *Manager) ApplySettings(update SettingsUpdate) (config.Config, error) { | ||||
| m.mu.Lock() | m.mu.Lock() | ||||
| defer m.mu.Unlock() | defer m.mu.Unlock() | ||||