| @@ -56,6 +56,7 @@ Edit `config.yaml` (autosave goes to `config.autosave.yaml`). | |||||
| ### New phase-1 pipeline fields | ### New phase-1 pipeline fields | ||||
| - `pipeline.mode` — operating mode label (`legacy`, `wideband-balanced`, ...) | - `pipeline.mode` — operating mode label (`legacy`, `wideband-balanced`, ...) | ||||
| - `pipeline.profile` — last applied operating profile name (if any) | |||||
| - `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` | ||||
| @@ -64,7 +65,7 @@ Edit `config.yaml` (autosave goes to `config.autosave.yaml`). | |||||
| - `auto_decode_classes` | - `auto_decode_classes` | ||||
| - `surveillance.analysis_fft_size` — analysis FFT size used by the surveillance layer | - `surveillance.analysis_fft_size` — analysis FFT size used by the surveillance layer | ||||
| - `surveillance.frame_rate` — surveillance cadence target | - `surveillance.frame_rate` — surveillance cadence target | ||||
| - `surveillance.strategy` — currently `single-resolution`, reserved for future multi-resolution modes | |||||
| - `surveillance.strategy` — `single-resolution` or `multi-resolution` | |||||
| - `surveillance.display_bins` — preferred presentation density for clients/UI | - `surveillance.display_bins` — preferred presentation density for clients/UI | ||||
| - `surveillance.display_fps` — preferred presentation cadence for clients/UI | - `surveillance.display_fps` — preferred presentation cadence for clients/UI | ||||
| - `refinement.enabled` — enables explicit candidate refinement stage | - `refinement.enabled` — enables explicit candidate refinement stage | ||||
| @@ -74,9 +75,11 @@ Edit `config.yaml` (autosave goes to `config.autosave.yaml`). | |||||
| - `refinement.auto_span` — use mod-type heuristics when candidate bandwidth is missing/odd | - `refinement.auto_span` — use mod-type heuristics when candidate bandwidth is missing/odd | ||||
| - `resources.prefer_gpu` — GPU preference hint | - `resources.prefer_gpu` — GPU preference hint | ||||
| **Profile defaults (wideband)** | |||||
| - `wideband-balanced`: min_span_hz=4000, max_span_hz=200000 | |||||
| - `wideband-aggressive`: min_span_hz=6000, max_span_hz=250000 | |||||
| **Operating profiles (wideband)** | |||||
| - `wideband-balanced`: multi-resolution, 4096 FFT, refinement span 4000-200000 Hz | |||||
| - `wideband-aggressive`: multi-resolution, 8192 FFT, refinement span 6000-250000 Hz | |||||
| - `archive`: record-forward bias, higher record/decode budgets | |||||
| - `digital-hunting`: digital-first priorities and decode bias | |||||
| - `resources.max_refinement_jobs` — processing budget hint | - `resources.max_refinement_jobs` — processing budget hint | ||||
| - `resources.max_recording_streams` — recording/streaming budget hint | - `resources.max_recording_streams` — recording/streaming budget hint | ||||
| - `resources.max_decode_jobs` — decode budget hint | - `resources.max_decode_jobs` — decode budget hint | ||||
| @@ -155,7 +158,7 @@ go build -tags sdrplay ./cmd/sdrd | |||||
| - `GET /api/gpu` | - `GET /api/gpu` | ||||
| - `GET /api/pipeline/policy` | - `GET /api/pipeline/policy` | ||||
| - `GET /api/pipeline/recommendations` | - `GET /api/pipeline/recommendations` | ||||
| - `GET /api/refinement` → latest refinement plan/windows snapshot (includes `window_stats`, `queue_stats`, `decision_summary`, `decision_items`, levels) | |||||
| - `GET /api/refinement` → latest refinement plan/windows snapshot (includes `window_stats`, `queue_stats`, `decision_summary`, `decision_items`, levels, request/context/budgets/work_items) | |||||
| ### Signals / Events | ### Signals / Events | ||||
| - `GET /api/signals` → current live signals | - `GET /api/signals` → current live signals | ||||
| @@ -16,6 +16,11 @@ type decisionQueueStats struct { | |||||
| DecodeActive int `json:"decode_active"` | DecodeActive int `json:"decode_active"` | ||||
| RecordOldestS float64 `json:"record_oldest_sec"` | RecordOldestS float64 `json:"record_oldest_sec"` | ||||
| DecodeOldestS float64 `json:"decode_oldest_sec"` | DecodeOldestS float64 `json:"decode_oldest_sec"` | ||||
| RecordBudget int `json:"record_budget"` | |||||
| DecodeBudget int `json:"decode_budget"` | |||||
| HoldMs int `json:"hold_ms"` | |||||
| RecordDropped int `json:"record_dropped"` | |||||
| DecodeDropped int `json:"decode_dropped"` | |||||
| } | } | ||||
| type queuedDecision struct { | type queuedDecision struct { | ||||
| @@ -43,10 +48,11 @@ func newDecisionQueues() *decisionQueues { | |||||
| } | } | ||||
| } | } | ||||
| func (dq *decisionQueues) Apply(decisions []pipeline.SignalDecision, maxRecord int, maxDecode int, hold time.Duration, now time.Time, policy pipeline.Policy) decisionQueueStats { | |||||
| func (dq *decisionQueues) Apply(decisions []pipeline.SignalDecision, budget pipeline.BudgetModel, now time.Time, policy pipeline.Policy) decisionQueueStats { | |||||
| if dq == nil { | if dq == nil { | ||||
| return decisionQueueStats{} | return decisionQueueStats{} | ||||
| } | } | ||||
| hold := time.Duration(budget.HoldMs) * time.Millisecond | |||||
| recSeen := map[int64]bool{} | recSeen := map[int64]bool{} | ||||
| decSeen := map[int64]bool{} | decSeen := map[int64]bool{} | ||||
| for i := range decisions { | for i := range decisions { | ||||
| @@ -93,8 +99,8 @@ func (dq *decisionQueues) Apply(decisions []pipeline.SignalDecision, maxRecord i | |||||
| purgeExpired(dq.recordHold, now) | purgeExpired(dq.recordHold, now) | ||||
| purgeExpired(dq.decodeHold, now) | purgeExpired(dq.decodeHold, now) | ||||
| recSelected := selectQueued("record", dq.record, dq.recordHold, maxRecord, hold, now, policy) | |||||
| decSelected := selectQueued("decode", dq.decode, dq.decodeHold, maxDecode, hold, now, policy) | |||||
| recSelected := selectQueued("record", dq.record, dq.recordHold, budget.Record.Max, hold, now, policy) | |||||
| decSelected := selectQueued("decode", dq.decode, dq.decodeHold, budget.Decode.Max, hold, now, policy) | |||||
| stats := decisionQueueStats{ | stats := decisionQueueStats{ | ||||
| RecordQueued: len(dq.record), | RecordQueued: len(dq.record), | ||||
| @@ -105,6 +111,9 @@ func (dq *decisionQueues) Apply(decisions []pipeline.SignalDecision, maxRecord i | |||||
| DecodeActive: len(dq.decodeHold), | DecodeActive: len(dq.decodeHold), | ||||
| RecordOldestS: oldestAge(dq.record, now), | RecordOldestS: oldestAge(dq.record, now), | ||||
| DecodeOldestS: oldestAge(dq.decode, now), | DecodeOldestS: oldestAge(dq.decode, now), | ||||
| RecordBudget: budget.Record.Max, | |||||
| DecodeBudget: budget.Decode.Max, | |||||
| HoldMs: budget.HoldMs, | |||||
| } | } | ||||
| for i := range decisions { | for i := range decisions { | ||||
| @@ -113,6 +122,7 @@ func (dq *decisionQueues) Apply(decisions []pipeline.SignalDecision, maxRecord i | |||||
| if _, ok := recSelected[id]; !ok { | if _, ok := recSelected[id]; !ok { | ||||
| decisions[i].ShouldRecord = false | decisions[i].ShouldRecord = false | ||||
| decisions[i].Reason = "queued: record budget" | decisions[i].Reason = "queued: record budget" | ||||
| stats.RecordDropped++ | |||||
| } | } | ||||
| } | } | ||||
| if decisions[i].ShouldAutoDecode { | if decisions[i].ShouldAutoDecode { | ||||
| @@ -121,6 +131,7 @@ func (dq *decisionQueues) Apply(decisions []pipeline.SignalDecision, maxRecord i | |||||
| if decisions[i].Reason == "" { | if decisions[i].Reason == "" { | ||||
| decisions[i].Reason = "queued: decode budget" | decisions[i].Reason = "queued: decode budget" | ||||
| } | } | ||||
| stats.DecodeDropped++ | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -14,7 +14,9 @@ func TestEnforceDecisionBudgets(t *testing.T) { | |||||
| {Candidate: pipeline.Candidate{ID: 3, SNRDb: 10}, ShouldRecord: true, ShouldAutoDecode: false}, | {Candidate: pipeline.Candidate{ID: 3, SNRDb: 10}, ShouldRecord: true, ShouldAutoDecode: false}, | ||||
| } | } | ||||
| q := newDecisionQueues() | q := newDecisionQueues() | ||||
| stats := q.Apply(decisions, 1, 1, 0, time.Now(), pipeline.Policy{SignalPriorities: []string{"digital"}}) | |||||
| policy := pipeline.Policy{SignalPriorities: []string{"digital"}, MaxRecordingStreams: 1, MaxDecodeJobs: 1} | |||||
| budget := pipeline.BudgetModelFromPolicy(policy) | |||||
| stats := q.Apply(decisions, budget, time.Now(), policy) | |||||
| if stats.RecordSelected != 1 || stats.DecodeSelected != 1 { | if stats.RecordSelected != 1 || stats.DecodeSelected != 1 { | ||||
| t.Fatalf("unexpected counts: record=%d decode=%d", stats.RecordSelected, stats.DecodeSelected) | t.Fatalf("unexpected counts: record=%d decode=%d", stats.RecordSelected, stats.DecodeSelected) | ||||
| } | } | ||||
| @@ -93,6 +93,8 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||||
| state.queueStats = rt.queueStats | state.queueStats = rt.queueStats | ||||
| state.presentation = pipeline.AnalysisLevel{ | state.presentation = pipeline.AnalysisLevel{ | ||||
| Name: "presentation", | Name: "presentation", | ||||
| Role: "presentation", | |||||
| Truth: "presentation", | |||||
| SampleRate: rt.cfg.SampleRate, | SampleRate: rt.cfg.SampleRate, | ||||
| FFTSize: rt.cfg.Surveillance.DisplayBins, | FFTSize: rt.cfg.Surveillance.DisplayBins, | ||||
| CenterHz: rt.cfg.CenterHz, | CenterHz: rt.cfg.CenterHz, | ||||
| @@ -147,6 +149,24 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det * | |||||
| if hasWindows { | if hasWindows { | ||||
| debugInfo.Windows = windowStats | debugInfo.Windows = windowStats | ||||
| } | } | ||||
| refinementDebug := &RefinementDebug{} | |||||
| if hasPlan { | |||||
| refinementDebug.Plan = &plan | |||||
| refinementDebug.Request = &state.refinement.Input.Request | |||||
| refinementDebug.WorkItems = state.refinement.Input.WorkItems | |||||
| } | |||||
| if hasWindows { | |||||
| refinementDebug.Windows = windowStats | |||||
| } | |||||
| refinementDebug.Queue = state.queueStats | |||||
| policy := pipeline.PolicyFromConfig(rt.cfg) | |||||
| budget := pipeline.BudgetModelFromPolicy(policy) | |||||
| refinementDebug.Budgets = &budget | |||||
| debugInfo.Refinement = refinementDebug | |||||
| debugInfo.Decisions = &DecisionDebug{ | |||||
| Summary: summarizeDecisions(state.refinement.Result.Decisions), | |||||
| Items: compactDecisions(state.refinement.Result.Decisions), | |||||
| } | |||||
| } | } | ||||
| h.broadcast(SpectrumFrame{Timestamp: art.now.UnixMilli(), CenterHz: rt.cfg.CenterHz, SampleHz: rt.cfg.SampleRate, FFTSize: rt.cfg.FFTSize, Spectrum: art.surveillanceSpectrum, Signals: displaySignals, Debug: debugInfo}) | h.broadcast(SpectrumFrame{Timestamp: art.now.UnixMilli(), CenterHz: rt.cfg.CenterHz, SampleHz: rt.cfg.SampleRate, FFTSize: rt.cfg.FFTSize, Spectrum: art.surveillanceSpectrum, Signals: displaySignals, Debug: debugInfo}) | ||||
| } | } | ||||
| @@ -137,9 +137,13 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime | |||||
| w.Header().Set("Content-Type", "application/json") | w.Header().Set("Content-Type", "application/json") | ||||
| cfg := cfgManager.Snapshot() | cfg := cfgManager.Snapshot() | ||||
| policy := pipeline.PolicyFromConfig(cfg) | policy := pipeline.PolicyFromConfig(cfg) | ||||
| budget := pipeline.BudgetModelFromPolicy(policy) | |||||
| recommend := map[string]any{ | recommend := map[string]any{ | ||||
| "profile": policy.Profile, | |||||
| "mode": policy.Mode, | "mode": policy.Mode, | ||||
| "intent": policy.Intent, | "intent": policy.Intent, | ||||
| "surveillance_strategy": policy.SurveillanceStrategy, | |||||
| "refinement_strategy": policy.RefinementStrategy, | |||||
| "monitor_center_hz": policy.MonitorCenterHz, | "monitor_center_hz": policy.MonitorCenterHz, | ||||
| "monitor_start_hz": policy.MonitorStartHz, | "monitor_start_hz": policy.MonitorStartHz, | ||||
| "monitor_end_hz": policy.MonitorEndHz, | "monitor_end_hz": policy.MonitorEndHz, | ||||
| @@ -151,6 +155,7 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime | |||||
| "refinement_auto_span": policy.RefinementAutoSpan, | "refinement_auto_span": policy.RefinementAutoSpan, | ||||
| "refinement_min_span_hz": policy.RefinementMinSpanHz, | "refinement_min_span_hz": policy.RefinementMinSpanHz, | ||||
| "refinement_max_span_hz": policy.RefinementMaxSpanHz, | "refinement_max_span_hz": policy.RefinementMaxSpanHz, | ||||
| "budgets": budget, | |||||
| } | } | ||||
| _ = json.NewEncoder(w).Encode(recommend) | _ = json.NewEncoder(w).Encode(recommend) | ||||
| }) | }) | ||||
| @@ -163,6 +168,11 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime | |||||
| "windows": snap.refinement.Input.Windows, | "windows": snap.refinement.Input.Windows, | ||||
| "window_stats": windowStats, | "window_stats": windowStats, | ||||
| "queue_stats": snap.queueStats, | "queue_stats": snap.queueStats, | ||||
| "request": snap.refinement.Input.Request, | |||||
| "context": snap.refinement.Input.Context, | |||||
| "detail_level": snap.refinement.Input.Detail, | |||||
| "budgets": snap.refinement.Input.Budgets, | |||||
| "work_items": snap.refinement.Input.WorkItems, | |||||
| "candidates": len(snap.refinement.Input.Candidates), | "candidates": len(snap.refinement.Input.Candidates), | ||||
| "scheduled": len(snap.refinement.Input.Scheduled), | "scheduled": len(snap.refinement.Input.Scheduled), | ||||
| "signals": len(snap.refinement.Result.Signals), | "signals": len(snap.refinement.Result.Signals), | ||||
| @@ -241,6 +241,8 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S | |||||
| scheduled := pipeline.ScheduleCandidates(candidates, policy) | scheduled := pipeline.ScheduleCandidates(candidates, policy) | ||||
| level := pipeline.AnalysisLevel{ | level := pipeline.AnalysisLevel{ | ||||
| Name: "surveillance", | Name: "surveillance", | ||||
| Role: "surveillance", | |||||
| Truth: "surveillance", | |||||
| SampleRate: rt.cfg.SampleRate, | SampleRate: rt.cfg.SampleRate, | ||||
| FFTSize: rt.cfg.Surveillance.AnalysisFFTSize, | FFTSize: rt.cfg.Surveillance.AnalysisFFTSize, | ||||
| CenterHz: rt.cfg.CenterHz, | CenterHz: rt.cfg.CenterHz, | ||||
| @@ -257,6 +259,8 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S | |||||
| } | } | ||||
| lowLevel := pipeline.AnalysisLevel{ | lowLevel := pipeline.AnalysisLevel{ | ||||
| Name: "surveillance-lowres", | Name: "surveillance-lowres", | ||||
| Role: "surveillance-lowres", | |||||
| Truth: "surveillance", | |||||
| SampleRate: lowRate, | SampleRate: lowRate, | ||||
| FFTSize: lowFFT, | FFTSize: lowFFT, | ||||
| CenterHz: rt.cfg.CenterHz, | CenterHz: rt.cfg.CenterHz, | ||||
| @@ -265,17 +269,20 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S | |||||
| } | } | ||||
| displayLevel := pipeline.AnalysisLevel{ | displayLevel := pipeline.AnalysisLevel{ | ||||
| Name: "presentation", | Name: "presentation", | ||||
| Role: "presentation", | |||||
| Truth: "presentation", | |||||
| SampleRate: rt.cfg.SampleRate, | SampleRate: rt.cfg.SampleRate, | ||||
| FFTSize: rt.cfg.Surveillance.DisplayBins, | FFTSize: rt.cfg.Surveillance.DisplayBins, | ||||
| CenterHz: rt.cfg.CenterHz, | CenterHz: rt.cfg.CenterHz, | ||||
| SpanHz: spanForPolicy(policy, float64(rt.cfg.SampleRate)), | SpanHz: spanForPolicy(policy, float64(rt.cfg.SampleRate)), | ||||
| Source: "display", | Source: "display", | ||||
| } | } | ||||
| levels := surveillanceLevels(policy, level, lowLevel) | |||||
| levels, context := surveillanceLevels(policy, level, lowLevel, displayLevel) | |||||
| return pipeline.SurveillanceResult{ | return pipeline.SurveillanceResult{ | ||||
| Level: level, | Level: level, | ||||
| Levels: levels, | Levels: levels, | ||||
| DisplayLevel: displayLevel, | DisplayLevel: displayLevel, | ||||
| Context: context, | |||||
| Candidates: candidates, | Candidates: candidates, | ||||
| Scheduled: scheduled, | Scheduled: scheduled, | ||||
| Finished: art.finished, | Finished: art.finished, | ||||
| @@ -292,9 +299,24 @@ func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult) pip | |||||
| if len(scheduled) == 0 && len(plan.Selected) > 0 { | if len(scheduled) == 0 && len(plan.Selected) > 0 { | ||||
| scheduled = append([]pipeline.ScheduledCandidate(nil), plan.Selected...) | scheduled = append([]pipeline.ScheduledCandidate(nil), plan.Selected...) | ||||
| } | } | ||||
| workItems := make([]pipeline.RefinementWorkItem, 0, len(plan.WorkItems)) | |||||
| if len(plan.WorkItems) > 0 { | |||||
| workItems = append(workItems, plan.WorkItems...) | |||||
| } | |||||
| workIndex := map[int64]int{} | |||||
| for i := range workItems { | |||||
| if workItems[i].Candidate.ID == 0 { | |||||
| continue | |||||
| } | |||||
| workIndex[workItems[i].Candidate.ID] = i | |||||
| } | |||||
| windows := make([]pipeline.RefinementWindow, 0, len(scheduled)) | windows := make([]pipeline.RefinementWindow, 0, len(scheduled)) | ||||
| for _, sc := range scheduled { | for _, sc := range scheduled { | ||||
| windows = append(windows, pipeline.RefinementWindowForCandidate(policy, sc.Candidate)) | |||||
| window := pipeline.RefinementWindowForCandidate(policy, sc.Candidate) | |||||
| windows = append(windows, window) | |||||
| if idx, ok := workIndex[sc.Candidate.ID]; ok { | |||||
| workItems[idx].Window = window | |||||
| } | |||||
| } | } | ||||
| levelSpan := spanForPolicy(policy, float64(rt.cfg.SampleRate)) | levelSpan := spanForPolicy(policy, float64(rt.cfg.SampleRate)) | ||||
| if _, maxSpan, ok := windowSpanBounds(windows); ok { | if _, maxSpan, ok := windowSpanBounds(windows); ok { | ||||
| @@ -302,16 +324,33 @@ func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult) pip | |||||
| } | } | ||||
| level := pipeline.AnalysisLevel{ | level := pipeline.AnalysisLevel{ | ||||
| Name: "refinement", | Name: "refinement", | ||||
| Role: "refinement", | |||||
| Truth: "refinement", | |||||
| SampleRate: rt.cfg.SampleRate, | SampleRate: rt.cfg.SampleRate, | ||||
| FFTSize: rt.cfg.FFTSize, | FFTSize: rt.cfg.FFTSize, | ||||
| CenterHz: rt.cfg.CenterHz, | CenterHz: rt.cfg.CenterHz, | ||||
| SpanHz: levelSpan, | SpanHz: levelSpan, | ||||
| Source: "refinement-window", | Source: "refinement-window", | ||||
| } | } | ||||
| detailLevel := pipeline.AnalysisLevel{ | |||||
| Name: "detail", | |||||
| Role: "detail", | |||||
| Truth: "refinement", | |||||
| SampleRate: rt.cfg.SampleRate, | |||||
| FFTSize: rt.cfg.FFTSize, | |||||
| CenterHz: rt.cfg.CenterHz, | |||||
| SpanHz: levelSpan, | |||||
| Source: "detail-spectrum", | |||||
| } | |||||
| input := pipeline.RefinementInput{ | input := pipeline.RefinementInput{ | ||||
| Level: level, | Level: level, | ||||
| Detail: detailLevel, | |||||
| Context: surv.Context, | |||||
| Request: pipeline.RefinementRequest{Strategy: plan.Strategy, Reason: "surveillance-plan", SpanHintHz: levelSpan}, | |||||
| Budgets: pipeline.BudgetModelFromPolicy(policy), | |||||
| Candidates: append([]pipeline.Candidate(nil), surv.Candidates...), | Candidates: append([]pipeline.Candidate(nil), surv.Candidates...), | ||||
| Scheduled: scheduled, | Scheduled: scheduled, | ||||
| WorkItems: workItems, | |||||
| Plan: plan, | Plan: plan, | ||||
| Windows: windows, | Windows: windows, | ||||
| SampleRate: rt.cfg.SampleRate, | SampleRate: rt.cfg.SampleRate, | ||||
| @@ -319,8 +358,12 @@ func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult) pip | |||||
| CenterHz: rt.cfg.CenterHz, | CenterHz: rt.cfg.CenterHz, | ||||
| Source: "surveillance-detector", | Source: "surveillance-detector", | ||||
| } | } | ||||
| input.Context.Refinement = level | |||||
| input.Context.Detail = detailLevel | |||||
| if !policy.RefinementEnabled { | if !policy.RefinementEnabled { | ||||
| input.Scheduled = nil | input.Scheduled = nil | ||||
| input.WorkItems = nil | |||||
| input.Request.Reason = pipeline.RefinementReasonDisabled | |||||
| } | } | ||||
| return input | return input | ||||
| } | } | ||||
| @@ -389,10 +432,8 @@ func (rt *dspRuntime) refineSignals(art *spectrumArtifacts, input pipeline.Refin | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| maxRecord := rt.cfg.Resources.MaxRecordingStreams | |||||
| maxDecode := rt.cfg.Resources.MaxDecodeJobs | |||||
| hold := time.Duration(rt.cfg.Resources.DecisionHoldMs) * time.Millisecond | |||||
| queueStats := rt.decisionQueues.Apply(decisions, maxRecord, maxDecode, hold, art.now, policy) | |||||
| budget := pipeline.BudgetModelFromPolicy(policy) | |||||
| queueStats := rt.decisionQueues.Apply(decisions, budget, art.now, policy) | |||||
| rt.queueStats = queueStats | rt.queueStats = queueStats | ||||
| summary := summarizeDecisions(decisions) | summary := summarizeDecisions(decisions) | ||||
| if rec != nil { | if rec != nil { | ||||
| @@ -531,16 +572,21 @@ func windowSpanBounds(windows []pipeline.RefinementWindow) (float64, float64, bo | |||||
| return minSpan, maxSpan, ok | return minSpan, maxSpan, ok | ||||
| } | } | ||||
| func surveillanceLevels(policy pipeline.Policy, primary pipeline.AnalysisLevel, secondary pipeline.AnalysisLevel) []pipeline.AnalysisLevel { | |||||
| func surveillanceLevels(policy pipeline.Policy, primary pipeline.AnalysisLevel, secondary pipeline.AnalysisLevel, presentation pipeline.AnalysisLevel) ([]pipeline.AnalysisLevel, pipeline.AnalysisContext) { | |||||
| levels := []pipeline.AnalysisLevel{primary} | levels := []pipeline.AnalysisLevel{primary} | ||||
| context := pipeline.AnalysisContext{ | |||||
| Surveillance: primary, | |||||
| Presentation: presentation, | |||||
| } | |||||
| strategy := strings.ToLower(strings.TrimSpace(policy.SurveillanceStrategy)) | strategy := strings.ToLower(strings.TrimSpace(policy.SurveillanceStrategy)) | ||||
| switch strategy { | switch strategy { | ||||
| case "multi-res", "multi-resolution", "multi", "multi_res": | case "multi-res", "multi-resolution", "multi", "multi_res": | ||||
| if secondary.SampleRate != primary.SampleRate || secondary.FFTSize != primary.FFTSize { | if secondary.SampleRate != primary.SampleRate || secondary.FFTSize != primary.FFTSize { | ||||
| levels = append(levels, secondary) | levels = append(levels, secondary) | ||||
| context.Derived = append(context.Derived, secondary) | |||||
| } | } | ||||
| } | } | ||||
| return levels | |||||
| return levels, context | |||||
| } | } | ||||
| func sameIQBuffer(a []complex64, b []complex64) bool { | func sameIQBuffer(a []complex64, b []complex64) bool { | ||||
| @@ -47,12 +47,13 @@ func TestSurveillanceLevelsRespectStrategy(t *testing.T) { | |||||
| policy := pipeline.Policy{SurveillanceStrategy: "single-resolution"} | policy := pipeline.Policy{SurveillanceStrategy: "single-resolution"} | ||||
| primary := pipeline.AnalysisLevel{Name: "primary", SampleRate: 2000000, FFTSize: 2048} | primary := pipeline.AnalysisLevel{Name: "primary", SampleRate: 2000000, FFTSize: 2048} | ||||
| secondary := pipeline.AnalysisLevel{Name: "secondary", SampleRate: 1000000, FFTSize: 1024} | secondary := pipeline.AnalysisLevel{Name: "secondary", SampleRate: 1000000, FFTSize: 1024} | ||||
| levels := surveillanceLevels(policy, primary, secondary) | |||||
| presentation := pipeline.AnalysisLevel{Name: "presentation", SampleRate: 2000000, FFTSize: 2048} | |||||
| levels, _ := surveillanceLevels(policy, primary, secondary, presentation) | |||||
| if len(levels) != 1 { | if len(levels) != 1 { | ||||
| t.Fatalf("expected single level for single-resolution, got %d", len(levels)) | t.Fatalf("expected single level for single-resolution, got %d", len(levels)) | ||||
| } | } | ||||
| policy.SurveillanceStrategy = "multi-res" | policy.SurveillanceStrategy = "multi-res" | ||||
| levels = surveillanceLevels(policy, primary, secondary) | |||||
| levels, _ = surveillanceLevels(policy, primary, secondary, presentation) | |||||
| if len(levels) != 2 { | if len(levels) != 2 { | ||||
| t.Fatalf("expected secondary level for multi-res, got %d", len(levels)) | t.Fatalf("expected secondary level for multi-res, got %d", len(levels)) | ||||
| } | } | ||||
| @@ -19,6 +19,8 @@ type SpectrumDebug struct { | |||||
| Scores []map[string]any `json:"scores,omitempty"` | Scores []map[string]any `json:"scores,omitempty"` | ||||
| RefinementPlan *pipeline.RefinementPlan `json:"refinement_plan,omitempty"` | RefinementPlan *pipeline.RefinementPlan `json:"refinement_plan,omitempty"` | ||||
| Windows *RefinementWindowStats `json:"refinement_windows,omitempty"` | Windows *RefinementWindowStats `json:"refinement_windows,omitempty"` | ||||
| Refinement *RefinementDebug `json:"refinement,omitempty"` | |||||
| Decisions *DecisionDebug `json:"decisions,omitempty"` | |||||
| } | } | ||||
| type RefinementWindowStats struct { | type RefinementWindowStats struct { | ||||
| @@ -29,6 +31,20 @@ type RefinementWindowStats struct { | |||||
| Sources map[string]int `json:"sources,omitempty"` | Sources map[string]int `json:"sources,omitempty"` | ||||
| } | } | ||||
| type RefinementDebug struct { | |||||
| Plan *pipeline.RefinementPlan `json:"plan,omitempty"` | |||||
| Request *pipeline.RefinementRequest `json:"request,omitempty"` | |||||
| WorkItems []pipeline.RefinementWorkItem `json:"work_items,omitempty"` | |||||
| Windows *RefinementWindowStats `json:"windows,omitempty"` | |||||
| Queue decisionQueueStats `json:"queue,omitempty"` | |||||
| Budgets *pipeline.BudgetModel `json:"budgets,omitempty"` | |||||
| } | |||||
| type DecisionDebug struct { | |||||
| Summary decisionSummary `json:"summary"` | |||||
| Items []compactDecision `json:"items,omitempty"` | |||||
| } | |||||
| type SpectrumFrame struct { | type SpectrumFrame struct { | ||||
| Timestamp int64 `json:"ts"` | Timestamp int64 `json:"ts"` | ||||
| CenterHz float64 `json:"center_hz"` | CenterHz float64 `json:"center_hz"` | ||||
| @@ -81,8 +81,9 @@ type PipelineGoalConfig struct { | |||||
| } | } | ||||
| type PipelineConfig struct { | type PipelineConfig struct { | ||||
| Mode string `yaml:"mode" json:"mode"` | |||||
| Goals PipelineGoalConfig `yaml:"goals" json:"goals"` | |||||
| Mode string `yaml:"mode" json:"mode"` | |||||
| Profile string `yaml:"profile,omitempty" json:"profile,omitempty"` | |||||
| Goals PipelineGoalConfig `yaml:"goals" json:"goals"` | |||||
| } | } | ||||
| type SurveillanceConfig struct { | type SurveillanceConfig struct { | ||||
| @@ -190,10 +191,153 @@ func Default() Config { | |||||
| DecisionHoldMs: 2000, | DecisionHoldMs: 2000, | ||||
| }, | }, | ||||
| Profiles: []ProfileConfig{ | Profiles: []ProfileConfig{ | ||||
| {Name: "legacy", Description: "Current single-band pipeline behavior", Pipeline: &PipelineConfig{Mode: "legacy", Goals: PipelineGoalConfig{Intent: "general-monitoring"}}}, | |||||
| {Name: "wideband-balanced", Description: "Prepared baseline for scalable wideband surveillance", Pipeline: &PipelineConfig{Mode: "wideband-balanced", Goals: PipelineGoalConfig{Intent: "wideband-surveillance"}}}, | |||||
| {Name: "wideband-aggressive", Description: "Higher surveillance/refinement budgets for future broad-span monitoring", Pipeline: &PipelineConfig{Mode: "wideband-aggressive", Goals: PipelineGoalConfig{Intent: "high-density-wideband-surveillance"}}}, | |||||
| {Name: "archive", Description: "Record-first monitoring profile", Pipeline: &PipelineConfig{Mode: "archive", Goals: PipelineGoalConfig{Intent: "archive-and-triage"}}}, | |||||
| { | |||||
| Name: "legacy", | |||||
| Description: "Current single-band pipeline behavior", | |||||
| Pipeline: &PipelineConfig{Mode: "legacy", Profile: "legacy", Goals: PipelineGoalConfig{Intent: "general-monitoring"}}, | |||||
| Surveillance: &SurveillanceConfig{ | |||||
| AnalysisFFTSize: 2048, | |||||
| FrameRate: 15, | |||||
| Strategy: "single-resolution", | |||||
| DisplayBins: 2048, | |||||
| DisplayFPS: 15, | |||||
| }, | |||||
| Refinement: &RefinementConfig{ | |||||
| Enabled: true, | |||||
| MaxConcurrent: 8, | |||||
| MinCandidateSNRDb: 0, | |||||
| MinSpanHz: 0, | |||||
| MaxSpanHz: 0, | |||||
| AutoSpan: boolPtr(true), | |||||
| }, | |||||
| Resources: &ResourceConfig{ | |||||
| PreferGPU: false, | |||||
| MaxRefinementJobs: 8, | |||||
| MaxRecordingStreams: 16, | |||||
| MaxDecodeJobs: 16, | |||||
| DecisionHoldMs: 2000, | |||||
| }, | |||||
| }, | |||||
| { | |||||
| Name: "wideband-balanced", | |||||
| Description: "Baseline multi-resolution wideband surveillance", | |||||
| Pipeline: &PipelineConfig{Mode: "wideband-balanced", Profile: "wideband-balanced", Goals: PipelineGoalConfig{ | |||||
| Intent: "wideband-surveillance", | |||||
| SignalPriorities: []string{"digital", "wfm"}, | |||||
| }}, | |||||
| Surveillance: &SurveillanceConfig{ | |||||
| AnalysisFFTSize: 4096, | |||||
| FrameRate: 12, | |||||
| Strategy: "multi-resolution", | |||||
| DisplayBins: 2048, | |||||
| DisplayFPS: 12, | |||||
| }, | |||||
| Refinement: &RefinementConfig{ | |||||
| Enabled: true, | |||||
| MaxConcurrent: 16, | |||||
| MinCandidateSNRDb: 0, | |||||
| MinSpanHz: 4000, | |||||
| MaxSpanHz: 200000, | |||||
| AutoSpan: boolPtr(true), | |||||
| }, | |||||
| Resources: &ResourceConfig{ | |||||
| PreferGPU: true, | |||||
| MaxRefinementJobs: 16, | |||||
| MaxRecordingStreams: 16, | |||||
| MaxDecodeJobs: 12, | |||||
| DecisionHoldMs: 2000, | |||||
| }, | |||||
| }, | |||||
| { | |||||
| Name: "wideband-aggressive", | |||||
| Description: "Higher surveillance/refinement budgets for dense wideband monitoring", | |||||
| Pipeline: &PipelineConfig{Mode: "wideband-aggressive", Profile: "wideband-aggressive", Goals: PipelineGoalConfig{ | |||||
| Intent: "high-density-wideband-surveillance", | |||||
| SignalPriorities: []string{"digital", "wfm", "trunk"}, | |||||
| }}, | |||||
| Surveillance: &SurveillanceConfig{ | |||||
| AnalysisFFTSize: 8192, | |||||
| FrameRate: 10, | |||||
| Strategy: "multi-resolution", | |||||
| DisplayBins: 4096, | |||||
| DisplayFPS: 10, | |||||
| }, | |||||
| Refinement: &RefinementConfig{ | |||||
| Enabled: true, | |||||
| MaxConcurrent: 32, | |||||
| MinCandidateSNRDb: 0, | |||||
| MinSpanHz: 6000, | |||||
| MaxSpanHz: 250000, | |||||
| AutoSpan: boolPtr(true), | |||||
| }, | |||||
| Resources: &ResourceConfig{ | |||||
| PreferGPU: true, | |||||
| MaxRefinementJobs: 32, | |||||
| MaxRecordingStreams: 24, | |||||
| MaxDecodeJobs: 16, | |||||
| DecisionHoldMs: 2000, | |||||
| }, | |||||
| }, | |||||
| { | |||||
| Name: "archive", | |||||
| Description: "Record-first monitoring profile", | |||||
| Pipeline: &PipelineConfig{Mode: "archive", Profile: "archive", Goals: PipelineGoalConfig{ | |||||
| Intent: "archive-and-triage", | |||||
| SignalPriorities: []string{"wfm", "nfm", "digital"}, | |||||
| }}, | |||||
| Surveillance: &SurveillanceConfig{ | |||||
| AnalysisFFTSize: 4096, | |||||
| FrameRate: 12, | |||||
| Strategy: "single-resolution", | |||||
| DisplayBins: 2048, | |||||
| DisplayFPS: 12, | |||||
| }, | |||||
| Refinement: &RefinementConfig{ | |||||
| Enabled: true, | |||||
| MaxConcurrent: 12, | |||||
| MinCandidateSNRDb: 0, | |||||
| MinSpanHz: 4000, | |||||
| MaxSpanHz: 200000, | |||||
| AutoSpan: boolPtr(true), | |||||
| }, | |||||
| Resources: &ResourceConfig{ | |||||
| PreferGPU: true, | |||||
| MaxRefinementJobs: 12, | |||||
| MaxRecordingStreams: 24, | |||||
| MaxDecodeJobs: 12, | |||||
| DecisionHoldMs: 2500, | |||||
| }, | |||||
| }, | |||||
| { | |||||
| Name: "digital-hunting", | |||||
| Description: "Digital-first refinement and decode focus", | |||||
| Pipeline: &PipelineConfig{Mode: "digital-hunting", Profile: "digital-hunting", Goals: PipelineGoalConfig{ | |||||
| Intent: "digital-surveillance", | |||||
| SignalPriorities: []string{"ft8", "wspr", "fsk", "psk", "dmr"}, | |||||
| }}, | |||||
| Surveillance: &SurveillanceConfig{ | |||||
| AnalysisFFTSize: 4096, | |||||
| FrameRate: 12, | |||||
| Strategy: "multi-resolution", | |||||
| DisplayBins: 2048, | |||||
| DisplayFPS: 12, | |||||
| }, | |||||
| Refinement: &RefinementConfig{ | |||||
| Enabled: true, | |||||
| MaxConcurrent: 16, | |||||
| MinCandidateSNRDb: 0, | |||||
| MinSpanHz: 3000, | |||||
| MaxSpanHz: 120000, | |||||
| AutoSpan: boolPtr(true), | |||||
| }, | |||||
| Resources: &ResourceConfig{ | |||||
| PreferGPU: true, | |||||
| MaxRefinementJobs: 16, | |||||
| MaxRecordingStreams: 12, | |||||
| MaxDecodeJobs: 16, | |||||
| DecisionHoldMs: 2000, | |||||
| }, | |||||
| }, | |||||
| }, | }, | ||||
| Detector: DetectorConfig{ | Detector: DetectorConfig{ | ||||
| ThresholdDb: -20, | ThresholdDb: -20, | ||||
| @@ -0,0 +1,66 @@ | |||||
| package pipeline | |||||
| import "strings" | |||||
| type BudgetQueue struct { | |||||
| Max int `json:"max"` | |||||
| IntentBias float64 `json:"intent_bias,omitempty"` | |||||
| Source string `json:"source,omitempty"` | |||||
| } | |||||
| type BudgetModel struct { | |||||
| Refinement BudgetQueue `json:"refinement"` | |||||
| Record BudgetQueue `json:"record"` | |||||
| Decode BudgetQueue `json:"decode"` | |||||
| HoldMs int `json:"hold_ms"` | |||||
| Intent string `json:"intent,omitempty"` | |||||
| Profile string `json:"profile,omitempty"` | |||||
| Strategy string `json:"strategy,omitempty"` | |||||
| } | |||||
| func BudgetModelFromPolicy(policy Policy) BudgetModel { | |||||
| recordBias, decodeBias := budgetIntentBias(policy.Intent) | |||||
| return BudgetModel{ | |||||
| Refinement: BudgetQueue{ | |||||
| Max: policy.MaxRefinementJobs, | |||||
| Source: "resources.max_refinement_jobs", | |||||
| }, | |||||
| Record: BudgetQueue{ | |||||
| Max: policy.MaxRecordingStreams, | |||||
| IntentBias: recordBias, | |||||
| Source: "resources.max_recording_streams", | |||||
| }, | |||||
| Decode: BudgetQueue{ | |||||
| Max: policy.MaxDecodeJobs, | |||||
| IntentBias: decodeBias, | |||||
| Source: "resources.max_decode_jobs", | |||||
| }, | |||||
| HoldMs: policy.DecisionHoldMs, | |||||
| Intent: policy.Intent, | |||||
| Profile: policy.Profile, | |||||
| Strategy: policy.RefinementStrategy, | |||||
| } | |||||
| } | |||||
| func budgetIntentBias(intent string) (float64, float64) { | |||||
| if intent == "" { | |||||
| return 0, 0 | |||||
| } | |||||
| recordBias := 0.0 | |||||
| decodeBias := 0.0 | |||||
| intent = strings.ToLower(intent) | |||||
| if strings.Contains(intent, "archive") || strings.Contains(intent, "record") { | |||||
| recordBias += 1.5 | |||||
| } | |||||
| if strings.Contains(intent, "triage") { | |||||
| recordBias += 0.5 | |||||
| decodeBias += 0.5 | |||||
| } | |||||
| if strings.Contains(intent, "decode") || strings.Contains(intent, "analysis") { | |||||
| decodeBias += 1.0 | |||||
| } | |||||
| if strings.Contains(intent, "digital") { | |||||
| decodeBias += 0.5 | |||||
| } | |||||
| return recordBias, decodeBias | |||||
| } | |||||
| @@ -4,6 +4,8 @@ import "sdr-wideband-suite/internal/detector" | |||||
| type AnalysisLevel struct { | type AnalysisLevel struct { | ||||
| Name string `json:"name"` | Name string `json:"name"` | ||||
| Role string `json:"role,omitempty"` | |||||
| Truth string `json:"truth,omitempty"` | |||||
| SampleRate int `json:"sample_rate"` | SampleRate int `json:"sample_rate"` | ||||
| FFTSize int `json:"fft_size"` | FFTSize int `json:"fft_size"` | ||||
| CenterHz float64 `json:"center_hz"` | CenterHz float64 `json:"center_hz"` | ||||
| @@ -11,6 +13,14 @@ type AnalysisLevel struct { | |||||
| Source string `json:"source,omitempty"` | Source string `json:"source,omitempty"` | ||||
| } | } | ||||
| type AnalysisContext struct { | |||||
| Surveillance AnalysisLevel `json:"surveillance,omitempty"` | |||||
| Refinement AnalysisLevel `json:"refinement,omitempty"` | |||||
| Presentation AnalysisLevel `json:"presentation,omitempty"` | |||||
| Detail AnalysisLevel `json:"detail,omitempty"` | |||||
| Derived []AnalysisLevel `json:"derived,omitempty"` | |||||
| } | |||||
| type SurveillanceResult struct { | type SurveillanceResult struct { | ||||
| Level AnalysisLevel `json:"level"` | Level AnalysisLevel `json:"level"` | ||||
| Levels []AnalysisLevel `json:"levels,omitempty"` | Levels []AnalysisLevel `json:"levels,omitempty"` | ||||
| @@ -21,29 +31,45 @@ type SurveillanceResult struct { | |||||
| NoiseFloor float64 `json:"noise_floor"` | NoiseFloor float64 `json:"noise_floor"` | ||||
| Thresholds []float64 `json:"thresholds,omitempty"` | Thresholds []float64 `json:"thresholds,omitempty"` | ||||
| DisplayLevel AnalysisLevel `json:"display_level"` | DisplayLevel AnalysisLevel `json:"display_level"` | ||||
| Context AnalysisContext `json:"context,omitempty"` | |||||
| } | } | ||||
| type RefinementPlan struct { | type RefinementPlan struct { | ||||
| TotalCandidates int `json:"total_candidates"` | TotalCandidates int `json:"total_candidates"` | ||||
| MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` | MinCandidateSNRDb float64 `json:"min_candidate_snr_db"` | ||||
| Budget int `json:"budget"` | Budget int `json:"budget"` | ||||
| Strategy string `json:"strategy,omitempty"` | |||||
| StrategyReason string `json:"strategy_reason,omitempty"` | |||||
| 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"` | ||||
| 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"` | ||||
| ScoreModel RefinementScoreModel `json:"score_model,omitempty"` | |||||
| PriorityMin float64 `json:"priority_min,omitempty"` | PriorityMin float64 `json:"priority_min,omitempty"` | ||||
| PriorityMax float64 `json:"priority_max,omitempty"` | PriorityMax float64 `json:"priority_max,omitempty"` | ||||
| PriorityAvg float64 `json:"priority_avg,omitempty"` | PriorityAvg float64 `json:"priority_avg,omitempty"` | ||||
| PriorityCutoff float64 `json:"priority_cutoff,omitempty"` | PriorityCutoff float64 `json:"priority_cutoff,omitempty"` | ||||
| Selected []ScheduledCandidate `json:"selected,omitempty"` | Selected []ScheduledCandidate `json:"selected,omitempty"` | ||||
| WorkItems []RefinementWorkItem `json:"work_items,omitempty"` | |||||
| } | |||||
| type RefinementRequest struct { | |||||
| Strategy string `json:"strategy,omitempty"` | |||||
| Reason string `json:"reason,omitempty"` | |||||
| SpanHintHz float64 `json:"span_hint_hz,omitempty"` | |||||
| } | } | ||||
| type RefinementInput struct { | type RefinementInput struct { | ||||
| Level AnalysisLevel `json:"level"` | Level AnalysisLevel `json:"level"` | ||||
| Detail AnalysisLevel `json:"detail,omitempty"` | |||||
| Context AnalysisContext `json:"context,omitempty"` | |||||
| Request RefinementRequest `json:"request,omitempty"` | |||||
| Budgets BudgetModel `json:"budgets,omitempty"` | |||||
| Candidates []Candidate `json:"candidates,omitempty"` | Candidates []Candidate `json:"candidates,omitempty"` | ||||
| Scheduled []ScheduledCandidate `json:"scheduled,omitempty"` | Scheduled []ScheduledCandidate `json:"scheduled,omitempty"` | ||||
| WorkItems []RefinementWorkItem `json:"work_items,omitempty"` | |||||
| Plan RefinementPlan `json:"plan,omitempty"` | Plan RefinementPlan `json:"plan,omitempty"` | ||||
| Windows []RefinementWindow `json:"windows,omitempty"` | Windows []RefinementWindow `json:"windows,omitempty"` | ||||
| SampleRate int `json:"sample_rate"` | SampleRate int `json:"sample_rate"` | ||||
| @@ -4,6 +4,7 @@ import "sdr-wideband-suite/internal/config" | |||||
| type Policy struct { | type Policy struct { | ||||
| Mode string `json:"mode"` | Mode string `json:"mode"` | ||||
| Profile string `json:"profile,omitempty"` | |||||
| Intent string `json:"intent"` | Intent string `json:"intent"` | ||||
| MonitorCenterHz float64 `json:"monitor_center_hz,omitempty"` | MonitorCenterHz float64 `json:"monitor_center_hz,omitempty"` | ||||
| MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` | MonitorStartHz float64 `json:"monitor_start_hz,omitempty"` | ||||
| @@ -17,6 +18,7 @@ type Policy struct { | |||||
| DisplayBins int `json:"display_bins"` | DisplayBins int `json:"display_bins"` | ||||
| DisplayFPS int `json:"display_fps"` | DisplayFPS int `json:"display_fps"` | ||||
| SurveillanceStrategy string `json:"surveillance_strategy"` | SurveillanceStrategy string `json:"surveillance_strategy"` | ||||
| RefinementStrategy string `json:"refinement_strategy,omitempty"` | |||||
| RefinementEnabled bool `json:"refinement_enabled"` | RefinementEnabled bool `json:"refinement_enabled"` | ||||
| MaxRefinementJobs int `json:"max_refinement_jobs"` | MaxRefinementJobs int `json:"max_refinement_jobs"` | ||||
| RefinementMaxConcurrent int `json:"refinement_max_concurrent"` | RefinementMaxConcurrent int `json:"refinement_max_concurrent"` | ||||
| @@ -25,6 +27,7 @@ type Policy struct { | |||||
| RefinementMaxSpanHz float64 `json:"refinement_max_span_hz"` | RefinementMaxSpanHz float64 `json:"refinement_max_span_hz"` | ||||
| RefinementAutoSpan bool `json:"refinement_auto_span"` | RefinementAutoSpan bool `json:"refinement_auto_span"` | ||||
| PreferGPU bool `json:"prefer_gpu"` | PreferGPU bool `json:"prefer_gpu"` | ||||
| MaxRecordingStreams int `json:"max_recording_streams"` | |||||
| MaxDecodeJobs int `json:"max_decode_jobs"` | MaxDecodeJobs int `json:"max_decode_jobs"` | ||||
| DecisionHoldMs int `json:"decision_hold_ms"` | DecisionHoldMs int `json:"decision_hold_ms"` | ||||
| } | } | ||||
| @@ -32,6 +35,7 @@ type Policy struct { | |||||
| func PolicyFromConfig(cfg config.Config) Policy { | func PolicyFromConfig(cfg config.Config) Policy { | ||||
| p := Policy{ | p := Policy{ | ||||
| Mode: cfg.Pipeline.Mode, | Mode: cfg.Pipeline.Mode, | ||||
| Profile: cfg.Pipeline.Profile, | |||||
| Intent: cfg.Pipeline.Goals.Intent, | Intent: cfg.Pipeline.Goals.Intent, | ||||
| MonitorCenterHz: cfg.CenterHz, | MonitorCenterHz: cfg.CenterHz, | ||||
| MonitorStartHz: cfg.Pipeline.Goals.MonitorStartHz, | MonitorStartHz: cfg.Pipeline.Goals.MonitorStartHz, | ||||
| @@ -53,9 +57,11 @@ func PolicyFromConfig(cfg config.Config) Policy { | |||||
| RefinementMaxSpanHz: cfg.Refinement.MaxSpanHz, | RefinementMaxSpanHz: cfg.Refinement.MaxSpanHz, | ||||
| RefinementAutoSpan: config.BoolValue(cfg.Refinement.AutoSpan, true), | RefinementAutoSpan: config.BoolValue(cfg.Refinement.AutoSpan, true), | ||||
| PreferGPU: cfg.Resources.PreferGPU, | PreferGPU: cfg.Resources.PreferGPU, | ||||
| MaxRecordingStreams: cfg.Resources.MaxRecordingStreams, | |||||
| MaxDecodeJobs: cfg.Resources.MaxDecodeJobs, | MaxDecodeJobs: cfg.Resources.MaxDecodeJobs, | ||||
| DecisionHoldMs: cfg.Resources.DecisionHoldMs, | DecisionHoldMs: cfg.Resources.DecisionHoldMs, | ||||
| } | } | ||||
| p.RefinementStrategy, _ = refinementStrategy(p) | |||||
| 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 | ||||
| } | } | ||||
| @@ -66,6 +72,10 @@ func ApplyNamedProfile(cfg *config.Config, name string) { | |||||
| if cfg == nil || name == "" { | if cfg == nil || name == "" { | ||||
| return | return | ||||
| } | } | ||||
| cfg.Pipeline.Profile = name | |||||
| if prof, ok := ResolveProfile(*cfg, name); ok { | |||||
| MergeProfile(cfg, prof) | |||||
| } | |||||
| switch name { | switch name { | ||||
| case "legacy": | case "legacy": | ||||
| cfg.Pipeline.Mode = "legacy" | cfg.Pipeline.Mode = "legacy" | ||||
| @@ -78,7 +88,7 @@ func ApplyNamedProfile(cfg *config.Config, name string) { | |||||
| case "wideband-balanced": | case "wideband-balanced": | ||||
| cfg.Pipeline.Mode = "wideband-balanced" | cfg.Pipeline.Mode = "wideband-balanced" | ||||
| cfg.Pipeline.Goals.Intent = "wideband-surveillance" | cfg.Pipeline.Goals.Intent = "wideband-surveillance" | ||||
| cfg.Surveillance.Strategy = "single-resolution" | |||||
| cfg.Surveillance.Strategy = "multi-resolution" | |||||
| if cfg.Surveillance.AnalysisFFTSize < 4096 { | if cfg.Surveillance.AnalysisFFTSize < 4096 { | ||||
| cfg.Surveillance.AnalysisFFTSize = 4096 | cfg.Surveillance.AnalysisFFTSize = 4096 | ||||
| } | } | ||||
| @@ -101,11 +111,14 @@ func ApplyNamedProfile(cfg *config.Config, name string) { | |||||
| if cfg.Refinement.MaxSpanHz <= 0 { | if cfg.Refinement.MaxSpanHz <= 0 { | ||||
| cfg.Refinement.MaxSpanHz = 200000 | cfg.Refinement.MaxSpanHz = 200000 | ||||
| } | } | ||||
| if len(cfg.Pipeline.Goals.SignalPriorities) == 0 { | |||||
| cfg.Pipeline.Goals.SignalPriorities = []string{"digital", "wfm"} | |||||
| } | |||||
| cfg.Resources.PreferGPU = true | cfg.Resources.PreferGPU = true | ||||
| case "wideband-aggressive": | case "wideband-aggressive": | ||||
| cfg.Pipeline.Mode = "wideband-aggressive" | cfg.Pipeline.Mode = "wideband-aggressive" | ||||
| cfg.Pipeline.Goals.Intent = "high-density-wideband-surveillance" | cfg.Pipeline.Goals.Intent = "high-density-wideband-surveillance" | ||||
| cfg.Surveillance.Strategy = "single-resolution" | |||||
| cfg.Surveillance.Strategy = "multi-resolution" | |||||
| if cfg.Surveillance.AnalysisFFTSize < 8192 { | if cfg.Surveillance.AnalysisFFTSize < 8192 { | ||||
| cfg.Surveillance.AnalysisFFTSize = 8192 | cfg.Surveillance.AnalysisFFTSize = 8192 | ||||
| } | } | ||||
| @@ -128,6 +141,9 @@ func ApplyNamedProfile(cfg *config.Config, name string) { | |||||
| if cfg.Refinement.MaxSpanHz <= 0 { | if cfg.Refinement.MaxSpanHz <= 0 { | ||||
| cfg.Refinement.MaxSpanHz = 250000 | cfg.Refinement.MaxSpanHz = 250000 | ||||
| } | } | ||||
| if len(cfg.Pipeline.Goals.SignalPriorities) == 0 { | |||||
| cfg.Pipeline.Goals.SignalPriorities = []string{"digital", "wfm", "trunk"} | |||||
| } | |||||
| cfg.Resources.PreferGPU = true | cfg.Resources.PreferGPU = true | ||||
| case "archive": | case "archive": | ||||
| cfg.Pipeline.Mode = "archive" | cfg.Pipeline.Mode = "archive" | ||||
| @@ -139,9 +155,51 @@ func ApplyNamedProfile(cfg *config.Config, name string) { | |||||
| if cfg.Resources.MaxRefinementJobs < 12 { | if cfg.Resources.MaxRefinementJobs < 12 { | ||||
| cfg.Resources.MaxRefinementJobs = 12 | cfg.Resources.MaxRefinementJobs = 12 | ||||
| } | } | ||||
| if cfg.Resources.MaxRecordingStreams < 24 { | |||||
| cfg.Resources.MaxRecordingStreams = 24 | |||||
| } | |||||
| if cfg.Resources.MaxDecodeJobs < 12 { | |||||
| cfg.Resources.MaxDecodeJobs = 12 | |||||
| } | |||||
| if len(cfg.Pipeline.Goals.SignalPriorities) == 0 { | |||||
| cfg.Pipeline.Goals.SignalPriorities = []string{"wfm", "nfm", "digital"} | |||||
| } | |||||
| if !cfg.Recorder.Enabled { | if !cfg.Recorder.Enabled { | ||||
| cfg.Recorder.Enabled = true | cfg.Recorder.Enabled = true | ||||
| } | } | ||||
| case "digital-hunting": | |||||
| cfg.Pipeline.Mode = "digital-hunting" | |||||
| cfg.Pipeline.Goals.Intent = "digital-surveillance" | |||||
| cfg.Surveillance.Strategy = "multi-resolution" | |||||
| if cfg.Surveillance.AnalysisFFTSize < 4096 { | |||||
| cfg.Surveillance.AnalysisFFTSize = 4096 | |||||
| } | |||||
| if cfg.FrameRate < 12 { | |||||
| cfg.FrameRate = 12 | |||||
| } | |||||
| if cfg.Surveillance.FrameRate < 12 { | |||||
| cfg.Surveillance.FrameRate = 12 | |||||
| } | |||||
| cfg.Refinement.Enabled = true | |||||
| if cfg.Refinement.MaxConcurrent < 16 { | |||||
| cfg.Refinement.MaxConcurrent = 16 | |||||
| } | |||||
| if cfg.Resources.MaxRefinementJobs < 16 { | |||||
| cfg.Resources.MaxRefinementJobs = 16 | |||||
| } | |||||
| if cfg.Refinement.MinSpanHz <= 0 { | |||||
| cfg.Refinement.MinSpanHz = 3000 | |||||
| } | |||||
| if cfg.Refinement.MaxSpanHz <= 0 { | |||||
| cfg.Refinement.MaxSpanHz = 120000 | |||||
| } | |||||
| if len(cfg.Pipeline.Goals.SignalPriorities) == 0 { | |||||
| cfg.Pipeline.Goals.SignalPriorities = []string{"ft8", "wspr", "fsk", "psk", "dmr"} | |||||
| } | |||||
| cfg.Resources.PreferGPU = true | |||||
| } | |||||
| if cfg.Pipeline.Goals.MonitorSpanHz <= 0 && cfg.SampleRate > 0 { | |||||
| cfg.Pipeline.Goals.MonitorSpanHz = float64(cfg.SampleRate) | |||||
| } | } | ||||
| if cfg.Resources.MaxDecodeJobs <= 0 { | if cfg.Resources.MaxDecodeJobs <= 0 { | ||||
| cfg.Resources.MaxDecodeJobs = cfg.Resources.MaxRecordingStreams | cfg.Resources.MaxDecodeJobs = cfg.Resources.MaxRecordingStreams | ||||
| @@ -12,12 +12,18 @@ func TestApplyNamedProfile(t *testing.T) { | |||||
| if cfg.Pipeline.Mode != "wideband-balanced" { | if cfg.Pipeline.Mode != "wideband-balanced" { | ||||
| t.Fatalf("mode not applied: %s", cfg.Pipeline.Mode) | t.Fatalf("mode not applied: %s", cfg.Pipeline.Mode) | ||||
| } | } | ||||
| if cfg.Pipeline.Profile != "wideband-balanced" { | |||||
| t.Fatalf("profile not applied: %s", cfg.Pipeline.Profile) | |||||
| } | |||||
| if cfg.Pipeline.Goals.Intent != "wideband-surveillance" { | if cfg.Pipeline.Goals.Intent != "wideband-surveillance" { | ||||
| t.Fatalf("intent not applied: %s", cfg.Pipeline.Goals.Intent) | t.Fatalf("intent not applied: %s", cfg.Pipeline.Goals.Intent) | ||||
| } | } | ||||
| if cfg.Surveillance.AnalysisFFTSize < 4096 { | if cfg.Surveillance.AnalysisFFTSize < 4096 { | ||||
| t.Fatalf("analysis fft too small: %d", cfg.Surveillance.AnalysisFFTSize) | t.Fatalf("analysis fft too small: %d", cfg.Surveillance.AnalysisFFTSize) | ||||
| } | } | ||||
| if cfg.Surveillance.Strategy != "multi-resolution" { | |||||
| t.Fatalf("strategy not applied: %s", cfg.Surveillance.Strategy) | |||||
| } | |||||
| if !cfg.Refinement.Enabled { | if !cfg.Refinement.Enabled { | ||||
| t.Fatalf("refinement should stay enabled") | t.Fatalf("refinement should stay enabled") | ||||
| } | } | ||||
| @@ -42,6 +48,7 @@ func TestPolicyFromConfig(t *testing.T) { | |||||
| cfg.Resources.MaxRefinementJobs = 5 | cfg.Resources.MaxRefinementJobs = 5 | ||||
| cfg.Refinement.MinCandidateSNRDb = 2.5 | cfg.Refinement.MinCandidateSNRDb = 2.5 | ||||
| cfg.Resources.PreferGPU = true | cfg.Resources.PreferGPU = true | ||||
| cfg.Resources.MaxRecordingStreams = 7 | |||||
| p := PolicyFromConfig(cfg) | p := PolicyFromConfig(cfg) | ||||
| if p.Mode != "archive" || p.Intent != "archive-and-triage" || p.SurveillanceFFTSize != 8192 || p.SurveillanceFPS != 9 || p.DisplayBins != 1200 || p.DisplayFPS != 6 { | if p.Mode != "archive" || p.Intent != "archive-and-triage" || p.SurveillanceFFTSize != 8192 || p.SurveillanceFPS != 9 || p.DisplayBins != 1200 || p.DisplayFPS != 6 { | ||||
| t.Fatalf("unexpected policy: %+v", p) | t.Fatalf("unexpected policy: %+v", p) | ||||
| @@ -55,4 +62,10 @@ func TestPolicyFromConfig(t *testing.T) { | |||||
| if !p.RefinementEnabled || p.MaxRefinementJobs != 5 || p.MinCandidateSNRDb != 2.5 || !p.PreferGPU { | if !p.RefinementEnabled || p.MaxRefinementJobs != 5 || p.MinCandidateSNRDb != 2.5 || !p.PreferGPU { | ||||
| t.Fatalf("unexpected policy details: %+v", p) | t.Fatalf("unexpected policy details: %+v", p) | ||||
| } | } | ||||
| if p.MaxRecordingStreams != 7 { | |||||
| t.Fatalf("unexpected record budget: %+v", p.MaxRecordingStreams) | |||||
| } | |||||
| if p.RefinementStrategy == "" { | |||||
| t.Fatalf("expected refinement strategy to be set") | |||||
| } | |||||
| } | } | ||||
| @@ -1,10 +1,15 @@ | |||||
| package pipeline | package pipeline | ||||
| import "sdr-wideband-suite/internal/config" | |||||
| import ( | |||||
| "strings" | |||||
| "sdr-wideband-suite/internal/config" | |||||
| ) | |||||
| func ResolveProfile(cfg config.Config, name string) (config.ProfileConfig, bool) { | func ResolveProfile(cfg config.Config, name string) (config.ProfileConfig, bool) { | ||||
| name = strings.ToLower(strings.TrimSpace(name)) | |||||
| for _, p := range cfg.Profiles { | for _, p := range cfg.Profiles { | ||||
| if p.Name == name { | |||||
| if strings.ToLower(strings.TrimSpace(p.Name)) == name { | |||||
| return p, true | return p, true | ||||
| } | } | ||||
| } | } | ||||
| @@ -15,8 +20,14 @@ func MergeProfile(cfg *config.Config, profile config.ProfileConfig) { | |||||
| if cfg == nil { | if cfg == nil { | ||||
| return | return | ||||
| } | } | ||||
| if profile.Name != "" { | |||||
| cfg.Pipeline.Profile = profile.Name | |||||
| } | |||||
| if profile.Pipeline != nil { | if profile.Pipeline != nil { | ||||
| cfg.Pipeline = *profile.Pipeline | cfg.Pipeline = *profile.Pipeline | ||||
| if profile.Name != "" { | |||||
| cfg.Pipeline.Profile = profile.Name | |||||
| } | |||||
| } | } | ||||
| if profile.Surveillance != nil { | if profile.Surveillance != nil { | ||||
| cfg.Surveillance = *profile.Surveillance | cfg.Surveillance = *profile.Surveillance | ||||
| @@ -9,12 +9,12 @@ import ( | |||||
| func TestResolveAndMergeProfile(t *testing.T) { | func TestResolveAndMergeProfile(t *testing.T) { | ||||
| cfg := config.Default() | cfg := config.Default() | ||||
| cfg.Profiles = append(cfg.Profiles, config.ProfileConfig{ | cfg.Profiles = append(cfg.Profiles, config.ProfileConfig{ | ||||
| Name: "custom-test", | |||||
| Description: "test profile", | |||||
| Pipeline: &config.PipelineConfig{Mode: "custom", Goals: config.PipelineGoalConfig{Intent: "custom-intent", MonitorSpanHz: 12.5e6}}, | |||||
| Name: "custom-test", | |||||
| Description: "test profile", | |||||
| Pipeline: &config.PipelineConfig{Mode: "custom", Goals: config.PipelineGoalConfig{Intent: "custom-intent", MonitorSpanHz: 12.5e6}}, | |||||
| Surveillance: &config.SurveillanceConfig{AnalysisFFTSize: 16384, FrameRate: 8, Strategy: "single-resolution"}, | Surveillance: &config.SurveillanceConfig{AnalysisFFTSize: 16384, FrameRate: 8, Strategy: "single-resolution"}, | ||||
| Refinement: &config.RefinementConfig{Enabled: true, MaxConcurrent: 20, MinCandidateSNRDb: 4}, | |||||
| Resources: &config.ResourceConfig{PreferGPU: true, MaxRefinementJobs: 20, MaxRecordingStreams: 32}, | |||||
| Refinement: &config.RefinementConfig{Enabled: true, MaxConcurrent: 20, MinCandidateSNRDb: 4}, | |||||
| Resources: &config.ResourceConfig{PreferGPU: true, MaxRefinementJobs: 20, MaxRecordingStreams: 32}, | |||||
| }) | }) | ||||
| p, ok := ResolveProfile(cfg, "custom-test") | p, ok := ResolveProfile(cfg, "custom-test") | ||||
| if !ok { | if !ok { | ||||
| @@ -24,6 +24,9 @@ func TestResolveAndMergeProfile(t *testing.T) { | |||||
| if cfg.Pipeline.Mode != "custom" || cfg.Pipeline.Goals.Intent != "custom-intent" { | if cfg.Pipeline.Mode != "custom" || cfg.Pipeline.Goals.Intent != "custom-intent" { | ||||
| t.Fatalf("pipeline not merged: %+v", cfg.Pipeline) | t.Fatalf("pipeline not merged: %+v", cfg.Pipeline) | ||||
| } | } | ||||
| if cfg.Pipeline.Profile != "custom-test" { | |||||
| t.Fatalf("profile not applied: %+v", cfg.Pipeline.Profile) | |||||
| } | |||||
| if cfg.FFTSize != 16384 || cfg.FrameRate != 8 { | if cfg.FFTSize != 16384 || cfg.FrameRate != 8 { | ||||
| t.Fatalf("surveillance not merged into legacy fields: fft=%d fps=%d", cfg.FFTSize, cfg.FrameRate) | t.Fatalf("surveillance not merged into legacy fields: fft=%d fps=%d", cfg.FFTSize, cfg.FrameRate) | ||||
| } | } | ||||
| @@ -1,32 +1,73 @@ | |||||
| package pipeline | package pipeline | ||||
| import "sort" | |||||
| import ( | |||||
| "sort" | |||||
| "strings" | |||||
| ) | |||||
| type ScheduledCandidate struct { | type ScheduledCandidate struct { | ||||
| Candidate Candidate `json:"candidate"` | |||||
| Priority float64 `json:"priority"` | |||||
| Breakdown *PriorityBreakdown `json:"breakdown,omitempty"` | |||||
| Candidate Candidate `json:"candidate"` | |||||
| Priority float64 `json:"priority"` | |||||
| Score *RefinementScore `json:"score,omitempty"` | |||||
| Breakdown *RefinementScoreDetails `json:"breakdown,omitempty"` | |||||
| } | } | ||||
| type PriorityBreakdown struct { | |||||
| type RefinementScoreModel struct { | |||||
| SNRWeight float64 `json:"snr_weight"` | |||||
| BandwidthWeight float64 `json:"bandwidth_weight"` | |||||
| PeakWeight float64 `json:"peak_weight"` | |||||
| } | |||||
| type RefinementScoreDetails struct { | |||||
| SNRScore float64 `json:"snr_score"` | SNRScore float64 `json:"snr_score"` | ||||
| BandwidthScore float64 `json:"bandwidth_score"` | BandwidthScore float64 `json:"bandwidth_score"` | ||||
| PeakScore float64 `json:"peak_score"` | PeakScore float64 `json:"peak_score"` | ||||
| PolicyBoost float64 `json:"policy_boost"` | PolicyBoost float64 `json:"policy_boost"` | ||||
| } | } | ||||
| type RefinementScore struct { | |||||
| Total float64 `json:"total"` | |||||
| Breakdown RefinementScoreDetails `json:"breakdown"` | |||||
| Weights *RefinementScoreModel `json:"weights,omitempty"` | |||||
| } | |||||
| type RefinementWorkItem struct { | |||||
| Candidate Candidate `json:"candidate"` | |||||
| Window RefinementWindow `json:"window,omitempty"` | |||||
| Priority float64 `json:"priority,omitempty"` | |||||
| Score *RefinementScore `json:"score,omitempty"` | |||||
| Breakdown *RefinementScoreDetails `json:"breakdown,omitempty"` | |||||
| Status string `json:"status,omitempty"` | |||||
| Reason string `json:"reason,omitempty"` | |||||
| } | |||||
| const ( | |||||
| RefinementStatusSelected = "selected" | |||||
| RefinementStatusDropped = "dropped" | |||||
| RefinementStatusDeferred = "deferred" | |||||
| ) | |||||
| const ( | |||||
| RefinementReasonSelected = "selected" | |||||
| RefinementReasonMonitorGate = "dropped:monitor" | |||||
| RefinementReasonBelowSNR = "dropped:snr" | |||||
| RefinementReasonBudget = "dropped:budget" | |||||
| RefinementReasonDisabled = "dropped:disabled" | |||||
| RefinementReasonUnclassified = "dropped:unclassified" | |||||
| ) | |||||
| // BuildRefinementPlan scores and budgets candidates for costly local refinement. | // BuildRefinementPlan scores and budgets candidates for costly local refinement. | ||||
| // Current heuristic is intentionally simple and deterministic; later phases can add | // Current heuristic is intentionally simple and deterministic; later phases can add | ||||
| // richer scoring (novelty, persistence, profile-aware band priorities, decoder value). | // richer scoring (novelty, persistence, profile-aware band priorities, decoder value). | ||||
| func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | ||||
| budget := policy.MaxRefinementJobs | |||||
| if policy.RefinementMaxConcurrent > 0 && (budget <= 0 || policy.RefinementMaxConcurrent < budget) { | |||||
| budget = policy.RefinementMaxConcurrent | |||||
| } | |||||
| budget := refinementBudget(policy) | |||||
| strategy, strategyReason := refinementStrategy(policy) | |||||
| plan := RefinementPlan{ | plan := RefinementPlan{ | ||||
| TotalCandidates: len(candidates), | TotalCandidates: len(candidates), | ||||
| MinCandidateSNRDb: policy.MinCandidateSNRDb, | MinCandidateSNRDb: policy.MinCandidateSNRDb, | ||||
| Budget: budget, | Budget: budget, | ||||
| Strategy: strategy, | |||||
| StrategyReason: strategyReason, | |||||
| } | } | ||||
| if start, end, ok := monitorBounds(policy); ok { | if start, end, ok := monitorBounds(policy); ok { | ||||
| plan.MonitorStartHz = start | plan.MonitorStartHz = start | ||||
| @@ -39,14 +80,31 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | |||||
| return plan | return plan | ||||
| } | } | ||||
| snrWeight, bwWeight, peakWeight := refinementIntentWeights(policy.Intent) | snrWeight, bwWeight, peakWeight := refinementIntentWeights(policy.Intent) | ||||
| scoreModel := RefinementScoreModel{ | |||||
| SNRWeight: snrWeight, | |||||
| BandwidthWeight: bwWeight, | |||||
| PeakWeight: peakWeight, | |||||
| } | |||||
| plan.ScoreModel = scoreModel | |||||
| scored := make([]ScheduledCandidate, 0, len(candidates)) | scored := make([]ScheduledCandidate, 0, len(candidates)) | ||||
| workItems := make([]RefinementWorkItem, 0, len(candidates)) | |||||
| for _, c := range candidates { | for _, c := range candidates { | ||||
| if !candidateInMonitor(policy, c) { | if !candidateInMonitor(policy, c) { | ||||
| plan.DroppedByMonitor++ | plan.DroppedByMonitor++ | ||||
| workItems = append(workItems, RefinementWorkItem{ | |||||
| Candidate: c, | |||||
| Status: RefinementStatusDropped, | |||||
| Reason: RefinementReasonMonitorGate, | |||||
| }) | |||||
| continue | continue | ||||
| } | } | ||||
| if c.SNRDb < policy.MinCandidateSNRDb { | if c.SNRDb < policy.MinCandidateSNRDb { | ||||
| plan.DroppedBySNR++ | plan.DroppedBySNR++ | ||||
| workItems = append(workItems, RefinementWorkItem{ | |||||
| Candidate: c, | |||||
| Status: RefinementStatusDropped, | |||||
| Reason: RefinementReasonBelowSNR, | |||||
| }) | |||||
| continue | continue | ||||
| } | } | ||||
| snrScore := c.SNRDb * snrWeight | snrScore := c.SNRDb * snrWeight | ||||
| @@ -60,15 +118,28 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | |||||
| peakScore = (c.PeakDb / 20.0) * peakWeight | peakScore = (c.PeakDb / 20.0) * peakWeight | ||||
| } | } | ||||
| priority := snrScore + bwScore + peakScore + policyBoost | priority := snrScore + bwScore + peakScore + policyBoost | ||||
| scored = append(scored, ScheduledCandidate{ | |||||
| Candidate: c, | |||||
| Priority: priority, | |||||
| Breakdown: &PriorityBreakdown{ | |||||
| score := &RefinementScore{ | |||||
| Total: priority, | |||||
| Breakdown: RefinementScoreDetails{ | |||||
| SNRScore: snrScore, | SNRScore: snrScore, | ||||
| BandwidthScore: bwScore, | BandwidthScore: bwScore, | ||||
| PeakScore: peakScore, | PeakScore: peakScore, | ||||
| PolicyBoost: policyBoost, | PolicyBoost: policyBoost, | ||||
| }, | }, | ||||
| Weights: &scoreModel, | |||||
| } | |||||
| scored = append(scored, ScheduledCandidate{ | |||||
| Candidate: c, | |||||
| Priority: priority, | |||||
| Score: score, | |||||
| Breakdown: &score.Breakdown, | |||||
| }) | |||||
| workItems = append(workItems, RefinementWorkItem{ | |||||
| Candidate: c, | |||||
| Priority: priority, | |||||
| Score: score, | |||||
| Breakdown: &score.Breakdown, | |||||
| Status: RefinementStatusDeferred, | |||||
| }) | }) | ||||
| } | } | ||||
| sort.Slice(scored, func(i, j int) bool { | sort.Slice(scored, func(i, j int) bool { | ||||
| @@ -103,6 +174,23 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan { | |||||
| plan.PriorityCutoff = plan.Selected[len(plan.Selected)-1].Priority | plan.PriorityCutoff = plan.Selected[len(plan.Selected)-1].Priority | ||||
| } | } | ||||
| plan.DroppedByBudget = len(scored) - len(plan.Selected) | plan.DroppedByBudget = len(scored) - len(plan.Selected) | ||||
| if len(plan.Selected) > 0 { | |||||
| selected := map[int64]struct{}{} | |||||
| for _, s := range plan.Selected { | |||||
| selected[s.Candidate.ID] = struct{}{} | |||||
| } | |||||
| for i := range workItems { | |||||
| item := &workItems[i] | |||||
| if _, ok := selected[item.Candidate.ID]; ok { | |||||
| item.Status = RefinementStatusSelected | |||||
| item.Reason = RefinementReasonSelected | |||||
| } else if item.Status == RefinementStatusDeferred { | |||||
| item.Status = RefinementStatusDropped | |||||
| item.Reason = RefinementReasonBudget | |||||
| } | |||||
| } | |||||
| } | |||||
| plan.WorkItems = workItems | |||||
| return plan | return plan | ||||
| } | } | ||||
| @@ -110,6 +198,28 @@ func ScheduleCandidates(candidates []Candidate, policy Policy) []ScheduledCandid | |||||
| return BuildRefinementPlan(candidates, policy).Selected | return BuildRefinementPlan(candidates, policy).Selected | ||||
| } | } | ||||
| func refinementBudget(policy Policy) int { | |||||
| budget := policy.MaxRefinementJobs | |||||
| if policy.RefinementMaxConcurrent > 0 && (budget <= 0 || policy.RefinementMaxConcurrent < budget) { | |||||
| budget = policy.RefinementMaxConcurrent | |||||
| } | |||||
| return budget | |||||
| } | |||||
| func refinementStrategy(policy Policy) (string, string) { | |||||
| intent := strings.ToLower(strings.TrimSpace(policy.Intent)) | |||||
| switch { | |||||
| case strings.Contains(intent, "digital") || strings.Contains(intent, "hunt") || strings.Contains(intent, "decode"): | |||||
| return "digital-hunting", "intent" | |||||
| case strings.Contains(intent, "archive") || strings.Contains(intent, "triage") || strings.Contains(policy.Mode, "archive"): | |||||
| return "archive-oriented", "intent" | |||||
| case strings.Contains(strings.ToLower(policy.SurveillanceStrategy), "multi"): | |||||
| return "multi-resolution", "surveillance-strategy" | |||||
| default: | |||||
| return "single-resolution", "default" | |||||
| } | |||||
| } | |||||
| func minFloat64(a, b float64) float64 { | func minFloat64(a, b float64) float64 { | ||||
| if a < b { | if a < b { | ||||
| return a | return a | ||||
| @@ -42,6 +42,21 @@ func TestBuildRefinementPlanTracksDrops(t *testing.T) { | |||||
| if len(plan.Selected) != 1 || plan.Selected[0].Candidate.ID != 2 { | if len(plan.Selected) != 1 || plan.Selected[0].Candidate.ID != 2 { | ||||
| t.Fatalf("unexpected plan selection: %+v", plan.Selected) | t.Fatalf("unexpected plan selection: %+v", plan.Selected) | ||||
| } | } | ||||
| if len(plan.WorkItems) != len(cands) { | |||||
| t.Fatalf("expected work items for all candidates, got %d", len(plan.WorkItems)) | |||||
| } | |||||
| item2 := findWorkItem(plan.WorkItems, 2) | |||||
| if item2 == nil || item2.Status != RefinementStatusSelected || item2.Reason != RefinementReasonSelected { | |||||
| t.Fatalf("expected candidate 2 selected with reason, got %+v", item2) | |||||
| } | |||||
| item1 := findWorkItem(plan.WorkItems, 1) | |||||
| if item1 == nil || item1.Reason != RefinementReasonBelowSNR { | |||||
| t.Fatalf("expected candidate 1 dropped by snr, got %+v", item1) | |||||
| } | |||||
| item3 := findWorkItem(plan.WorkItems, 3) | |||||
| if item3 == nil || item3.Reason != RefinementReasonBudget { | |||||
| t.Fatalf("expected candidate 3 dropped by budget, got %+v", item3) | |||||
| } | |||||
| } | } | ||||
| func TestBuildRefinementPlanRespectsMaxConcurrent(t *testing.T) { | func TestBuildRefinementPlanRespectsMaxConcurrent(t *testing.T) { | ||||
| @@ -139,4 +154,16 @@ func TestBuildRefinementPlanPriorityStats(t *testing.T) { | |||||
| if plan.Selected[0].Breakdown == nil { | if plan.Selected[0].Breakdown == nil { | ||||
| t.Fatalf("expected breakdown on selected candidate") | t.Fatalf("expected breakdown on selected candidate") | ||||
| } | } | ||||
| if plan.Selected[0].Score == nil || plan.Selected[0].Score.Total == 0 { | |||||
| t.Fatalf("expected score on selected candidate") | |||||
| } | |||||
| } | |||||
| func findWorkItem(items []RefinementWorkItem, id int64) *RefinementWorkItem { | |||||
| for i := range items { | |||||
| if items[i].Candidate.ID == id { | |||||
| return &items[i] | |||||
| } | |||||
| } | |||||
| return nil | |||||
| } | } | ||||
| @@ -63,23 +63,23 @@ type ConfigUpdate struct { | |||||
| } | } | ||||
| type DetectorUpdate struct { | type DetectorUpdate struct { | ||||
| ThresholdDb *float64 `json:"threshold_db"` | |||||
| MinDuration *int `json:"min_duration_ms"` | |||||
| HoldMs *int `json:"hold_ms"` | |||||
| EmaAlpha *float64 `json:"ema_alpha"` | |||||
| HysteresisDb *float64 `json:"hysteresis_db"` | |||||
| MinStableFrames *int `json:"min_stable_frames"` | |||||
| GapToleranceMs *int `json:"gap_tolerance_ms"` | |||||
| CFARMode *string `json:"cfar_mode"` | |||||
| CFARGuardHz *float64 `json:"cfar_guard_hz"` | |||||
| CFARTrainHz *float64 `json:"cfar_train_hz"` | |||||
| CFARGuardCells *int `json:"cfar_guard_cells"` | |||||
| CFARTrainCells *int `json:"cfar_train_cells"` | |||||
| CFARRank *int `json:"cfar_rank"` | |||||
| CFARScaleDb *float64 `json:"cfar_scale_db"` | |||||
| CFARWrapAround *bool `json:"cfar_wrap_around"` | |||||
| EdgeMarginDb *float64 `json:"edge_margin_db"` | |||||
| MergeGapHz *float64 `json:"merge_gap_hz"` | |||||
| ThresholdDb *float64 `json:"threshold_db"` | |||||
| MinDuration *int `json:"min_duration_ms"` | |||||
| HoldMs *int `json:"hold_ms"` | |||||
| EmaAlpha *float64 `json:"ema_alpha"` | |||||
| HysteresisDb *float64 `json:"hysteresis_db"` | |||||
| MinStableFrames *int `json:"min_stable_frames"` | |||||
| GapToleranceMs *int `json:"gap_tolerance_ms"` | |||||
| CFARMode *string `json:"cfar_mode"` | |||||
| CFARGuardHz *float64 `json:"cfar_guard_hz"` | |||||
| CFARTrainHz *float64 `json:"cfar_train_hz"` | |||||
| CFARGuardCells *int `json:"cfar_guard_cells"` | |||||
| CFARTrainCells *int `json:"cfar_train_cells"` | |||||
| CFARRank *int `json:"cfar_rank"` | |||||
| CFARScaleDb *float64 `json:"cfar_scale_db"` | |||||
| CFARWrapAround *bool `json:"cfar_wrap_around"` | |||||
| EdgeMarginDb *float64 `json:"edge_margin_db"` | |||||
| MergeGapHz *float64 `json:"merge_gap_hz"` | |||||
| ClassHistorySize *int `json:"class_history_size"` | ClassHistorySize *int `json:"class_history_size"` | ||||
| ClassSwitchRatio *float64 `json:"class_switch_ratio"` | ClassSwitchRatio *float64 `json:"class_switch_ratio"` | ||||
| } | } | ||||
| @@ -179,6 +179,9 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) { | |||||
| if update.Pipeline.Mode != nil { | if update.Pipeline.Mode != nil { | ||||
| next.Pipeline.Mode = *update.Pipeline.Mode | next.Pipeline.Mode = *update.Pipeline.Mode | ||||
| } | } | ||||
| if update.Pipeline.Profile != nil { | |||||
| next.Pipeline.Profile = *update.Pipeline.Profile | |||||
| } | |||||
| if update.Pipeline.Intent != nil { | if update.Pipeline.Intent != nil { | ||||
| next.Pipeline.Goals.Intent = *update.Pipeline.Intent | next.Pipeline.Goals.Intent = *update.Pipeline.Intent | ||||
| } | } | ||||
| @@ -53,8 +53,8 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| AutoDecodeClasses: &autoDecode, | AutoDecodeClasses: &autoDecode, | ||||
| }, | }, | ||||
| Surveillance: &SurveillanceUpdate{FrameRate: &survFPS, DisplayBins: &displayBins, DisplayFPS: &displayFPS}, | Surveillance: &SurveillanceUpdate{FrameRate: &survFPS, DisplayBins: &displayBins, DisplayFPS: &displayFPS}, | ||||
| Refinement: &RefinementUpdate{MinSpanHz: &minSpan, MaxSpanHz: &maxSpan, AutoSpan: &autoSpan}, | |||||
| Resources: &ResourcesUpdate{MaxRefinementJobs: &maxRefJobs, MaxDecodeJobs: &maxDecode, DecisionHoldMs: &decisionHold}, | |||||
| Refinement: &RefinementUpdate{MinSpanHz: &minSpan, MaxSpanHz: &maxSpan, AutoSpan: &autoSpan}, | |||||
| Resources: &ResourcesUpdate{MaxRefinementJobs: &maxRefJobs, MaxDecodeJobs: &maxDecode, DecisionHoldMs: &decisionHold}, | |||||
| Detector: &DetectorUpdate{ | Detector: &DetectorUpdate{ | ||||
| ThresholdDb: &threshold, | ThresholdDb: &threshold, | ||||
| CFARMode: &cfarMode, | CFARMode: &cfarMode, | ||||
| @@ -107,6 +107,9 @@ func TestApplyConfigUpdate(t *testing.T) { | |||||
| if updated.Pipeline.Mode != mode { | if updated.Pipeline.Mode != mode { | ||||
| t.Fatalf("pipeline mode: %v", updated.Pipeline.Mode) | t.Fatalf("pipeline mode: %v", updated.Pipeline.Mode) | ||||
| } | } | ||||
| if updated.Pipeline.Profile != profile { | |||||
| t.Fatalf("pipeline profile: %v", updated.Pipeline.Profile) | |||||
| } | |||||
| if updated.Pipeline.Goals.Intent != intent { | if updated.Pipeline.Goals.Intent != intent { | ||||
| t.Fatalf("pipeline intent: %v", updated.Pipeline.Goals.Intent) | t.Fatalf("pipeline intent: %v", updated.Pipeline.Goals.Intent) | ||||
| } | } | ||||