Sfoglia il codice sorgente

Expand pipeline levels, refinement planning, budgets, and profiles

master
Jan Svabenik 16 ore fa
parent
commit
804eed45fa
19 ha cambiato i file con 639 aggiunte e 66 eliminazioni
  1. +8
    -5
      README.md
  2. +14
    -3
      cmd/sdrd/decision_budget.go
  3. +3
    -1
      cmd/sdrd/decision_budget_test.go
  4. +20
    -0
      cmd/sdrd/dsp_loop.go
  5. +10
    -0
      cmd/sdrd/http_handlers.go
  6. +54
    -8
      cmd/sdrd/pipeline_runtime.go
  7. +3
    -2
      cmd/sdrd/pipeline_runtime_test.go
  8. +16
    -0
      cmd/sdrd/types.go
  9. +150
    -6
      internal/config/config.go
  10. +66
    -0
      internal/pipeline/budget.go
  11. +26
    -0
      internal/pipeline/phases.go
  12. +60
    -2
      internal/pipeline/policy.go
  13. +13
    -0
      internal/pipeline/policy_test.go
  14. +13
    -2
      internal/pipeline/profile.go
  15. +8
    -5
      internal/pipeline/profile_test.go
  16. +123
    -13
      internal/pipeline/scheduler.go
  17. +27
    -0
      internal/pipeline/scheduler_test.go
  18. +20
    -17
      internal/runtime/runtime.go
  19. +5
    -2
      internal/runtime/runtime_test.go

+ 8
- 5
README.md Vedi File

@@ -56,6 +56,7 @@ Edit `config.yaml` (autosave goes to `config.autosave.yaml`).

