Przeglądaj źródła

Add window zone biases for record/decode actions

master
Jan Svabenik 5 godzin temu
rodzic
commit
962cf069fd
10 zmienionych plików z 250 dodań i 18 usunięć
  1. +6
    -0
      cmd/sdrd/level_summary.go
  2. +1
    -0
      internal/config/config.go
  3. +64
    -8
      internal/pipeline/decision_queue.go
  4. +40
    -0
      internal/pipeline/decision_queue_test.go
  5. +22
    -10
      internal/pipeline/decisions.go
  6. +47
    -0
      internal/pipeline/monitor_rules.go
  7. +39
    -0
      internal/pipeline/monitor_rules_test.go
  8. +3
    -0
      internal/pipeline/scheduler.go
  9. +9
    -0
      internal/pipeline/types.go
  10. +19
    -0
      internal/runtime/runtime.go

+ 6
- 0
cmd/sdrd/level_summary.go Wyświetl plik

@@ -46,6 +46,7 @@ type CandidateEvidenceStateSummary struct {
type CandidateWindowSummary struct {
Index int `json:"index"`
Label string `json:"label,omitempty"`
Zone string `json:"zone,omitempty"`
Source string `json:"source,omitempty"`
StartHz float64 `json:"start_hz,omitempty"`
EndHz float64 `json:"end_hz,omitempty"`
@@ -53,6 +54,8 @@ type CandidateWindowSummary struct {
SpanHz float64 `json:"span_hz,omitempty"`
Priority float64 `json:"priority,omitempty"`
PriorityBias float64 `json:"priority_bias,omitempty"`
RecordBias float64 `json:"record_bias,omitempty"`
DecodeBias float64 `json:"decode_bias,omitempty"`
AutoRecord bool `json:"auto_record,omitempty"`
AutoDecode bool `json:"auto_decode,omitempty"`
Candidates int `json:"candidates"`
@@ -230,6 +233,7 @@ func buildCandidateWindowSummary(candidates []pipeline.Candidate, windows []pipe
entry := CandidateWindowSummary{
Index: win.Index,
Label: win.Label,
Zone: win.Zone,
Source: win.Source,
StartHz: win.StartHz,
EndHz: win.EndHz,
@@ -237,6 +241,8 @@ func buildCandidateWindowSummary(candidates []pipeline.Candidate, windows []pipe
SpanHz: win.SpanHz,
Priority: win.Priority,
PriorityBias: win.PriorityBias,
RecordBias: win.RecordBias,
DecodeBias: win.DecodeBias,
AutoRecord: win.AutoRecord,
AutoDecode: win.AutoDecode,
}


+ 1
- 0
internal/config/config.go Wyświetl plik

@@ -17,6 +17,7 @@ type Band struct {

type MonitorWindow struct {
Label string `yaml:"label" json:"label"`
Zone string `yaml:"zone" json:"zone"`
StartHz float64 `yaml:"start_hz" json:"start_hz"`
EndHz float64 `yaml:"end_hz" json:"end_hz"`
CenterHz float64 `yaml:"center_hz" json:"center_hz"`


+ 64
- 8
internal/pipeline/decision_queue.go Wyświetl plik

@@ -46,6 +46,7 @@ type queuedDecision struct {
Hint string
Class string
WindowTag string
WindowZone string
WindowBias float64
FirstSeen time.Time
LastSeen time.Time
@@ -65,6 +66,7 @@ type queueSelection struct {
familyRanks map[int64]int
tierFloors map[int64]string
windowTags map[int64]string
windowZones map[int64]string
minScore float64
maxScore float64
cutoff float64
@@ -109,8 +111,9 @@ func (dq *decisionQueues) Apply(decisions []SignalDecision, budget BudgetModel,
qd.SNRDb = decisions[i].Candidate.SNRDb
qd.Hint = decisions[i].Candidate.Hint
qd.Class = decisions[i].Class
qd.WindowTag = windowTagForDecision(decisions[i])
qd.WindowBias = decisions[i].MonitorBias
qd.WindowTag = windowTagForDecision(decisions[i], "record")
qd.WindowZone = windowZoneForDecision(decisions[i], "record")
qd.WindowBias = decisions[i].MonitorBias + decisions[i].RecordBias
qd.LastSeen = now
recSeen[id] = true
}
@@ -123,8 +126,9 @@ func (dq *decisionQueues) Apply(decisions []SignalDecision, budget BudgetModel,
qd.SNRDb = decisions[i].Candidate.SNRDb
qd.Hint = decisions[i].Candidate.Hint
qd.Class = decisions[i].Class
qd.WindowTag = windowTagForDecision(decisions[i])
qd.WindowBias = decisions[i].MonitorBias
qd.WindowTag = windowTagForDecision(decisions[i], "decode")
qd.WindowZone = windowZoneForDecision(decisions[i], "decode")
qd.WindowBias = decisions[i].MonitorBias + decisions[i].DecodeBias
qd.LastSeen = now
decSeen[id] = true
}
@@ -238,6 +242,7 @@ func selectQueued(queueName string, queue map[int64]*queuedDecision, hold map[in
familyRanks: map[int64]int{},
tierFloors: map[int64]string{},
windowTags: map[int64]string{},
windowZones: map[int64]string{},
}
if len(queue) == 0 {
return selection
@@ -268,6 +273,9 @@ func selectQueued(queueName string, queue map[int64]*queuedDecision, hold map[in
if qd.WindowTag != "" {
selection.windowTags[id] = qd.WindowTag
}
if qd.WindowZone != "" {
selection.windowZones[id] = qd.WindowZone
}
score := qd.SNRDb + boost + policyBoost + qd.WindowBias
selection.scores[id] = score
if len(scoredList) == 0 || score < selection.minScore {
@@ -412,12 +420,14 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p
return nil
}
windowTag := selection.windowTags[id]
windowZone := selection.windowZones[id]
admission := &PriorityAdmission{
Basis: queueName,
Score: score,
Cutoff: selection.cutoff,
Tier: selection.tiers[id],
}
zoneTag := windowZoneTag(windowZone)
admission.TierFloor = selection.tierFloors[id]
admission.Family = selection.families[id]
admission.FamilyRank = familyRankForOutput(selection.familyRanks[id])
@@ -428,6 +438,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p
if windowTag != "" {
extras = append(extras, windowTag)
}
if zoneTag != "" {
extras = append(extras, zoneTag)
}
if _, ok := selection.protected[id]; ok {
extras = append(extras, ReasonTagHoldProtected)
}
@@ -438,6 +451,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p
if windowTag != "" {
extras = append(extras, windowTag)
}
if zoneTag != "" {
extras = append(extras, zoneTag)
}
if _, ok := selection.opportunistic[id]; ok {
extras = append(extras, "pressure:hold", ReasonTagDisplaceOpportunist, ReasonTagDisplaceTier, ReasonTagHoldDisplaced)
}
@@ -451,6 +467,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p
if windowTag != "" {
extras = append(extras, windowTag)
}
if zoneTag != "" {
extras = append(extras, zoneTag)
}
admission.Reason = admissionReason("queue:"+queueName+":displace", policy, holdPolicy, extras...)
return admission
}
@@ -460,6 +479,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p
if windowTag != "" {
extras = append(extras, windowTag)
}
if zoneTag != "" {
extras = append(extras, zoneTag)
}
admission.Reason = admissionReason("queue:"+queueName+":displace", policy, holdPolicy, extras...)
return admission
}
@@ -468,6 +490,9 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p
if windowTag != "" {
extras = append(extras, windowTag)
}
if zoneTag != "" {
extras = append(extras, zoneTag)
}
if _, ok := selection.expired[id]; ok {
extras = append(extras, ReasonTagHoldExpired)
}
@@ -475,13 +500,14 @@ func buildQueueAdmission(queueName string, id int64, selection queueSelection, p
return admission
}

func windowTagForDecision(decision SignalDecision) string {
if decision.MonitorDetail == nil {
func windowTagForDecision(decision SignalDecision, action string) string {
match := windowMatchForDecision(decision, action)
if match == nil {
return ""
}
label := strings.TrimSpace(decision.MonitorDetail.Label)
label := strings.TrimSpace(match.Label)
if label == "" {
label = "index-" + strconv.Itoa(decision.MonitorDetail.Index)
label = "index-" + strconv.Itoa(match.Index)
}
label = slugToken(label)
if label == "" {
@@ -490,6 +516,36 @@ func windowTagForDecision(decision SignalDecision) string {
return "window:" + label
}

func windowZoneForDecision(decision SignalDecision, action string) string {
match := windowMatchForDecision(decision, action)
if match == nil {
return ""
}
return match.Zone
}

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

func windowZoneTag(zone string) string {
zone = slugToken(zone)
if zone == "" {
return ""
}
return "window-zone:" + zone
}

func oldestAge(queue map[int64]*queuedDecision, now time.Time) float64 {
oldest := 0.0
first := true


+ 40
- 0
internal/pipeline/decision_queue_test.go Wyświetl plik

@@ -101,6 +101,46 @@ func TestDecisionQueueMonitorWindowBiasSelectsPreferred(t *testing.T) {
}
}

func TestDecisionQueueWindowZoneBiasSelectsPerAction(t *testing.T) {
arbiter := NewArbiter()
policy := Policy{
DecisionHoldMs: 250,
AutoRecordClasses: []string{"test"},
AutoDecodeClasses: []string{"test"},
MonitorWindows: finalizeMonitorWindows([]MonitorWindow{
{Label: "record-zone", StartHz: 100, EndHz: 200, SpanHz: 100, Zone: "record", AutoRecord: true, AutoDecode: true},
{Label: "decode-zone", StartHz: 300, EndHz: 400, SpanHz: 100, Zone: "decode", AutoRecord: true, AutoDecode: true},
}),
}
budget := BudgetModel{Record: BudgetQueue{Max: 1}, Decode: BudgetQueue{Max: 1}}
now := time.Now()

decisions := []SignalDecision{
DecideSignalAction(policy, Candidate{ID: 1, CenterHz: 150, SNRDb: 10, Hint: "test"}, nil),
DecideSignalAction(policy, Candidate{ID: 2, CenterHz: 350, SNRDb: 10, Hint: "test"}, nil),
}
arbiter.ApplyDecisions(decisions, budget, now, policy)

if !decisions[0].ShouldRecord {
t.Fatalf("expected record-zone candidate to be selected for record")
}
if decisions[1].ShouldRecord {
t.Fatalf("expected decode-zone candidate to be deferred for record")
}
if !decisions[1].ShouldAutoDecode {
t.Fatalf("expected decode-zone candidate to be selected for decode")
}
if decisions[0].ShouldAutoDecode {
t.Fatalf("expected record-zone candidate to be deferred for decode")
}
if decisions[0].RecordAdmission == nil || !strings.Contains(decisions[0].RecordAdmission.Reason, "window-zone:record") {
t.Fatalf("expected record admission to include window-zone tag, got %+v", decisions[0].RecordAdmission)
}
if decisions[1].DecodeAdmission == nil || !strings.Contains(decisions[1].DecodeAdmission.Reason, "window-zone:decode") {
t.Fatalf("expected decode admission to include window-zone tag, got %+v", decisions[1].DecodeAdmission)
}
}

func TestDecisionQueueHoldKeepsSelection(t *testing.T) {
arbiter := NewArbiter()
policy := Policy{DecisionHoldMs: 500}


+ 22
- 10
internal/pipeline/decisions.go Wyświetl plik

@@ -13,6 +13,8 @@ type SignalDecision struct {
ShouldAutoDecode bool `json:"should_auto_decode"`
Reason string `json:"reason,omitempty"`
MonitorBias float64 `json:"monitor_bias,omitempty"`
RecordBias float64 `json:"record_bias,omitempty"`
DecodeBias float64 `json:"decode_bias,omitempty"`
MonitorDetail *MonitorWindowMatch `json:"monitor_detail,omitempty"`
RecordWindow *MonitorWindowMatch `json:"record_window,omitempty"`
DecodeWindow *MonitorWindowMatch `json:"decode_window,omitempty"`
@@ -50,19 +52,29 @@ func DecideSignalAction(policy Policy, candidate Candidate, cls *classifier.Clas
}
}
recordMatch := bestMonitorActionMatch(candidate.MonitorMatches, true, false)
if !decision.ShouldRecord && recordMatch != nil {
decision.ShouldRecord = true
decision.RecordWindow = recordMatch
if decision.Reason == "" {
decision.Reason = DecisionReasonRecordWindow
if recordMatch != nil {
decision.RecordBias = recordMatch.RecordBias
if decision.RecordWindow == nil {
decision.RecordWindow = recordMatch
}
if !decision.ShouldRecord {
decision.ShouldRecord = true
if decision.Reason == "" {
decision.Reason = DecisionReasonRecordWindow
}
}
}
decodeMatch := bestMonitorActionMatch(candidate.MonitorMatches, false, true)
if !decision.ShouldAutoDecode && decodeMatch != nil {
decision.ShouldAutoDecode = true
decision.DecodeWindow = decodeMatch
if decision.Reason == "" {
decision.Reason = DecisionReasonDecodeWindow
if decodeMatch != nil {
decision.DecodeBias = decodeMatch.DecodeBias
if decision.DecodeWindow == nil {
decision.DecodeWindow = decodeMatch
}
if !decision.ShouldAutoDecode {
decision.ShouldAutoDecode = true
if decision.Reason == "" {
decision.Reason = DecisionReasonDecodeWindow
}
}
}
if decision.Reason == "" && candidate.Hint != "" {


+ 47
- 0
internal/pipeline/monitor_rules.go Wyświetl plik

@@ -2,11 +2,13 @@ package pipeline

import (
"math"
"strings"

"sdr-wideband-suite/internal/config"
)

const maxMonitorWindowBias = 0.2
const maxMonitorWindowZoneBias = 0.15

func NormalizeMonitorWindows(goals config.PipelineGoalConfig, centerHz float64) []MonitorWindow {
if len(goals.MonitorWindows) > 0 {
@@ -61,6 +63,7 @@ func finalizeMonitorWindows(windows []MonitorWindow) []MonitorWindow {
}
for i := range windows {
windows[i].Index = i
windows[i].Zone = normalizeMonitorZone(windows[i].Zone)
priority := normalizeMonitorPriority(windows[i].Priority)
windows[i].Priority = priority
spanBias := 0.0
@@ -78,6 +81,9 @@ func finalizeMonitorWindows(windows []MonitorWindow) []MonitorWindow {
totalBias = -maxMonitorWindowBias
}
windows[i].PriorityBias = totalBias
recordBias, decodeBias := monitorWindowZoneBias(windows[i].Zone)
windows[i].RecordBias = recordBias
windows[i].DecodeBias = decodeBias
}
return windows
}
@@ -102,10 +108,12 @@ func MonitorWindowBounds(windows []MonitorWindow) (float64, float64, bool) {
}

func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (MonitorWindow, bool) {
zone := normalizeMonitorZone(raw.Zone)
if raw.StartHz > 0 && raw.EndHz > raw.StartHz {
span := raw.EndHz - raw.StartHz
return MonitorWindow{
Label: raw.Label,
Zone: zone,
StartHz: raw.StartHz,
EndHz: raw.EndHz,
CenterHz: (raw.StartHz + raw.EndHz) / 2,
@@ -128,6 +136,7 @@ func normalizeGoalWindow(raw config.MonitorWindow, fallbackCenter float64) (Moni
}
return MonitorWindow{
Label: raw.Label,
Zone: zone,
StartHz: center - half,
EndHz: center + half,
CenterHz: center,
@@ -154,6 +163,39 @@ func normalizeMonitorPriority(priority float64) float64 {
return priority
}

func normalizeMonitorZone(raw string) string {
zone := strings.ToLower(strings.TrimSpace(raw))
switch zone {
case "", "neutral", "monitor", "default":
return ""
case "focus", "priority", "hot":
return "focus"
case "record", "recording", "record-only":
return "record"
case "decode", "decoding", "decode-only":
return "decode"
case "background", "bg", "defer":
return "background"
default:
return ""
}
}

func monitorWindowZoneBias(zone string) (float64, float64) {
switch normalizeMonitorZone(zone) {
case "focus":
return maxMonitorWindowZoneBias, maxMonitorWindowZoneBias
case "record":
return maxMonitorWindowZoneBias, 0
case "decode":
return 0, maxMonitorWindowZoneBias
case "background":
return -maxMonitorWindowZoneBias, -maxMonitorWindowZoneBias
default:
return 0, 0
}
}

func monitorBounds(policy Policy) (float64, float64, bool) {
if len(policy.MonitorWindows) > 0 {
return MonitorWindowBounds(policy.MonitorWindows)
@@ -263,9 +305,12 @@ func MonitorWindowMatchesForCandidate(windows []MonitorWindow, candidate Candida
}
distance := math.Abs(candidate.CenterHz - center)
bias := win.PriorityBias * coverage
recordBias := win.RecordBias * coverage
decodeBias := win.DecodeBias * coverage
matches = append(matches, MonitorWindowMatch{
Index: win.Index,
Label: win.Label,
Zone: win.Zone,
Source: win.Source,
StartHz: win.StartHz,
EndHz: win.EndHz,
@@ -275,6 +320,8 @@ func MonitorWindowMatchesForCandidate(windows []MonitorWindow, candidate Candida
Coverage: coverage,
DistanceHz: distance,
Bias: bias,
RecordBias: recordBias,
DecodeBias: decodeBias,
AutoRecord: win.AutoRecord,
AutoDecode: win.AutoDecode,
})


+ 39
- 0
internal/pipeline/monitor_rules_test.go Wyświetl plik

@@ -119,3 +119,42 @@ func TestMonitorWindowPriorityBiasUsesPriority(t *testing.T) {
t.Fatalf("expected high priority bias > low priority bias, got %.3f vs %.3f", high.PriorityBias, low.PriorityBias)
}
}

func TestMonitorWindowZoneBiases(t *testing.T) {
goals := config.PipelineGoalConfig{
MonitorWindows: []config.MonitorWindow{
{Label: "record", StartHz: 100, EndHz: 200, Zone: "record"},
{Label: "decode", StartHz: 300, EndHz: 400, Zone: "decode"},
},
}
policy := Policy{MonitorWindows: NormalizeMonitorWindows(goals, 0)}
if len(policy.MonitorWindows) != 2 {
t.Fatalf("expected 2 windows, got %d", len(policy.MonitorWindows))
}
var recordWin, decodeWin *MonitorWindow
for i := range policy.MonitorWindows {
win := &policy.MonitorWindows[i]
switch win.Label {
case "record":
recordWin = win
case "decode":
decodeWin = win
}
}
if recordWin == nil || decodeWin == nil {
t.Fatalf("expected both window entries")
}
if recordWin.RecordBias <= 0 || recordWin.DecodeBias != 0 {
t.Fatalf("unexpected record window biases: %+v", recordWin)
}
if decodeWin.DecodeBias <= 0 || decodeWin.RecordBias != 0 {
t.Fatalf("unexpected decode window biases: %+v", decodeWin)
}
matches := MonitorWindowMatches(policy, Candidate{CenterHz: 150, BandwidthHz: 0})
if len(matches) != 1 {
t.Fatalf("expected 1 match, got %d", len(matches))
}
if matches[0].RecordBias <= 0 || matches[0].DecodeBias != 0 {
t.Fatalf("unexpected match biases: %+v", matches[0])
}
}

+ 3
- 0
internal/pipeline/scheduler.go Wyświetl plik

@@ -415,6 +415,7 @@ func buildMonitorWindowStats(windows []MonitorWindow) []MonitorWindowStats {
stats = append(stats, MonitorWindowStats{
Index: win.Index,
Label: win.Label,
Zone: win.Zone,
Source: win.Source,
StartHz: win.StartHz,
EndHz: win.EndHz,
@@ -422,6 +423,8 @@ func buildMonitorWindowStats(windows []MonitorWindow) []MonitorWindowStats {
SpanHz: win.SpanHz,
Priority: win.Priority,
PriorityBias: win.PriorityBias,
RecordBias: win.RecordBias,
DecodeBias: win.DecodeBias,
AutoRecord: win.AutoRecord,
AutoDecode: win.AutoDecode,
})


+ 9
- 0
internal/pipeline/types.go Wyświetl plik

@@ -34,6 +34,7 @@ type LevelEvidence struct {
type MonitorWindow struct {
Index int `json:"index,omitempty"`
Label string `json:"label,omitempty"`
Zone string `json:"zone,omitempty"`
StartHz float64 `json:"start_hz,omitempty"`
EndHz float64 `json:"end_hz,omitempty"`
CenterHz float64 `json:"center_hz,omitempty"`
@@ -41,6 +42,8 @@ type MonitorWindow struct {
Source string `json:"source,omitempty"`
Priority float64 `json:"priority,omitempty"`
PriorityBias float64 `json:"priority_bias,omitempty"`
RecordBias float64 `json:"record_bias,omitempty"`
DecodeBias float64 `json:"decode_bias,omitempty"`
AutoRecord bool `json:"auto_record,omitempty"`
AutoDecode bool `json:"auto_decode,omitempty"`
}
@@ -49,6 +52,7 @@ type MonitorWindow struct {
type MonitorWindowMatch struct {
Index int `json:"index"`
Label string `json:"label,omitempty"`
Zone string `json:"zone,omitempty"`
Source string `json:"source,omitempty"`
StartHz float64 `json:"start_hz,omitempty"`
EndHz float64 `json:"end_hz,omitempty"`
@@ -58,6 +62,8 @@ type MonitorWindowMatch struct {
Coverage float64 `json:"coverage,omitempty"`
DistanceHz float64 `json:"distance_hz,omitempty"`
Bias float64 `json:"bias,omitempty"`
RecordBias float64 `json:"record_bias,omitempty"`
DecodeBias float64 `json:"decode_bias,omitempty"`
AutoRecord bool `json:"auto_record,omitempty"`
AutoDecode bool `json:"auto_decode,omitempty"`
}
@@ -66,6 +72,7 @@ type MonitorWindowMatch struct {
type MonitorWindowStats struct {
Index int `json:"index"`
Label string `json:"label,omitempty"`
Zone string `json:"zone,omitempty"`
Source string `json:"source,omitempty"`
StartHz float64 `json:"start_hz,omitempty"`
EndHz float64 `json:"end_hz,omitempty"`
@@ -73,6 +80,8 @@ type MonitorWindowStats struct {
SpanHz float64 `json:"span_hz,omitempty"`
Priority float64 `json:"priority,omitempty"`
PriorityBias float64 `json:"priority_bias,omitempty"`
RecordBias float64 `json:"record_bias,omitempty"`
DecodeBias float64 `json:"decode_bias,omitempty"`
AutoRecord bool `json:"auto_record,omitempty"`
AutoDecode bool `json:"auto_decode,omitempty"`
Candidates int `json:"candidates,omitempty"`


+ 19
- 0
internal/runtime/runtime.go Wyświetl plik

@@ -508,6 +508,9 @@ func (m *Manager) ApplyConfig(update ConfigUpdate) (config.Config, error) {

func validateMonitorWindows(windows []config.MonitorWindow) error {
for i, w := range windows {
if !isValidMonitorZone(w.Zone) {
return fmt.Errorf("monitor_windows[%d] zone is invalid", i)
}
if math.IsNaN(w.Priority) || math.IsInf(w.Priority, 0) || w.Priority < -1 || w.Priority > 1 {
return fmt.Errorf("monitor_windows[%d] priority must be between -1 and 1", i)
}
@@ -528,6 +531,22 @@ func validateMonitorWindows(windows []config.MonitorWindow) error {
return nil
}

func isValidMonitorZone(zone string) bool {
zone = strings.ToLower(strings.TrimSpace(zone))
if zone == "" {
return true
}
switch zone {
case "neutral", "monitor", "default", "focus", "priority", "hot",
"record", "recording", "record-only",
"decode", "decoding", "decode-only",
"background", "bg", "defer":
return true
default:
return false
}
}

func (m *Manager) ApplySettings(update SettingsUpdate) (config.Config, error) {
m.mu.Lock()
defer m.mu.Unlock()


Ładowanie…
Anuluj
Zapisz