Explorar el Código

Add per-window outcome summaries for admission pressure

master
Jan Svabenik hace 4 horas
padre
commit
8545b62516
Se han modificado 4 ficheros con 289 adiciones y 4 borrados
  1. +1
    -1
      cmd/sdrd/dsp_loop.go
  2. +1
    -1
      cmd/sdrd/http_handlers.go
  3. +196
    -2
      cmd/sdrd/window_summary.go
  4. +91
    -0
      cmd/sdrd/window_summary_test.go

+ 1
- 1
cmd/sdrd/dsp_loop.go Ver fichero

@@ -111,7 +111,7 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det *
}
var debugInfo *SpectrumDebug
plan := state.refinement.Input.Plan
windowSummary := buildWindowSummary(plan, state.refinement.Input.Windows, state.surveillance.Candidates)
windowSummary := buildWindowSummary(plan, state.refinement.Input.Windows, state.surveillance.Candidates, state.refinement.Input.WorkItems, state.refinement.Result.Decisions)
var windowStats *RefinementWindowStats
var monitorSummary []pipeline.MonitorWindowStats
if windowSummary != nil {


+ 1
- 1
cmd/sdrd/http_handlers.go Ver fichero

@@ -165,7 +165,7 @@ func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime
mux.HandleFunc("/api/refinement", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
snap := phaseSnap.Snapshot()
windowSummary := buildWindowSummary(snap.refinement.Input.Plan, snap.refinement.Input.Windows, snap.surveillance.Candidates)
windowSummary := buildWindowSummary(snap.refinement.Input.Plan, snap.refinement.Input.Windows, snap.surveillance.Candidates, snap.refinement.Input.WorkItems, snap.refinement.Result.Decisions)
var windowStats *RefinementWindowStats
var monitorSummary []pipeline.MonitorWindowStats
if windowSummary != nil {


+ 196
- 2
cmd/sdrd/window_summary.go Ver fichero

@@ -2,6 +2,7 @@ package main

import (
"sort"
"strings"

"sdr-wideband-suite/internal/pipeline"
)
@@ -9,17 +10,84 @@ import (
type WindowSummary struct {
Refinement *RefinementWindowStats `json:"refinement,omitempty"`
MonitorWindows []pipeline.MonitorWindowStats `json:"monitor_windows,omitempty"`
Outcomes *WindowOutcomeSummary `json:"outcomes,omitempty"`
}

func buildWindowSummary(plan pipeline.RefinementPlan, refinementWindows []pipeline.RefinementWindow, candidates []pipeline.Candidate) *WindowSummary {
type WindowOutcomeSummary struct {
Windows []MonitorWindowOutcome `json:"windows,omitempty"`
Zones []MonitorZoneOutcome `json:"zones,omitempty"`
}

type MonitorWindowOutcome struct {
Index int `json:"index"`
Label string `json:"label,omitempty"`
Zone string `json:"zone,omitempty"`
Refinement OutcomeCounts `json:"refinement,omitempty"`
Record OutcomeCounts `json:"record,omitempty"`
Decode OutcomeCounts `json:"decode,omitempty"`
}

type MonitorZoneOutcome struct {
Zone string `json:"zone"`
Refinement OutcomeCounts `json:"refinement,omitempty"`
Record OutcomeCounts `json:"record,omitempty"`
Decode OutcomeCounts `json:"decode,omitempty"`
}

type OutcomeCounts struct {
Admit int `json:"admit,omitempty"`
Hold int `json:"hold,omitempty"`
Displace int `json:"displace,omitempty"`
Defer int `json:"defer,omitempty"`
Drop int `json:"drop,omitempty"`
Enabled int `json:"enabled,omitempty"`
}

func (o *OutcomeCounts) addClass(class string) {
switch class {
case pipeline.AdmissionClassAdmit:
o.Admit++
case pipeline.AdmissionClassHold:
o.Hold++
case pipeline.AdmissionClassDisplace:
o.Displace++
case pipeline.AdmissionClassDefer:
o.Defer++
case pipeline.AdmissionClassDrop:
o.Drop++
}
}

func (o *OutcomeCounts) addEnabled(enabled bool) {
if enabled {
o.Enabled++
}
}

func (o OutcomeCounts) hasAny() bool {
return o.Admit > 0 || o.Hold > 0 || o.Displace > 0 || o.Defer > 0 || o.Drop > 0 || o.Enabled > 0
}

func (o *OutcomeCounts) addTotals(in OutcomeCounts) {
o.Admit += in.Admit
o.Hold += in.Hold
o.Displace += in.Displace
o.Defer += in.Defer
o.Drop += in.Drop
o.Enabled += in.Enabled
}

func buildWindowSummary(plan pipeline.RefinementPlan, refinementWindows []pipeline.RefinementWindow, candidates []pipeline.Candidate, workItems []pipeline.RefinementWorkItem, decisions []pipeline.SignalDecision) *WindowSummary {
refinementStats := buildWindowStats(refinementWindows)
monitorSummary := buildMonitorWindowSummary(plan.MonitorWindows, plan.MonitorWindowStats, candidates)
if refinementStats == nil && len(monitorSummary) == 0 {
outcomes := buildWindowOutcomeSummary(plan.MonitorWindows, plan.MonitorWindowStats, workItems, decisions)
if refinementStats == nil && len(monitorSummary) == 0 && outcomes == nil {
return nil
}
return &WindowSummary{
Refinement: refinementStats,
MonitorWindows: monitorSummary,
Outcomes: outcomes,
}
}

@@ -86,6 +154,132 @@ func buildMonitorWindowSummary(windows []pipeline.MonitorWindow, stats []pipelin
return summary
}

func buildWindowOutcomeSummary(windows []pipeline.MonitorWindow, stats []pipeline.MonitorWindowStats, workItems []pipeline.RefinementWorkItem, decisions []pipeline.SignalDecision) *WindowOutcomeSummary {
base := windows
if len(base) == 0 && len(stats) > 0 {
base = monitorWindowsFromStats(stats)
}
if len(base) == 0 {
return nil
}
outcomes := make([]MonitorWindowOutcome, 0, len(base))
index := make(map[int]int, len(base))
for _, win := range base {
outcomes = append(outcomes, MonitorWindowOutcome{
Index: win.Index,
Label: win.Label,
Zone: win.Zone,
})
index[win.Index] = len(outcomes) - 1
}
windowsForMatch := base
if len(workItems) > 0 {
for _, item := range workItems {
class := outcomeClassForWorkItem(item)
if class == "" {
continue
}
matches := item.Candidate.MonitorMatches
if len(matches) == 0 {
matches = pipeline.MonitorWindowMatchesForCandidate(windowsForMatch, item.Candidate)
}
for _, match := range matches {
if idx, ok := index[match.Index]; ok {
outcomes[idx].Refinement.addClass(class)
}
}
}
}
if len(decisions) > 0 {
for _, decision := range decisions {
if match := decisionWindowMatch(decision, "record"); match != nil {
if idx, ok := index[match.Index]; ok {
outcomes[idx].Record.addEnabled(decision.ShouldRecord)
if decision.RecordAdmission != nil {
outcomes[idx].Record.addClass(decision.RecordAdmission.Class)
}
}
}
if match := decisionWindowMatch(decision, "decode"); match != nil {
if idx, ok := index[match.Index]; ok {
outcomes[idx].Decode.addEnabled(decision.ShouldAutoDecode)
if decision.DecodeAdmission != nil {
outcomes[idx].Decode.addClass(decision.DecodeAdmission.Class)
}
}
}
}
}
hasOutcome := false
for i := range outcomes {
if outcomes[i].Refinement.hasAny() || outcomes[i].Record.hasAny() || outcomes[i].Decode.hasAny() {
hasOutcome = true
break
}
}
if !hasOutcome {
return nil
}
sort.Slice(outcomes, func(i, j int) bool {
return outcomes[i].Index < outcomes[j].Index
})
zoneIndex := map[string]int{}
zones := make([]MonitorZoneOutcome, 0, len(outcomes))
for _, outcome := range outcomes {
zone := strings.TrimSpace(outcome.Zone)
if zone == "" {
continue
}
idx, ok := zoneIndex[zone]
if !ok {
zones = append(zones, MonitorZoneOutcome{Zone: zone})
idx = len(zones) - 1
zoneIndex[zone] = idx
}
zones[idx].Refinement.addTotals(outcome.Refinement)
zones[idx].Record.addTotals(outcome.Record)
zones[idx].Decode.addTotals(outcome.Decode)
}
if len(zones) > 0 {
sort.Slice(zones, func(i, j int) bool {
return zones[i].Zone < zones[j].Zone
})
}
return &WindowOutcomeSummary{Windows: outcomes, Zones: zones}
}

func outcomeClassForWorkItem(item pipeline.RefinementWorkItem) string {
if item.Admission != nil && item.Admission.Class != "" {
return item.Admission.Class
}
switch item.Status {
case pipeline.RefinementStatusAdmitted, pipeline.RefinementStatusRunning, pipeline.RefinementStatusCompleted:
return pipeline.AdmissionClassAdmit
case pipeline.RefinementStatusDisplaced:
return pipeline.AdmissionClassDisplace
case pipeline.RefinementStatusSkipped:
return pipeline.AdmissionClassDefer
case pipeline.RefinementStatusDropped:
return pipeline.AdmissionClassDrop
default:
return ""
}
}

func decisionWindowMatch(decision pipeline.SignalDecision, action string) *pipeline.MonitorWindowMatch {
switch strings.ToLower(strings.TrimSpace(action)) {
case "record":
if decision.RecordWindow != nil {
return decision.RecordWindow
}
case "decode":
if decision.DecodeWindow != nil {
return decision.DecodeWindow
}
}
return decision.MonitorDetail
}

func monitorWindowsFromStats(stats []pipeline.MonitorWindowStats) []pipeline.MonitorWindow {
if len(stats) == 0 {
return nil


+ 91
- 0
cmd/sdrd/window_summary_test.go Ver fichero

@@ -45,3 +45,94 @@ func TestBuildMonitorWindowSummaryPreservesStatsCounts(t *testing.T) {
t.Fatalf("expected planned to stay at 1, got %d", summary[0].Planned)
}
}

func TestBuildWindowOutcomeSummaryTracksPressureByWindowAndZone(t *testing.T) {
windows := []pipeline.MonitorWindow{
{Index: 0, Label: "alpha", Zone: "north", StartHz: 100, EndHz: 200},
{Index: 1, Label: "beta", Zone: "south", StartHz: 300, EndHz: 400},
}
match0 := pipeline.MonitorWindowMatch{Index: 0, Label: "alpha", Zone: "north"}
match1 := pipeline.MonitorWindowMatch{Index: 1, Label: "beta", Zone: "south"}
workItems := []pipeline.RefinementWorkItem{
{
Candidate: pipeline.Candidate{ID: 1, MonitorMatches: []pipeline.MonitorWindowMatch{match0}},
Admission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassAdmit},
},
{
Candidate: pipeline.Candidate{ID: 2, MonitorMatches: []pipeline.MonitorWindowMatch{match0}},
Admission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassHold},
},
{
Candidate: pipeline.Candidate{ID: 3, MonitorMatches: []pipeline.MonitorWindowMatch{match1}},
Admission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassDisplace},
},
{
Candidate: pipeline.Candidate{ID: 4, MonitorMatches: []pipeline.MonitorWindowMatch{match1}},
Admission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassDefer},
},
}
decisions := []pipeline.SignalDecision{
{
Candidate: pipeline.Candidate{ID: 1},
ShouldRecord: true,
RecordWindow: &match0,
RecordAdmission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassAdmit},
},
{
Candidate: pipeline.Candidate{ID: 2},
ShouldRecord: false,
RecordWindow: &match0,
RecordAdmission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassDisplace},
},
{
Candidate: pipeline.Candidate{ID: 3},
ShouldAutoDecode: true,
DecodeWindow: &match1,
DecodeAdmission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassHold},
},
{
Candidate: pipeline.Candidate{ID: 4},
ShouldAutoDecode: false,
DecodeWindow: &match1,
DecodeAdmission: &pipeline.PriorityAdmission{Class: pipeline.AdmissionClassDefer},
},
}
summary := buildWindowSummary(pipeline.RefinementPlan{MonitorWindows: windows}, nil, nil, workItems, decisions)
if summary == nil || summary.Outcomes == nil {
t.Fatalf("expected outcome summary to be populated")
}
if len(summary.Outcomes.Windows) != 2 {
t.Fatalf("expected 2 window outcomes, got %d", len(summary.Outcomes.Windows))
}
win0 := summary.Outcomes.Windows[0]
win1 := summary.Outcomes.Windows[1]
if win0.Refinement.Admit != 1 || win0.Refinement.Hold != 1 {
t.Fatalf("unexpected refinement outcomes for window 0: %+v", win0.Refinement)
}
if win0.Record.Admit != 1 || win0.Record.Displace != 1 || win0.Record.Enabled != 1 {
t.Fatalf("unexpected record outcomes for window 0: %+v", win0.Record)
}
if win1.Refinement.Displace != 1 || win1.Refinement.Defer != 1 {
t.Fatalf("unexpected refinement outcomes for window 1: %+v", win1.Refinement)
}
if win1.Decode.Hold != 1 || win1.Decode.Defer != 1 || win1.Decode.Enabled != 1 {
t.Fatalf("unexpected decode outcomes for window 1: %+v", win1.Decode)
}
if len(summary.Outcomes.Zones) != 2 {
t.Fatalf("expected 2 zone outcomes, got %d", len(summary.Outcomes.Zones))
}
for _, zone := range summary.Outcomes.Zones {
switch zone.Zone {
case "north":
if zone.Refinement.Admit != 1 || zone.Refinement.Hold != 1 {
t.Fatalf("unexpected north zone refinement: %+v", zone.Refinement)
}
case "south":
if zone.Refinement.Displace != 1 || zone.Refinement.Defer != 1 {
t.Fatalf("unexpected south zone refinement: %+v", zone.Refinement)
}
default:
t.Fatalf("unexpected zone %q", zone.Zone)
}
}
}

Cargando…
Cancelar
Guardar