### New phase-1 pipeline fields
- `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
- `intent`
- `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`
- `surveillance.analysis_fft_size` — analysis FFT size used by the surveillance layer
- `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_fps` — preferred presentation cadence for clients/UI
- `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
- `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_recording_streams` — recording/streaming budget hint
- `resources.max_decode_jobs` — decode budget hint
@@ -155,7 +158,7 @@ go build -tags sdrplay ./cmd/sdrd
- `GET /api/gpu`
- `GET /api/pipeline/policy`
- `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
- `GET /api/signals` → current live signals


+ 14
- 3
cmd/sdrd/decision_budget.go Vedi File

@@ -16,6 +16,11 @@ type decisionQueueStats struct {
DecodeActive int `json:"decode_active"`
RecordOldestS float64 `json:"record_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 {
@@ -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 {
return decisionQueueStats{}
}
hold := time.Duration(budget.HoldMs) * time.Millisecond
recSeen := map[int64]bool{}
decSeen := map[int64]bool{}
for i := range decisions {
@@ -93,8 +99,8 @@ func (dq *decisionQueues) Apply(decisions []pipeline.SignalDecision, maxRecord i
purgeExpired(dq.recordHold, 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{
RecordQueued: len(dq.record),
@@ -105,6 +111,9 @@ func (dq *decisionQueues) Apply(decisions []pipeline.SignalDecision, maxRecord i
DecodeActive: len(dq.decodeHold),
RecordOldestS: oldestAge(dq.record, now),
DecodeOldestS: oldestAge(dq.decode, now),
RecordBudget: budget.Record.Max,
DecodeBudget: budget.Decode.Max,
HoldMs: budget.HoldMs,
}

for i := range decisions {
@@ -113,6 +122,7 @@ func (dq *decisionQueues) Apply(decisions []pipeline.SignalDecision, maxRecord i
if _, ok := recSelected[id]; !ok {
decisions[i].ShouldRecord = false
decisions[i].Reason = "queued: record budget"
stats.RecordDropped++
}
}
if decisions[i].ShouldAutoDecode {
@@ -121,6 +131,7 @@ func (dq *decisionQueues) Apply(decisions []pipeline.SignalDecision, maxRecord i
if decisions[i].Reason == "" {
decisions[i].Reason = "queued: decode budget"
}
stats.DecodeDropped++
}
}
}


+ 3
- 1
cmd/sdrd/decision_budget_test.go Vedi File

@@ -14,7 +14,9 @@ func TestEnforceDecisionBudgets(t *testing.T) {
{Candidate: pipeline.Candidate{ID: 3, SNRDb: 10}, ShouldRecord: true, ShouldAutoDecode: false},
}
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 {
t.Fatalf("unexpected counts: record=%d decode=%d", stats.RecordSelected, stats.DecodeSelected)
}


+ 20
- 0
cmd/sdrd/dsp_loop.go Vedi File

@@ -93,6 +93,8 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det *
state.queueStats = rt.queueStats
state.presentation = pipeline.AnalysisLevel{
Name: "presentation",
Role: "presentation",
Truth: "presentation",
SampleRate: rt.cfg.SampleRate,
FFTSize: rt.cfg.Surveillance.DisplayBins,
CenterHz: rt.cfg.CenterHz,
@@ -147,6 +149,24 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det *
if hasWindows {
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})
}


+ 10
- 0
cmd/sdrd/http_handlers.go Vedi File

@@ -137,9 +137,13 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime
w.Header().Set("Content-Type", "application/json")
cfg := cfgManager.Snapshot()
policy := pipeline.PolicyFromConfig(cfg)
budget := pipeline.BudgetModelFromPolicy(policy)
recommend := map[string]any{
"profile": policy.Profile,
"mode": policy.Mode,
"intent": policy.Intent,
"surveillance_strategy": policy.SurveillanceStrategy,
"refinement_strategy": policy.RefinementStrategy,
"monitor_center_hz": policy.MonitorCenterHz,
"monitor_start_hz": policy.MonitorStartHz,
"monitor_end_hz": policy.MonitorEndHz,
@@ -151,6 +155,7 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime
"refinement_auto_span": policy.RefinementAutoSpan,
"refinement_min_span_hz": policy.RefinementMinSpanHz,
"refinement_max_span_hz": policy.RefinementMaxSpanHz,
"budgets": budget,
}
_ = json.NewEncoder(w).Encode(recommend)
})
@@ -163,6 +168,11 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime
"windows": snap.refinement.Input.Windows,
"window_stats": windowStats,
"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),
"scheduled": len(snap.refinement.Input.Scheduled),
"signals": len(snap.refinement.Result.Signals),


+ 54
- 8
cmd/sdrd/pipeline_runtime.go Vedi File

@@ -241,6 +241,8 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S
scheduled := pipeline.ScheduleCandidates(candidates, policy)
level := pipeline.AnalysisLevel{
Name: "surveillance",
Role: "surveillance",
Truth: "surveillance",
SampleRate: rt.cfg.SampleRate,
FFTSize: rt.cfg.Surveillance.AnalysisFFTSize,
CenterHz: rt.cfg.CenterHz,
@@ -257,6 +259,8 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S
}
lowLevel := pipeline.AnalysisLevel{
Name: "surveillance-lowres",
Role: "surveillance-lowres",
Truth: "surveillance",
SampleRate: lowRate,
FFTSize: lowFFT,
CenterHz: rt.cfg.CenterHz,
@@ -265,17 +269,20 @@ func (rt *dspRuntime) buildSurveillanceResult(art *spectrumArtifacts) pipeline.S
}
displayLevel := pipeline.AnalysisLevel{
Name: "presentation",
Role: "presentation",
Truth: "presentation",
SampleRate: rt.cfg.SampleRate,
FFTSize: rt.cfg.Surveillance.DisplayBins,
CenterHz: rt.cfg.CenterHz,
SpanHz: spanForPolicy(policy, float64(rt.cfg.SampleRate)),
Source: "display",
}
levels := surveillanceLevels(policy, level, lowLevel)
levels, context := surveillanceLevels(policy, level, lowLevel, displayLevel)
return pipeline.SurveillanceResult{
Level: level,
Levels: levels,
DisplayLevel: displayLevel,
Context: context,
Candidates: candidates,
Scheduled: scheduled,
Finished: art.finished,
@@ -292,9 +299,24 @@ func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult) pip
if len(scheduled) == 0 && len(plan.Selected) > 0 {
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))
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))
if _, maxSpan, ok := windowSpanBounds(windows); ok {
@@ -302,16 +324,33 @@ func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult) pip
}
level := pipeline.AnalysisLevel{
Name: "refinement",
Role: "refinement",
Truth: "refinement",
SampleRate: rt.cfg.SampleRate,
FFTSize: rt.cfg.FFTSize,
CenterHz: rt.cfg.CenterHz,
SpanHz: levelSpan,
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{
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...),
Scheduled: scheduled,
WorkItems: workItems,
Plan: plan,
Windows: windows,
SampleRate: rt.cfg.SampleRate,
@@ -319,8 +358,12 @@ func (rt *dspRuntime) buildRefinementInput(surv pipeline.SurveillanceResult) pip
CenterHz: rt.cfg.CenterHz,
Source: "surveillance-detector",
}
input.Context.Refinement = level
input.Context.Detail = detailLevel
if !policy.RefinementEnabled {
input.Scheduled = nil
input.WorkItems = nil
input.Request.Reason = pipeline.RefinementReasonDisabled
}
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
summary := summarizeDecisions(decisions)
if rec != nil {
@@ -531,16 +572,21 @@ func windowSpanBounds(windows []pipeline.RefinementWindow) (float64, float64, bo
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}
context := pipeline.AnalysisContext{
Surveillance: primary,
Presentation: presentation,
}
strategy := strings.ToLower(strings.TrimSpace(policy.SurveillanceStrategy))
switch strategy {
case "multi-res", "multi-resolution", "multi", "multi_res":
if secondary.SampleRate != primary.SampleRate || secondary.FFTSize != primary.FFTSize {
levels = append(levels, secondary)
context.Derived = append(context.Derived, secondary)
}
}
return levels
return levels, context
}

