| @@ -2,39 +2,154 @@ package main | |||||
| import ( | import ( | ||||
| "sort" | "sort" | ||||
| "time" | |||||
| "sdr-wideband-suite/internal/pipeline" | "sdr-wideband-suite/internal/pipeline" | ||||
| ) | ) | ||||
| func enforceDecisionBudgets(decisions []pipeline.SignalDecision, maxRecord int, maxDecode int) (int, int) { | |||||
| recorded := 0 | |||||
| decoded := 0 | |||||
| order := make([]int, len(decisions)) | |||||
| type decisionQueueStats struct { | |||||
| RecordQueued int `json:"record_queued"` | |||||
| DecodeQueued int `json:"decode_queued"` | |||||
| RecordSelected int `json:"record_selected"` | |||||
| DecodeSelected int `json:"decode_selected"` | |||||
| RecordOldestS float64 `json:"record_oldest_sec"` | |||||
| DecodeOldestS float64 `json:"decode_oldest_sec"` | |||||
| } | |||||
| type queuedDecision struct { | |||||
| ID int64 | |||||
| SNRDb float64 | |||||
| FirstSeen time.Time | |||||
| LastSeen time.Time | |||||
| } | |||||
| type decisionQueues struct { | |||||
| record map[int64]*queuedDecision | |||||
| decode map[int64]*queuedDecision | |||||
| } | |||||
| func newDecisionQueues() *decisionQueues { | |||||
| return &decisionQueues{record: map[int64]*queuedDecision{}, decode: map[int64]*queuedDecision{}} | |||||
| } | |||||
| func (dq *decisionQueues) Apply(decisions []pipeline.SignalDecision, maxRecord int, maxDecode int, now time.Time) decisionQueueStats { | |||||
| if dq == nil { | |||||
| return decisionQueueStats{} | |||||
| } | |||||
| recSeen := map[int64]bool{} | |||||
| decSeen := map[int64]bool{} | |||||
| for i := range decisions { | for i := range decisions { | ||||
| order[i] = i | |||||
| id := decisions[i].Candidate.ID | |||||
| if id == 0 { | |||||
| continue | |||||
| } | |||||
| if decisions[i].ShouldRecord { | |||||
| qd := dq.record[id] | |||||
| if qd == nil { | |||||
| qd = &queuedDecision{ID: id, FirstSeen: now} | |||||
| dq.record[id] = qd | |||||
| } | |||||
| qd.SNRDb = decisions[i].Candidate.SNRDb | |||||
| qd.LastSeen = now | |||||
| recSeen[id] = true | |||||
| } | |||||
| if decisions[i].ShouldAutoDecode { | |||||
| qd := dq.decode[id] | |||||
| if qd == nil { | |||||
| qd = &queuedDecision{ID: id, FirstSeen: now} | |||||
| dq.decode[id] = qd | |||||
| } | |||||
| qd.SNRDb = decisions[i].Candidate.SNRDb | |||||
| qd.LastSeen = now | |||||
| decSeen[id] = true | |||||
| } | |||||
| } | } | ||||
| sort.SliceStable(order, func(i, j int) bool { | |||||
| return decisions[order[i]].Candidate.SNRDb > decisions[order[j]].Candidate.SNRDb | |||||
| }) | |||||
| for _, idx := range order { | |||||
| if decisions[idx].ShouldRecord { | |||||
| if maxRecord > 0 && recorded >= maxRecord { | |||||
| decisions[idx].ShouldRecord = false | |||||
| decisions[idx].Reason = "recording budget exceeded" | |||||
| } else { | |||||
| recorded++ | |||||
| for id := range dq.record { | |||||
| if !recSeen[id] { | |||||
| delete(dq.record, id) | |||||
| } | |||||
| } | |||||
| for id := range dq.decode { | |||||
| if !decSeen[id] { | |||||
| delete(dq.decode, id) | |||||
| } | |||||
| } | |||||
| recSelected := selectQueued(dq.record, maxRecord, now) | |||||
| decSelected := selectQueued(dq.decode, maxDecode, now) | |||||
| stats := decisionQueueStats{ | |||||
| RecordQueued: len(dq.record), | |||||
| DecodeQueued: len(dq.decode), | |||||
| RecordSelected: len(recSelected), | |||||
| DecodeSelected: len(decSelected), | |||||
| RecordOldestS: oldestAge(dq.record, now), | |||||
| DecodeOldestS: oldestAge(dq.decode, now), | |||||
| } | |||||
| for i := range decisions { | |||||
| id := decisions[i].Candidate.ID | |||||
| if decisions[i].ShouldRecord { | |||||
| if _, ok := recSelected[id]; !ok { | |||||
| decisions[i].ShouldRecord = false | |||||
| decisions[i].Reason = "queued: record budget" | |||||
| } | } | ||||
| } | } | ||||
| if decisions[idx].ShouldAutoDecode { | |||||
| if maxDecode > 0 && decoded >= maxDecode { | |||||
| decisions[idx].ShouldAutoDecode = false | |||||
| if decisions[idx].Reason == "" { | |||||
| decisions[idx].Reason = "decode budget exceeded" | |||||
| if decisions[i].ShouldAutoDecode { | |||||
| if _, ok := decSelected[id]; !ok { | |||||
| decisions[i].ShouldAutoDecode = false | |||||
| if decisions[i].Reason == "" { | |||||
| decisions[i].Reason = "queued: decode budget" | |||||
| } | } | ||||
| } else { | |||||
| decoded++ | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| return recorded, decoded | |||||
| return stats | |||||
| } | |||||
| func selectQueued(queue map[int64]*queuedDecision, max int, now time.Time) map[int64]struct{} { | |||||
| selected := map[int64]struct{}{} | |||||
| if len(queue) == 0 { | |||||
| return selected | |||||
| } | |||||
| type scored struct { | |||||
| id int64 | |||||
| score float64 | |||||
| } | |||||
| scoredList := make([]scored, 0, len(queue)) | |||||
| for id, qd := range queue { | |||||
| age := now.Sub(qd.FirstSeen).Seconds() | |||||
| boost := age / 2.0 | |||||
| if boost > 5 { | |||||
| boost = 5 | |||||
| } | |||||
| scoredList = append(scoredList, scored{id: id, score: qd.SNRDb + boost}) | |||||
| } | |||||
| sort.Slice(scoredList, func(i, j int) bool { | |||||
| return scoredList[i].score > scoredList[j].score | |||||
| }) | |||||
| limit := max | |||||
| if limit <= 0 || limit > len(scoredList) { | |||||
| limit = len(scoredList) | |||||
| } | |||||
| for i := 0; i < limit; i++ { | |||||
| selected[scoredList[i].id] = struct{}{} | |||||
| } | |||||
| return selected | |||||
| } | |||||
| func oldestAge(queue map[int64]*queuedDecision, now time.Time) float64 { | |||||
| oldest := 0.0 | |||||
| first := true | |||||
| for _, qd := range queue { | |||||
| age := now.Sub(qd.FirstSeen).Seconds() | |||||
| if first || age > oldest { | |||||
| oldest = age | |||||
| first = false | |||||
| } | |||||
| } | |||||
| if first { | |||||
| return 0 | |||||
| } | |||||
| return oldest | |||||
| } | } | ||||
| @@ -2,19 +2,21 @@ package main | |||||
| import ( | import ( | ||||
| "testing" | "testing" | ||||
| "time" | |||||
| "sdr-wideband-suite/internal/pipeline" | "sdr-wideband-suite/internal/pipeline" | ||||
| ) | ) | ||||
| func TestEnforceDecisionBudgets(t *testing.T) { | func TestEnforceDecisionBudgets(t *testing.T) { | ||||
| decisions := []pipeline.SignalDecision{ | decisions := []pipeline.SignalDecision{ | ||||
| {Candidate: pipeline.Candidate{SNRDb: 5}, ShouldRecord: true, ShouldAutoDecode: true}, | |||||
| {Candidate: pipeline.Candidate{SNRDb: 15}, ShouldRecord: true, ShouldAutoDecode: true}, | |||||
| {Candidate: pipeline.Candidate{SNRDb: 10}, ShouldRecord: true, ShouldAutoDecode: false}, | |||||
| {Candidate: pipeline.Candidate{ID: 1, SNRDb: 5}, ShouldRecord: true, ShouldAutoDecode: true}, | |||||
| {Candidate: pipeline.Candidate{ID: 2, SNRDb: 15}, ShouldRecord: true, ShouldAutoDecode: true}, | |||||
| {Candidate: pipeline.Candidate{ID: 3, SNRDb: 10}, ShouldRecord: true, ShouldAutoDecode: false}, | |||||
| } | } | ||||
| recorded, decoded := enforceDecisionBudgets(decisions, 1, 1) | |||||
| if recorded != 1 || decoded != 1 { | |||||
| t.Fatalf("unexpected counts: record=%d decode=%d", recorded, decoded) | |||||
| q := newDecisionQueues() | |||||
| stats := q.Apply(decisions, 1, 1, time.Now()) | |||||
| if stats.RecordSelected != 1 || stats.DecodeSelected != 1 { | |||||
| t.Fatalf("unexpected counts: record=%d decode=%d", stats.RecordSelected, stats.DecodeSelected) | |||||
| } | } | ||||
| if !decisions[1].ShouldRecord || !decisions[1].ShouldAutoDecode { | if !decisions[1].ShouldRecord || !decisions[1].ShouldAutoDecode { | ||||
| t.Fatalf("expected highest SNR decision to remain allowed") | t.Fatalf("expected highest SNR decision to remain allowed") | ||||
| @@ -92,6 +92,15 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||||
| state.refinement = pipeline.RefinementResult{} | state.refinement = pipeline.RefinementResult{} | ||||
| displaySignals = rt.det.StableSignals() | displaySignals = rt.det.StableSignals() | ||||
| } | } | ||||
| state.queueStats = rt.queueStats | |||||
| state.presentation = pipeline.AnalysisLevel{ | |||||
| Name: "presentation", | |||||
| SampleRate: rt.cfg.SampleRate, | |||||
| FFTSize: rt.cfg.Surveillance.DisplayBins, | |||||
| CenterHz: rt.cfg.CenterHz, | |||||
| SpanHz: float64(rt.cfg.SampleRate), | |||||
| Source: "display", | |||||
| } | |||||
| if phaseSnap != nil { | if phaseSnap != nil { | ||||
| phaseSnap.Set(*state) | phaseSnap.Set(*state) | ||||
| } | } | ||||
| @@ -159,6 +159,7 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime | |||||
| "plan": snap.refinementInput.Plan, | "plan": snap.refinementInput.Plan, | ||||
| "windows": snap.refinementInput.Windows, | "windows": snap.refinementInput.Windows, | ||||
| "window_stats": windowStats, | "window_stats": windowStats, | ||||
| "queue_stats": snap.queueStats, | |||||
| "candidates": len(snap.refinementInput.Candidates), | "candidates": len(snap.refinementInput.Candidates), | ||||
| "scheduled": len(snap.refinementInput.Scheduled), | "scheduled": len(snap.refinementInput.Scheduled), | ||||
| "signals": len(snap.refinement.Signals), | "signals": len(snap.refinement.Signals), | ||||
| @@ -167,6 +168,7 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime | |||||
| "decision_items": compactDecisions(snap.refinement.Decisions), | "decision_items": compactDecisions(snap.refinement.Decisions), | ||||
| "surveillance_level": snap.surveillance.Level, | "surveillance_level": snap.surveillance.Level, | ||||
| "refinement_level": snap.refinementInput.Level, | "refinement_level": snap.refinementInput.Level, | ||||
| "presentation_level": snap.presentation, | |||||
| } | } | ||||
| _ = json.NewEncoder(w).Encode(out) | _ = json.NewEncoder(w).Encode(out) | ||||
| }) | }) | ||||
| @@ -6,4 +6,6 @@ type phaseState struct { | |||||
| surveillance pipeline.SurveillanceResult | surveillance pipeline.SurveillanceResult | ||||
| refinementInput pipeline.RefinementInput | refinementInput pipeline.RefinementInput | ||||
| refinement pipeline.RefinementResult | refinement pipeline.RefinementResult | ||||
| queueStats decisionQueueStats | |||||
| presentation pipeline.AnalysisLevel | |||||
| } | } | ||||
| @@ -11,6 +11,8 @@ func TestPhaseStateCarriesPhaseResults(t *testing.T) { | |||||
| surveillance: pipeline.SurveillanceResult{NoiseFloor: -90, Scheduled: []pipeline.ScheduledCandidate{{Candidate: pipeline.Candidate{ID: 1}, Priority: 5}}}, | surveillance: pipeline.SurveillanceResult{NoiseFloor: -90, Scheduled: []pipeline.ScheduledCandidate{{Candidate: pipeline.Candidate{ID: 1}, Priority: 5}}}, | ||||
| refinementInput: pipeline.RefinementInput{Scheduled: []pipeline.ScheduledCandidate{{Candidate: pipeline.Candidate{ID: 1}, Priority: 5}}, SampleRate: 2048000, FFTSize: 2048, CenterHz: 7.1e6}, | refinementInput: pipeline.RefinementInput{Scheduled: []pipeline.ScheduledCandidate{{Candidate: pipeline.Candidate{ID: 1}, Priority: 5}}, SampleRate: 2048000, FFTSize: 2048, CenterHz: 7.1e6}, | ||||
| refinement: pipeline.RefinementResult{Level: pipeline.AnalysisLevel{Name: "refinement"}, Decisions: []pipeline.SignalDecision{{ShouldRecord: true}}, Candidates: []pipeline.Candidate{{ID: 1}}}, | refinement: pipeline.RefinementResult{Level: pipeline.AnalysisLevel{Name: "refinement"}, Decisions: []pipeline.SignalDecision{{ShouldRecord: true}}, Candidates: []pipeline.Candidate{{ID: 1}}}, | ||||
| queueStats: decisionQueueStats{RecordQueued: 1}, | |||||
| presentation: pipeline.AnalysisLevel{Name: "presentation"}, | |||||
| } | } | ||||
| if ps.surveillance.NoiseFloor != -90 || len(ps.surveillance.Scheduled) != 1 { | if ps.surveillance.NoiseFloor != -90 || len(ps.surveillance.Scheduled) != 1 { | ||||
| t.Fatalf("unexpected surveillance state: %+v", ps.surveillance) | t.Fatalf("unexpected surveillance state: %+v", ps.surveillance) | ||||
| @@ -39,6 +39,8 @@ type dspRuntime struct { | |||||
| rdsMap map[int64]*rdsState | rdsMap map[int64]*rdsState | ||||
| streamPhaseState map[int64]*streamExtractState | streamPhaseState map[int64]*streamExtractState | ||||
| streamOverlap *streamIQOverlap | streamOverlap *streamIQOverlap | ||||
| decisionQueues *decisionQueues | |||||
| queueStats decisionQueueStats | |||||
| gotSamples bool | gotSamples bool | ||||
| } | } | ||||
| @@ -356,7 +358,8 @@ func (rt *dspRuntime) refineSignals(art *spectrumArtifacts, input pipeline.Refin | |||||
| } | } | ||||
| maxRecord := rt.cfg.Resources.MaxRecordingStreams | maxRecord := rt.cfg.Resources.MaxRecordingStreams | ||||
| maxDecode := rt.cfg.Resources.MaxDecodeJobs | maxDecode := rt.cfg.Resources.MaxDecodeJobs | ||||
| enforceDecisionBudgets(decisions, maxRecord, maxDecode) | |||||
| queueStats := rt.decisionQueues.Apply(decisions, maxRecord, maxDecode, art.now) | |||||
| rt.queueStats = queueStats | |||||
| summary := summarizeDecisions(decisions) | summary := summarizeDecisions(decisions) | ||||
| if rec != nil { | if rec != nil { | ||||
| if summary.RecordEnabled > 0 { | if summary.RecordEnabled > 0 { | ||||