func sameIQBuffer(a []complex64, b []complex64) bool {


+ 3
- 2
cmd/sdrd/pipeline_runtime_test.go Vedi File

@@ -47,12 +47,13 @@ func TestSurveillanceLevelsRespectStrategy(t *testing.T) {
policy := pipeline.Policy{SurveillanceStrategy: "single-resolution"}
primary := pipeline.AnalysisLevel{Name: "primary", SampleRate: 2000000, FFTSize: 2048}
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 {
t.Fatalf("expected single level for single-resolution, got %d", len(levels))
}
policy.SurveillanceStrategy = "multi-res"
levels = surveillanceLevels(policy, primary, secondary)
levels, _ = surveillanceLevels(policy, primary, secondary, presentation)
if len(levels) != 2 {
t.Fatalf("expected secondary level for multi-res, got %d", len(levels))
}


+ 16
- 0
cmd/sdrd/types.go Vedi File

@@ -19,6 +19,8 @@ type SpectrumDebug struct {
Scores []map[string]any `json:"scores,omitempty"`
RefinementPlan *pipeline.RefinementPlan `json:"refinement_plan,omitempty"`
Windows *RefinementWindowStats `json:"refinement_windows,omitempty"`
Refinement *RefinementDebug `json:"refinement,omitempty"`
Decisions *DecisionDebug `json:"decisions,omitempty"`
}

type RefinementWindowStats struct {
@@ -29,6 +31,20 @@ type RefinementWindowStats struct {
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 {
Timestamp int64 `json:"ts"`
CenterHz float64 `json:"center_hz"`


+ 150
- 6
internal/config/config.go Vedi File

@@ -81,8 +81,9 @@ type PipelineGoalConfig 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 {
@@ -190,10 +191,153 @@ func Default() Config {
DecisionHoldMs: 2000,
},
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{
ThresholdDb: -20,


+ 66
- 0
internal/pipeline/budget.go Vedi File

@@ -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
}

+ 26
- 0
internal/pipeline/phases.go Vedi File

@@ -4,6 +4,8 @@ import "sdr-wideband-suite/internal/detector"

type AnalysisLevel struct {
Name string `json:"name"`
Role string `json:"role,omitempty"`
Truth string `json:"truth,omitempty"`
SampleRate int `json:"sample_rate"`
FFTSize int `json:"fft_size"`
CenterHz float64 `json:"center_hz"`
@@ -11,6 +13,14 @@ type AnalysisLevel struct {
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 {
Level AnalysisLevel `json:"level"`
Levels []AnalysisLevel `json:"levels,omitempty"`
@@ -21,29 +31,45 @@ type SurveillanceResult struct {
NoiseFloor float64 `json:"noise_floor"`
Thresholds []float64 `json:"thresholds,omitempty"`
DisplayLevel AnalysisLevel `json:"display_level"`
Context AnalysisContext `json:"context,omitempty"`
}

type RefinementPlan struct {
TotalCandidates int `json:"total_candidates"`
MinCandidateSNRDb float64 `json:"min_candidate_snr_db"`
Budget int `json:"budget"`
Strategy string `json:"strategy,omitempty"`
StrategyReason string `json:"strategy_reason,omitempty"`
MonitorStartHz float64 `json:"monitor_start_hz,omitempty"`
MonitorEndHz float64 `json:"monitor_end_hz,omitempty"`
MonitorSpanHz float64 `json:"monitor_span_hz,omitempty"`
DroppedByMonitor int `json:"dropped_by_monitor"`
DroppedBySNR int `json:"dropped_by_snr"`
DroppedByBudget int `json:"dropped_by_budget"`
ScoreModel RefinementScoreModel `json:"score_model,omitempty"`
PriorityMin float64 `json:"priority_min,omitempty"`
PriorityMax float64 `json:"priority_max,omitempty"`
PriorityAvg float64 `json:"priority_avg,omitempty"`
PriorityCutoff float64 `json:"priority_cutoff,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 {
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"`
Scheduled []ScheduledCandidate `json:"scheduled,omitempty"`
WorkItems []RefinementWorkItem `json:"work_items,omitempty"`
Plan RefinementPlan `json:"plan,omitempty"`
Windows []RefinementWindow `json:"windows,omitempty"`
SampleRate int `json:"sample_rate"`


+ 60
- 2
internal/pipeline/policy.go Vedi File

@@ -4,6 +4,7 @@ import "sdr-wideband-suite/internal/config"

type Policy struct {
Mode string `json:"mode"`
Profile string `json:"profile,omitempty"`
Intent string `json:"intent"`
MonitorCenterHz float64 `json:"monitor_center_hz,omitempty"`
MonitorStartHz float64 `json:"monitor_start_hz,omitempty"`
@@ -17,6 +18,7 @@ type Policy struct {
DisplayBins int `json:"display_bins"`
DisplayFPS int `json:"display_fps"`
SurveillanceStrategy string `json:"surveillance_strategy"`
RefinementStrategy string `json:"refinement_strategy,omitempty"`
RefinementEnabled bool `json:"refinement_enabled"`
MaxRefinementJobs int `json:"max_refinement_jobs"`
RefinementMaxConcurrent int `json:"refinement_max_concurrent"`
@@ -25,6 +27,7 @@ type Policy struct {
RefinementMaxSpanHz float64 `json:"refinement_max_span_hz"`
RefinementAutoSpan bool `json:"refinement_auto_span"`
PreferGPU bool `json:"prefer_gpu"`
MaxRecordingStreams int `json:"max_recording_streams"`
MaxDecodeJobs int `json:"max_decode_jobs"`
DecisionHoldMs int `json:"decision_hold_ms"`
}
@@ -32,6 +35,7 @@ type Policy struct {
func PolicyFromConfig(cfg config.Config) Policy {
p := Policy{
Mode: cfg.Pipeline.Mode,
Profile: cfg.Pipeline.Profile,
Intent: cfg.Pipeline.Goals.Intent,
MonitorCenterHz: cfg.CenterHz,
MonitorStartHz: cfg.Pipeline.Goals.MonitorStartHz,
@@ -53,9 +57,11 @@ func PolicyFromConfig(cfg config.Config) Policy {
RefinementMaxSpanHz: cfg.Refinement.MaxSpanHz,
RefinementAutoSpan: config.BoolValue(cfg.Refinement.AutoSpan, true),
PreferGPU: cfg.Resources.PreferGPU,
MaxRecordingStreams: cfg.Resources.MaxRecordingStreams,
MaxDecodeJobs: cfg.Resources.MaxDecodeJobs,
DecisionHoldMs: cfg.Resources.DecisionHoldMs,
}
p.RefinementStrategy, _ = refinementStrategy(p)
if p.MonitorSpanHz <= 0 && p.MonitorStartHz != 0 && p.MonitorEndHz != 0 && 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 == "" {
return
}
cfg.Pipeline.Profile = name
if prof, ok := ResolveProfile(*cfg, name); ok {
MergeProfile(cfg, prof)
}
switch name {
case "legacy":
cfg.Pipeline.Mode = "legacy"
@@ -78,7 +88,7 @@ func ApplyNamedProfile(cfg *config.Config, name string) {
case "wideband-balanced":
cfg.Pipeline.Mode = "wideband-balanced"
cfg.Pipeline.Goals.Intent = "wideband-surveillance"
cfg.Surveillance.Strategy = "single-resolution"
cfg.Surveillance.Strategy = "multi-resolution"
if cfg.Surveillance.AnalysisFFTSize < 4096 {
cfg.Surveillance.AnalysisFFTSize = 4096
}
@@ -101,11 +111,14 @@ func ApplyNamedProfile(cfg *config.Config, name string) {
if cfg.Refinement.MaxSpanHz <= 0 {
cfg.Refinement.MaxSpanHz = 200000
}
if len(cfg.Pipeline.Goals.SignalPriorities) == 0 {
cfg.Pipeline.Goals.SignalPriorities = []string{"digital", "wfm"}
}
cfg.Resources.PreferGPU = true
case "wideband-aggressive":
cfg.Pipeline.Mode = "wideband-aggressive"
cfg.Pipeline.Goals.Intent = "high-density-wideband-surveillance"
cfg.Surveillance.Strategy = "single-resolution"
cfg.Surveillance.Strategy = "multi-resolution"
if cfg.Surveillance.AnalysisFFTSize < 8192 {
cfg.Surveillance.AnalysisFFTSize = 8192
}
@@ -128,6 +141,9 @@ func ApplyNamedProfile(cfg *config.Config, name string) {
if cfg.Refinement.MaxSpanHz <= 0 {
cfg.Refinement.MaxSpanHz = 250000
}
if len(cfg.Pipeline.Goals.SignalPriorities) == 0 {
cfg.Pipeline.Goals.SignalPriorities = []string{"digital", "wfm", "trunk"}
}
cfg.Resources.PreferGPU = true
case "archive":
cfg.Pipeline.Mode = "archive"
@@ -139,9 +155,51 @@ func ApplyNamedProfile(cfg *config.Config, name string) {
if 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 {
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 {
cfg.Resources.MaxDecodeJobs = cfg.Resources.MaxRecordingStreams


+ 13
- 0
internal/pipeline/policy_test.go Vedi File

@@ -12,12 +12,18 @@ func TestApplyNamedProfile(t *testing.T) {
if cfg.Pipeline.Mode != "wideband-balanced" {
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" {
t.Fatalf("intent not applied: %s", cfg.Pipeline.Goals.Intent)
}
if cfg.Surveillance.AnalysisFFTSize < 4096 {
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 {
t.Fatalf("refinement should stay enabled")
}
@@ -42,6 +48,7 @@ func TestPolicyFromConfig(t *testing.T) {
cfg.Resources.MaxRefinementJobs = 5
cfg.Refinement.MinCandidateSNRDb = 2.5
cfg.Resources.PreferGPU = true
cfg.Resources.MaxRecordingStreams = 7
p := PolicyFromConfig(cfg)
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)
@@ -55,4 +62,10 @@ func TestPolicyFromConfig(t *testing.T) {
if !p.RefinementEnabled || p.MaxRefinementJobs != 5 || p.MinCandidateSNRDb != 2.5 || !p.PreferGPU {
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")
}
}

+ 13
- 2
internal/pipeline/profile.go Vedi File

@@ -1,10 +1,15 @@
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) {
name = strings.ToLower(strings.TrimSpace(name))
for _, p := range cfg.Profiles {
if p.Name == name {
if strings.ToLower(strings.TrimSpace(p.Name)) == name {
return p, true
}
}
@@ -15,8 +20,14 @@ func MergeProfile(cfg *config.Config, profile config.ProfileConfig) {
if cfg == nil {
return
}
if profile.Name != "" {
cfg.Pipeline.Profile = profile.Name
}
if profile.Pipeline != nil {
cfg.Pipeline = *profile.Pipeline
if profile.Name != "" {
cfg.Pipeline.Profile = profile.Name
}
}
if profile.Surveillance != nil {
cfg.Surveillance = *profile.Surveillance


+ 8
- 5
internal/pipeline/profile_test.go Vedi File

@@ -9,12 +9,12 @@ import (
func TestResolveAndMergeProfile(t *testing.T) {
cfg := config.Default()
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"},
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")
if !ok {
@@ -24,6 +24,9 @@ func TestResolveAndMergeProfile(t *testing.T) {
if cfg.Pipeline.Mode != "custom" || cfg.Pipeline.Goals.Intent != "custom-intent" {
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 {
t.Fatalf("surveillance not merged into legacy fields: fft=%d fps=%d", cfg.FFTSize, cfg.FrameRate)
}


+ 123
- 13
internal/pipeline/scheduler.go Vedi File

@@ -1,32 +1,73 @@
package pipeline

import "sort"
import (
"sort"
"strings"
)

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"`
BandwidthScore float64 `json:"bandwidth_score"`
PeakScore float64 `json:"peak_score"`
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.
// Current heuristic is intentionally simple and deterministic; later phases can add
// richer scoring (novelty, persistence, profile-aware band priorities, decoder value).
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{
TotalCandidates: len(candidates),
MinCandidateSNRDb: policy.MinCandidateSNRDb,
Budget: budget,
Strategy: strategy,
StrategyReason: strategyReason,
}
if start, end, ok := monitorBounds(policy); ok {
plan.MonitorStartHz = start
@@ -39,14 +80,31 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan {
return plan
}
snrWeight, bwWeight, peakWeight := refinementIntentWeights(policy.Intent)
scoreModel := RefinementScoreModel{
SNRWeight: snrWeight,
BandwidthWeight: bwWeight,
PeakWeight: peakWeight,
}
plan.ScoreModel = scoreModel
scored := make([]ScheduledCandidate, 0, len(candidates))
workItems := make([]RefinementWorkItem, 0, len(candidates))
for _, c := range candidates {
if !candidateInMonitor(policy, c) {
plan.DroppedByMonitor++
workItems = append(workItems, RefinementWorkItem{
Candidate: c,
Status: RefinementStatusDropped,
Reason: RefinementReasonMonitorGate,
})
continue
}
if c.SNRDb < policy.MinCandidateSNRDb {
plan.DroppedBySNR++
workItems = append(workItems, RefinementWorkItem{
Candidate: c,
Status: RefinementStatusDropped,
Reason: RefinementReasonBelowSNR,
})
continue
}
snrScore := c.SNRDb * snrWeight
@@ -60,15 +118,28 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan {
peakScore = (c.PeakDb / 20.0) * peakWeight
}
priority := snrScore + bwScore + peakScore + policyBoost
scored = append(scored, ScheduledCandidate{
Candidate: c,
Priority: priority,
Breakdown: &PriorityBreakdown{
score := &RefinementScore{
Total: priority,
Breakdown: RefinementScoreDetails{
SNRScore: snrScore,
BandwidthScore: bwScore,
PeakScore: peakScore,
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 {
@@ -103,6 +174,23 @@ func BuildRefinementPlan(candidates []Candidate, policy Policy) RefinementPlan {
plan.PriorityCutoff = plan.Selected[len(plan.Selected)-1].Priority
}
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
}

@@ -110,6 +198,28 @@ func ScheduleCandidates(candidates []Candidate, policy Policy) []ScheduledCandid
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 {
if a < b {
return a


+ 27
- 0
internal/pipeline/scheduler_test.go Vedi File

@@ -42,6 +42,21 @@ func TestBuildRefinementPlanTracksDrops(t *testing.T) {
if len(plan.Selected) != 1 || plan.Selected[0].Candidate.ID != 2 {
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) {
@@ -139,4 +154,16 @@ func TestBuildRefinementPlanPriorityStats(t *testing.T) {
if plan.Selected[0].Breakdown == nil {
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
}

+ 20
- 17
internal/runtime/runtime.go Vedi File

@@ -63,23 +63,23 @@ type ConfigUpdate 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"`
ClassSwitchRatio *float64 `json:"class_switch_ratio"`
}
@@ -179,6 +179,9 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) {
if update.Pipeline.Mode != nil {
next.Pipeline.Mode = *update.Pipeline.Mode
}
if update.Pipeline.Profile != nil {
next.Pipeline.Profile = *update.Pipeline.Profile
}
if update.Pipeline.Intent != nil {
next.Pipeline.Goals.Intent = *update.Pipeline.Intent
}


+ 5
- 2
internal/runtime/runtime_test.go Vedi File

@@ -53,8 +53,8 @@ func TestApplyConfigUpdate(t *testing.T) {
AutoDecodeClasses: &autoDecode,
},
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{
ThresholdDb: &threshold,
CFARMode: &cfarMode,
@@ -107,6 +107,9 @@ func TestApplyConfigUpdate(t *testing.T) {
if updated.Pipeline.Mode != 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 {
t.Fatalf("pipeline intent: %v", updated.Pipeline.Goals.Intent)
}


Loading…
Annulla
Salva