siglab

package
v0.3.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 8, 2026 License: Apache-2.0 Imports: 38 Imported by: 0

Documentation

Overview

Package siglab is the offline signal replay / testing / analysis core shared by the gophertrunk replay, analyze, gen, test and siglab-TUI subcommands. It drives any protocol GopherTrunk can decode through the same production receiver + control-channel pipelines the daemon runs (via the ccdecoder factory map), collects a protocol-agnostic structured Result, and exposes signal-quality analysis, synthesis, and a metadata-driven acceptance harness on top of it.

The engine deliberately mirrors the daemon's IQ → DDC → pipeline chain so a replay lock implies an on-air lock and a replay failure makes the offline capture a reproducible fixture (the same contract the original replay subcommand established for P25 in issue #402).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DiscoverMetadata

func DiscoverMetadata(capturePath string) string

DiscoverMetadata finds the sidecar for a capture by stem: it tries <stem>.metadata.json, <stem>.metadata.yaml, then <capture>.json/.yaml. Returns "" when none exists.

func EncodeCapture added in v0.3.3

func EncodeCapture(iq []complex64, format SampleFormat) []byte

EncodeCapture returns IQ encoded in the given on-disk format (u8 or f32). It is the pure (no-I/O) core shared by WriteCapture and the in-memory synth→decode bridge (SynthesizeAndAnalyze), so a synthesized signal can be fed straight back into the engine via a bytes.Reader without touching disk.

func Fixtures

func Fixtures() []trunking.Protocol

Fixtures returns the protocols with a registered synthesis fixture, in stable order, for the `gen` usage message and protocol picker.

func HasFixture

func HasFixture(p trunking.Protocol) bool

HasFixture reports whether a synthesis fixture is registered for p.

func ParseProtocolCLI

func ParseProtocolCLI(s string) (trunking.Protocol, error)

ParseProtocolCLI maps a command-line protocol name to a trunking.Protocol. It first honours the legacy replay aliases ("p25p1" → P25 Phase 1, "dmr-tier3" → DMR Tier III) so existing replay invocations keep working, then falls back to the canonical trunking.ParseProtocol (which covers all config spellings: p25, p25-phase2, dmr, dmr-tier2, nxdn, dpmr, edacs, motorola, ltr, mpt1327, tetra, ysf, dstar).

func SortedDecodeErrors

func SortedDecodeErrors(m map[string]int) []string

SortedDecodeErrors returns the decode-error stages in a stable order, for deterministic text/CSV rendering by callers.

func SynthesizeAndAnalyze added in v0.3.3

func SynthesizeAndAnalyze(synth SynthOptions, decode Config, onEvent func(EventRecord)) (*Result, *Metadata, error)

SynthesizeAndAnalyze synthesizes an idealized (optionally impaired) signal for synth.Protocol and immediately analyzes it through the production pipeline, all in memory — no capture file is written to disk. It is the one-call path behind the web interface's "compare against a generated idealized signal" feature: the returned Result (and its IQTaps, when decode.CaptureIQ is set) can be diffed against an uploaded capture's Result.

The decode template carries the analysis knobs the caller wants (CollectIQDiag, CaptureIQ, CaptureIQMaxPoints, Conjugate, IQCorrect, DemodMode and the P25 deep knobs); the protocol, sample rate, format and per-protocol system knobs are taken from the synthesis fixture so the decode always matches the signal that was generated. onEvent (when non-nil) receives each EventRecord live, exactly like RunReaderStream.

func WriteCapture

func WriteCapture(path string, iq []complex64, format SampleFormat) error

WriteCapture writes IQ to path in the given format (u8 or f32).

func WriteMetadata

func WriteMetadata(path string, m *Metadata) error

WriteMetadata writes m to path as JSON (the sidecar `test` auto-discovers).

func WriteResult

func WriteResult(w io.Writer, r *Result, f Format) error

WriteResult serializes r to w in the requested format. FormatTextSummary is not handled here (the subcommands own their text rendering); it returns an error so a miswired caller is caught.

Types

type Acceptance

type Acceptance struct {
	// Lock, when non-nil, requires the run to (true) or to not (false) lock
	// the control channel.
	Lock *bool `json:"lock,omitempty" yaml:"lock,omitempty"`
	// LockLatencyMaxSec caps the time-to-first-lock (0 ⇒ unchecked).
	LockLatencyMaxSec float64 `json:"lock_latency_max_sec,omitempty" yaml:"lock_latency_max_sec,omitempty"`
	// LockFields are expected lock-payload fields (NAC, ColorCode,
	// SystemID, …). Values may be given as numbers or hex strings
	// ("0x293"); comparison is hex-tolerant. Matched as a subset of the
	// observed lock fields (and the common frequency_hz).
	LockFields map[string]any `json:"lock_fields,omitempty" yaml:"lock_fields,omitempty"`
	// MinGrants requires at least this many grants (0 ⇒ unchecked).
	MinGrants int `json:"min_grants,omitempty" yaml:"min_grants,omitempty"`
	// BaudTolerancePct caps |effective − expected| / expected (0 ⇒ unchecked).
	BaudTolerancePct float64 `json:"baud_tolerance_pct,omitempty" yaml:"baud_tolerance_pct,omitempty"`
	// MaxDecodeErrorRate caps decode errors per 1000 symbols (0 ⇒ unchecked).
	MaxDecodeErrorRate float64 `json:"max_decode_error_rate,omitempty" yaml:"max_decode_error_rate,omitempty"`
}

Acceptance is the expected-outcome contract a capture is graded against by the `test` harness. A nil field means "don't check this dimension". It generalizes the per-test assertions the integration_cc_*_test.go files and samples/README.md acceptance criteria describe into one schema.

type BandPlanSlot added in v0.3.5

type BandPlanSlot = trunking.TopoBandPlanSlot

BandPlanSlot is one band-plan entry. See trunking.TopoBandPlanSlot.

type CCStatsBreakdown

type CCStatsBreakdown struct {
	NIDTrusted        int64 `json:"nid_trusted" yaml:"nid_trusted"`
	NIDMarginal       int64 `json:"nid_marginal" yaml:"nid_marginal"`
	NIDFailed         int64 `json:"nid_failed" yaml:"nid_failed"`
	TSBKDecoded       int64 `json:"tsbk_decoded" yaml:"tsbk_decoded"`
	TSBKTrellisFailed int64 `json:"tsbk_trellis_failed" yaml:"tsbk_trellis_failed"`
	TSBKCRCFailed     int64 `json:"tsbk_crc_failed" yaml:"tsbk_crc_failed"`
}

CCStatsBreakdown mirrors p25phase1.CCStats: the per-frame decode outcomes.

type CandidateScore

type CandidateScore struct {
	Protocol       string  `json:"protocol"`
	Locked         bool    `json:"locked"`
	LockLatencySec float64 `json:"lock_latency_sec"`
	SyncHits       int     `json:"sync_hits"`
	SyncVariant    string  `json:"sync_variant"`
	ModalSpacing   int     `json:"modal_spacing"`
	FECPassRate    float64 `json:"fec_pass_rate"`
	Score          float64 `json:"score"`
	// contains filtered or unexported fields
}

CandidateScore is one protocol's identification evidence and composite score.

type ChannelRef added in v0.3.5

type ChannelRef = trunking.TopoChannelRef

ChannelRef is a band-plan channel coordinate. See trunking.TopoChannelRef.

type Config

type Config struct {
	// Protocol selects the production pipeline to drive. Every protocol the
	// ccdecoder factory map registers is supported.
	Protocol trunking.Protocol
	// System carries per-protocol knobs (demod mode, colour code, band
	// plan, FEC modes, …) exactly as the daemon reads them off a configured
	// trunking.System. SystemName/FrequencyHz are surfaced separately
	// because every factory consumes them.
	System      trunking.System
	SystemName  string
	FrequencyHz uint32

	// SampleRateHz is the capture's IQ sample rate (input rate, before any
	// down-conversion). Required.
	SampleRateHz float64
	// Format is the on-disk sample encoding.
	Format SampleFormat

	// TuneHz frequency-shifts the capture so a channel at +TuneHz lands at
	// 0 Hz before demod. AutoTune estimates it from a prefix of the file
	// (and overrides TuneHz when set).
	TuneHz   float64
	AutoTune bool

	// Conjugate negates Q before channelization (spectrum-inverted /
	// I-Q-swapped front-end, issue #264). IQCorrect applies blind
	// I/Q-imbalance correction to the raw IQ before decimation (issue #402).
	Conjugate bool
	IQCorrect bool

	// CollectIQDiag enables the protocol-agnostic signal-quality analyzer
	// (symbol histogram, IQ imbalance, soft-sample distribution where
	// available). Off by default since it buffers per-symbol observations.
	CollectIQDiag bool

	// DemodMode is the P25 Phase 1 symbol-recovery path: "c4fm" (default) or
	// "cqpsk". Empty ⇒ c4fm.
	DemodMode string
	// NIDSearchSpan overrides the NID-alignment search radius in dibits
	// (issue #275 bisect knob). 0 ⇒ the production default.
	NIDSearchSpan int
	// EnableDDA opts the C4FM path into the experimental decision-directed
	// AFC (issue #402; off in production).
	EnableDDA bool
	// EnableAdaptiveSlicer opts the C4FM path into the adaptive slicer
	// (issue #402; off in production).
	EnableAdaptiveSlicer bool
	// CollectReceiverState captures a per-(stream-)second snapshot of the P25
	// receiver's AFC/AGC/clock/slicer state into the Result (the per-second
	// state log replay emitted to stderr, now structured + exportable).
	CollectReceiverState bool

	// ChunkSamples is the read-loop chunk size in IQ samples; 0 ⇒ default.
	ChunkSamples int

	// CaptureIQ enables buffering a stride-decimated copy of the
	// post-DDC (channelized) IQ and the pre-slicer soft samples into
	// Result.IQTaps, so a web/visualization consumer can render the
	// constellation, spectrogram, PSD and eye diagram client-side. Off
	// by default since it is O(captured points) memory. The capture is
	// always decimated and hard-capped (see CaptureIQMaxPoints), so it
	// never ships full-rate IQ.
	CaptureIQ bool
	// CaptureIQMaxPoints caps the number of decimated IQ points (and,
	// separately, soft samples) retained when CaptureIQ is set. 0 ⇒
	// defaultCaptureIQMaxPoints. The read loop strides the channelized
	// stream so the retained density targets visualization fidelity
	// rather than faithful reconstruction.
	CaptureIQMaxPoints int

	// MaxSamples caps the number of input IQ samples processed (0 ⇒ whole
	// file). The signal identifier sets this to scan only a prefix of a
	// capture, bounding per-candidate cost regardless of capture length.
	MaxSamples int64

	// Acceptance, when non-nil, is evaluated against the Result to produce a
	// pass/fail Verdict (the `test` harness sets it).
	Acceptance *Acceptance

	// Log receives the production pipeline's structured diagnostics. nil ⇒
	// a discard logger (the engine never wants a nil *slog.Logger reaching a
	// factory that does not nil-guard it).
	Log *slog.Logger
}

Config drives a single offline run of the engine over one capture (or a synthesized stream). The zero value is not runnable — Protocol, a sample source, and SampleRateHz must be set.

type EventRecord

type EventRecord struct {
	Seq       int            `json:"seq" yaml:"seq"`
	OffsetSec float64        `json:"offset_sec" yaml:"offset_sec"`
	Kind      string         `json:"kind" yaml:"kind"`
	Fields    map[string]any `json:"fields" yaml:"fields"`
}

EventRecord is one bus event captured generically — Kind plus the reflection-flattened payload — so the JSONL stream and the events CSV represent every protocol's events without a typed switch per protocol.

type FECStat

type FECStat struct {
	Stage         string `json:"stage" yaml:"stage"`
	Frames        int    `json:"frames" yaml:"frames"`
	Clean         int    `json:"clean" yaml:"clean"`
	Corrected     int    `json:"corrected" yaml:"corrected"`
	Uncorrectable int    `json:"uncorrectable" yaml:"uncorrectable"`
	CRCPass       int    `json:"crc_pass,omitempty" yaml:"crc_pass,omitempty"`
	CRCFail       int    `json:"crc_fail,omitempty" yaml:"crc_fail,omitempty"`
}

FECStat tallies one FEC stage's outcomes across the frames found at clean sync hits — the direct analog of P25's NID trusted/marginal/uncorrectable.

type Format

type Format int

Format selects an output encoding for a Result.

const (
	// FormatTextSummary is the human-readable summary (handled by the
	// caller's renderer, not WriteResult). Defined so callers share one
	// Format enum.
	FormatTextSummary Format = iota
	// FormatJSON is a single indented JSON object.
	FormatJSON
	// FormatJSONL is one JSON object per line: every captured event, then a
	// final summary object tagged "summary".
	FormatJSONL
	// FormatYAML is a single YAML document.
	FormatYAML
	// FormatCSVSummary is a two-line CSV (header + one summary row).
	FormatCSVSummary
	// FormatCSVEvents is a CSV with one row per captured event.
	FormatCSVEvents
)

func ParseFormat

func ParseFormat(s string) (Format, error)

ParseFormat maps a -format flag value to a Format. csv defaults to the summary shape; use "csv-events" for the per-event shape.

type GrantRecord

type GrantRecord struct {
	OffsetSec   float64 `json:"offset_sec" yaml:"offset_sec"`
	GroupID     uint32  `json:"group_id" yaml:"group_id"`
	SourceID    uint32  `json:"source_id" yaml:"source_id"`
	ChannelID   uint8   `json:"channel_id" yaml:"channel_id"`
	ChannelNum  uint16  `json:"channel_num" yaml:"channel_num"`
	Timeslot    uint8   `json:"timeslot" yaml:"timeslot"`
	FrequencyHz uint32  `json:"frequency_hz" yaml:"frequency_hz"`
	Encrypted   bool    `json:"encrypted" yaml:"encrypted"`
	Emergency   bool    `json:"emergency" yaml:"emergency"`
}

GrantRecord mirrors trunking.Grant, flattened with the stream-time offset at which it was observed.

type IQPoint added in v0.3.3

type IQPoint struct {
	I float32 `json:"i"`
	Q float32 `json:"q"`
}

IQPoint is one complex sample on the wire. Float32 keeps the captured stream compact; web consumers render it directly without conversion. The JSON tags match internal/api's IQPoint (the live Constellation panel) so the offline siglab taps and the live diag stream share a wire shape.

type IQTaps added in v0.3.3

type IQTaps struct {
	// DecimatedIQ is the channelized IQ sampled every Stride samples.
	DecimatedIQ []IQPoint `json:"decimated_iq" yaml:"decimated_iq"`
	// SoftSamples is the pre-slicer soft-sample stream (deep P25 path);
	// empty for protocols/paths that do not emit soft samples.
	SoftSamples []float32 `json:"soft_samples" yaml:"soft_samples"`
	// DecimatedRateHz is the effective sample rate of DecimatedIQ
	// (PipelineRate / Stride).
	DecimatedRateHz float64 `json:"decimated_rate_hz" yaml:"decimated_rate_hz"`
	// Stride is the input-to-output downsample ratio applied to the
	// channelized stream.
	Stride int `json:"stride" yaml:"stride"`

	// SymbolDibits is the recovered-symbol decision stream (values
	// 0..SymbolCardinality-1), decimated to the same cap as the IQ. It
	// backs the offline Symbol-scope viz (the OP25-style time series).
	// Empty for protocols/paths that buffer no symbols.
	SymbolDibits []uint8 `json:"symbol_dibits,omitempty" yaml:"symbol_dibits,omitempty"`
	// SymbolSoft is the pre-slicer soft waveform aligned index-for-index
	// with SymbolDibits (deep P25 C4FM path). Empty when the demod path
	// emits no soft samples (e.g. CQPSK); the viz then shows the dibit
	// rows only.
	SymbolSoft []float32 `json:"symbol_soft,omitempty" yaml:"symbol_soft,omitempty"`
	// SymbolCardinality is 4 for the dibit (4-level) protocols, 2 for the
	// bit (2-level) protocols.
	SymbolCardinality int `json:"symbol_cardinality,omitempty" yaml:"symbol_cardinality,omitempty"`
}

IQTaps is the optional decimated signal capture attached to a Result when Config.CaptureIQ is set. It carries a stride-decimated copy of the post-DDC (channelized) IQ — what the constellation, spectrogram and PSD views render — plus the pre-slicer soft samples (deep P25 C4FM path) the eye diagram folds. Both slices are independently hard-capped at Config.CaptureIQMaxPoints, so the taps never grow without bound and never ship full-rate IQ.

It is deliberately heavy (potentially MBs), so the API/export layer strips it from the Result before serializing the summary or running the CSV/JSON exporters; it is retrieved separately (GET .../iq) when a view needs it.

type IdentifyConfig

type IdentifyConfig struct {
	SampleRateHz float64
	Format       SampleFormat
	AutoTune     bool
	Conjugate    bool
	IQCorrect    bool
	// MaxSamples caps the input samples each candidate processes (0 ⇒ whole
	// capture). Set it to scan a fast prefix when identifying.
	MaxSamples int64
	// Candidates restricts the protocols tried. nil ⇒ every registered
	// protocol whose channel rate fits the capture sample rate.
	Candidates []trunking.Protocol
	Log        *slog.Logger
}

IdentifyConfig configures a signal-identification scan over a capture.

type IdentifyResult

type IdentifyResult struct {
	Source       string           `json:"source"`
	SampleRateHz float64          `json:"sample_rate_hz"`
	Winner       string           `json:"winner"`
	Confidence   float64          `json:"confidence"`
	Inconclusive bool             `json:"inconclusive"`
	Candidates   []CandidateScore `json:"candidates"`
}

IdentifyResult ranks the candidates and names the most likely protocol.

func Identify

func Identify(path string, cfg IdentifyConfig) (*IdentifyResult, error)

Identify scans path against candidate protocols and returns a ranked result. Each candidate is run through the engine — over a bounded prefix when MaxSamples is set — and scored on lock + sync-landscape evidence + FEC pass rate. The winner's run result is retained for WinnerResult.

It is a thin wrapper over IdentifyReader: the file is opened once and each candidate seeks back to the start, so the capture is read from disk a single time rather than re-opened per protocol.

func IdentifyIQ added in v0.3.5

func IdentifyIQ(iq []complex64, source string, cfg IdentifyConfig) (*IdentifyResult, error)

IdentifyIQ identifies an in-memory IQ buffer without any disk round-trip. It encodes the samples once in cfg.Format and delegates to IdentifyReader. This is the path the live hunt sweeper uses: capture a short slice off the SDR at a candidate frequency, then identify it in memory.

func IdentifyReader added in v0.3.5

func IdentifyReader(r io.ReadSeeker, source string, cfg IdentifyConfig) (*IdentifyResult, error)

IdentifyReader scans the capture read from r against candidate protocols and returns a ranked result. r is read once per candidate and rewound between candidates via Seek, so the underlying bytes are materialized a single time (an *os.File or *bytes.Reader both satisfy io.ReadSeeker, and the seek keeps AutoTune working). It is the shared core behind Identify and IdentifyIQ.

func (*IdentifyResult) WinnerProtocol

func (r *IdentifyResult) WinnerProtocol() (trunking.Protocol, error)

WinnerProtocol parses the winning protocol name back to a trunking.Protocol.

func (*IdentifyResult) WinnerResult

func (r *IdentifyResult) WinnerResult() *Result

WinnerResult returns the run Result for the winning candidate (the prefix scan), or nil. Callers wanting a full-capture analysis should re-run siglab.Run with MaxSamples=0 for the winning protocol.

type LockInfo

type LockInfo struct {
	FrequencyHz uint32         `json:"frequency_hz" yaml:"frequency_hz"`
	Fields      map[string]any `json:"fields" yaml:"fields"`
}

LockInfo is the flattened, protocol-agnostic view of a KindCCLocked payload: the frequency every LockState carries, plus a Fields map of the remaining protocol-specific fields (NAC/DUID, ColorCode/SystemID, MCC/MNC, RAN, …) so one struct represents all 13 protocols' distinct LockState types.

type Metadata

type Metadata struct {
	// Protocol is the CLI/config protocol name (p25, p25-phase2, dmr,
	// dmr-tier2, nxdn, dpmr, edacs, motorola, ltr, mpt1327, tetra, ysf,
	// dstar; the replay aliases p25p1/dmr-tier3 are also accepted).
	Protocol string `json:"protocol" yaml:"protocol"`
	// Source / ToolCrossCheck are free-text provenance (where the capture
	// came from, which reference receiver it was validated against).
	Source         string `json:"source,omitempty" yaml:"source,omitempty"`
	ToolCrossCheck string `json:"tool_cross_check,omitempty" yaml:"tool_cross_check,omitempty"`
	// SampleRateHz is required — a raw cfile/u8 carries no rate of its own.
	SampleRateHz float64 `json:"sample_rate_hz" yaml:"sample_rate_hz"`
	// CenterFreqHz is informational (the capture's nominal centre).
	CenterFreqHz uint32 `json:"center_freq_hz,omitempty" yaml:"center_freq_hz,omitempty"`
	// Format is the on-disk encoding (u8|f32); empty defaults to u8.
	Format string `json:"format,omitempty" yaml:"format,omitempty"`
	// TuneHz / AutoTune / Conjugate / IQCorrect mirror the engine knobs for
	// captures that need them.
	TuneHz    float64 `json:"tune_hz,omitempty" yaml:"tune_hz,omitempty"`
	AutoTune  bool    `json:"auto_tune,omitempty" yaml:"auto_tune,omitempty"`
	Conjugate bool    `json:"conjugate,omitempty" yaml:"conjugate,omitempty"`
	IQCorrect bool    `json:"iq_correct,omitempty" yaml:"iq_correct,omitempty"`
	// System carries per-protocol decoder knobs by their YAML config-key
	// names (e.g. "tetra_colour_code": "1", "p25_phase1_demod_mode":
	// "cqpsk"), applied onto the trunking.System the engine drives.
	System map[string]string `json:"system,omitempty" yaml:"system,omitempty"`
	// Expected is the acceptance contract graded against the decode.
	Expected Acceptance `json:"expected" yaml:"expected"`
}

Metadata is the sidecar that accompanies a capture (real-air or synthesized) and describes how to decode it plus what a correct decode must produce. It generalizes the ad-hoc per-test loaders and the acceptance-criteria documented in samples/README.md into one schema the `test` harness consumes. It loads from JSON or YAML.

func LoadMetadata

func LoadMetadata(path string) (*Metadata, error)

LoadMetadata reads a Metadata document from path (JSON or YAML, chosen by extension; .json → JSON, everything else → YAML).

func Synthesize

func Synthesize(opts SynthOptions) ([]complex64, *Metadata, error)

Synthesize builds a known-good (optionally impaired) capture for a protocol and returns the IQ plus the Metadata describing how to decode and grade it. Returns an error when no synthesis fixture is registered for the protocol (see Fixtures for the supported set).

func (*Metadata) Config

func (m *Metadata) Config(collectIQDiag bool) (Config, error)

Config builds an engine Config from the metadata for the given capture. collectIQDiag is threaded through because the harness wants the analyzer's decode-error rate available to the verdict.

type NIDDecode

type NIDDecode struct {
	Pos  int    `json:"pos" yaml:"pos"`
	Errs int    `json:"errs" yaml:"errs"`
	NAC  uint16 `json:"nac" yaml:"nac"`
	DUID uint8  `json:"duid" yaml:"duid"`
	OK   bool   `json:"ok" yaml:"ok"`
}

NIDDecode is one NID BCH decode attempt at an FSW hit.

type NeighborRef added in v0.3.5

type NeighborRef = trunking.TopoNeighborRef

NeighborRef is an adjacent site. See trunking.TopoNeighborRef.

type P25P1Detail

type P25P1Detail struct {
	DibitsBuffered  int             `json:"dibits_buffered" yaml:"dibits_buffered"`
	DibitHistogram  [4]int64        `json:"dibit_histogram" yaml:"dibit_histogram"`
	Rotations       [4]RotationStat `json:"rotations" yaml:"rotations"`
	WinningRotation int             `json:"winning_rotation" yaml:"winning_rotation"`
	WinningHits     int             `json:"winning_hits" yaml:"winning_hits"`
	// NIDDecodes are the NAC/DUID results from BCH-decoding the NID at the
	// cleanest (distance-0) FSW hits under the winning rotation. A mix of
	// successes and failures points at signal quality; zero successes
	// despite perfect FSW alignment points at framing.
	NIDDecodes []NIDDecode `json:"nid_decodes" yaml:"nid_decodes"`

	// CCStats is the per-frame NID/TSBK outcome breakdown the control channel
	// accumulates (the replay EOF summary's "nid trusted/marginal/…" lines).
	// Non-nil only on the deep path.
	CCStats *CCStatsBreakdown `json:"cc_stats,omitempty" yaml:"cc_stats,omitempty"`
	// SoftEye is the pre-slicer soft-sample analysis (DC, per-rail
	// distribution, true-symbol outer-rail eye + mechanism verdict). Non-nil
	// only when soft samples were captured (the deep C4FM path).
	SoftEye *SoftEye `json:"soft_eye,omitempty" yaml:"soft_eye,omitempty"`
	// ReceiverStates is the per-(stream-)second receiver-state series the
	// deep path captures when Config.CollectReceiverState is set.
	ReceiverStates []ReceiverState `json:"receiver_states,omitempty" yaml:"receiver_states,omitempty"`
}

P25P1Detail is the P25-Phase-1-specific demod deep-dive, surfaced in Result.P25P1 when the protocol is P25 Phase 1 and CollectIQDiag is set. It reuses the dibit-domain analysis the historical iqdiag report pioneered (issue #275): a per-rotation Frame-Sync-Word correlation landscape that tells whether the demod produces canonical dibits at all and which rotation aligns the stream, plus NID decode attempts at the cleanest FSW hits. It operates purely on the recovered dibit stream (no soft samples), so it is available through the generic SymbolTap for every P25 P1 capture.

type ProtocolDetail

type ProtocolDetail struct {
	Protocol           string         `json:"protocol" yaml:"protocol"`
	SymbolsBuffered    int            `json:"symbols_buffered" yaml:"symbols_buffered"`
	SymbolCardinality  int            `json:"symbol_cardinality" yaml:"symbol_cardinality"`
	SymbolHistogram    []int64        `json:"symbol_histogram" yaml:"symbol_histogram"`
	SymbolHistogramPct []float64      `json:"symbol_histogram_pct" yaml:"symbol_histogram_pct"`
	Sync               *SyncLandscape `json:"sync,omitempty" yaml:"sync,omitempty"`
	FEC                []FECStat      `json:"fec,omitempty" yaml:"fec,omitempty"`
	Notes              string         `json:"notes,omitempty" yaml:"notes,omitempty"`
}

ProtocolDetail is the protocol-specific deep dive for every protocol other than P25 Phase 1 (which keeps its richer P25P1Detail with soft eye + receiver state). It is computed from the buffered recovered-symbol stream: a symbol histogram, a sync-correlation landscape against the protocol's own sync word(s), and — where tractable — a per-stage FEC-decode tally. This is the symbol-domain analog of P25's FSW landscape + NID-BCH probe, the most valuable half of a deep dive, available without any receiver changes.

type RailStat

type RailStat struct {
	Label string  `json:"label" yaml:"label"`
	N     int     `json:"n" yaml:"n"`
	Mean  float64 `json:"mean" yaml:"mean"`
	Std   float64 `json:"std" yaml:"std"`
	P10   float64 `json:"p10" yaml:"p10"`
	P50   float64 `json:"p50" yaml:"p50"`
	P90   float64 `json:"p90" yaml:"p90"`
}

RailStat is the distribution of soft samples on one decision rail.

type ReceiverState

type ReceiverState struct {
	TimeSec float64 `json:"time_sec" yaml:"time_sec"`
	CQPSK   bool    `json:"cqpsk" yaml:"cqpsk"`

	// C4FM path.
	AFCHzEst         float64    `json:"afc_hz_est" yaml:"afc_hz_est"`
	AGCLevel         float64    `json:"agc_level" yaml:"agc_level"`
	AGCTarget        float64    `json:"agc_target" yaml:"agc_target"`
	MMMu             float64    `json:"mm_mu" yaml:"mm_mu"`
	MMSPS            float64    `json:"mm_sps" yaml:"mm_sps"`
	DDAActive        bool       `json:"dda_active" yaml:"dda_active"`
	SlicerLevels     [4]float64 `json:"slicer_levels" yaml:"slicer_levels"`
	SlicerThresholds [3]float64 `json:"slicer_thresholds" yaml:"slicer_thresholds"`

	// CQPSK path.
	CarrierHzEst float64 `json:"carrier_hz_est,omitempty" yaml:"carrier_hz_est,omitempty"`
	GardnerMu    float64 `json:"gardner_mu,omitempty" yaml:"gardner_mu,omitempty"`
	GardnerSPS   float64 `json:"gardner_sps,omitempty" yaml:"gardner_sps,omitempty"`
	CQPSKAGCGain float64 `json:"cqpsk_agc_gain,omitempty" yaml:"cqpsk_agc_gain,omitempty"`
	CMAError     float64 `json:"cma_error,omitempty" yaml:"cma_error,omitempty"`
}

ReceiverState is one snapshot of the P25 receiver's internal loops at a point in stream time. C4FM and CQPSK populate disjoint field sets (the other path's fields read zero); CQPSK is true on the CQPSK path.

type Result

type Result struct {
	// Provenance.
	Source         string  `json:"source" yaml:"source"`
	Protocol       string  `json:"protocol" yaml:"protocol"`
	SampleRateHz   float64 `json:"sample_rate_hz" yaml:"sample_rate_hz"`
	PipelineRateHz float64 `json:"pipeline_rate_hz" yaml:"pipeline_rate_hz"`
	TuneHz         float64 `json:"tune_hz" yaml:"tune_hz"`
	DurationSec    float64 `json:"duration_sec" yaml:"duration_sec"`

	// Throughput.
	TotalSamples     int64   `json:"total_samples" yaml:"total_samples"`
	Symbols          int64   `json:"symbols" yaml:"symbols"`
	EffectiveBaud    float64 `json:"effective_baud" yaml:"effective_baud"`
	ExpectedBaud     float64 `json:"expected_baud" yaml:"expected_baud"`
	BaudDeviationPct float64 `json:"baud_deviation_pct" yaml:"baud_deviation_pct"`

	// Lock.
	Locked         bool      `json:"locked" yaml:"locked"`
	LockLatencySec float64   `json:"lock_latency_sec" yaml:"lock_latency_sec"`
	Lock           *LockInfo `json:"lock,omitempty" yaml:"lock,omitempty"`

	// Grants + events.
	Grants       []GrantRecord  `json:"grants" yaml:"grants"`
	Events       []EventRecord  `json:"events" yaml:"events"`
	EventCounts  map[string]int `json:"event_counts" yaml:"event_counts"`
	DecodeErrors map[string]int `json:"decode_errors" yaml:"decode_errors"`

	// Analysis (nil unless CollectIQDiag).
	Signal *SignalQuality `json:"signal,omitempty" yaml:"signal,omitempty"`
	// Detail is the protocol-specific deep dive (a *P25P1Detail, *DMRDetail,
	// *NXDNDetail, …) populated when CollectIQDiag is set and a per-protocol
	// detail builder exists. Consumers type-switch on it (keyed by Protocol).
	Detail any `json:"detail,omitempty" yaml:"detail,omitempty"`

	// Topology is the protocol-neutral system topology accumulated from the
	// control channel during the run — identity (WACN/SYSID/RFSS/Site or the
	// per-protocol equivalent), neighbor sites, and band plan. It is the only
	// path by which a caller learns WACN/SYSID/RFSS/Site, which never appear in
	// event payloads (they live in the decoder's network model). nil when the
	// protocol's pipeline does not implement TopologyProvider.
	Topology *TopologySnapshot `json:"topology,omitempty" yaml:"topology,omitempty"`

	// Verdict (nil unless Config.Acceptance supplied).
	Verdict *Verdict `json:"verdict,omitempty" yaml:"verdict,omitempty"`

	// IQTaps is the optional decimated signal capture (channelized IQ +
	// pre-slicer soft samples) populated when Config.CaptureIQ is set. It
	// backs the web visualization views (constellation, spectrogram, PSD,
	// eye diagram). It can be large, so the API/export layer strips it
	// before serializing the summary or running the exporters and serves
	// it from a dedicated endpoint instead.
	IQTaps *IQTaps `json:"iq_taps,omitempty" yaml:"iq_taps,omitempty"`
}

Result is the protocol-agnostic structured outcome of one engine run. It is the single model every exporter (JSON / JSONL / YAML / CSV) and the acceptance harness render — populated identically for all 13 protocols.

func Run

func Run(path string, cfg Config) (*Result, error)

Run decodes the capture at path through the production pipeline for cfg.Protocol and returns the structured Result. It is the batch entry point behind the replay/analyze/test subcommands.

func RunReader added in v0.3.3

func RunReader(r io.Reader, source string, cfg Config) (*Result, error)

RunReader decodes the capture read from r through the production pipeline for cfg.Protocol and returns the structured Result. source is recorded as the Result's provenance (its base name). It is the in-memory sibling of Run: a synthesized signal, an uploaded HTTP body, or any io.Reader can be analyzed without a disk round-trip. AutoTune requires r to also satisfy io.ReadSeeker (an *os.File or *bytes.Reader does).

func RunReaderStream added in v0.3.3

func RunReaderStream(r io.Reader, source string, cfg Config, onEvent func(EventRecord)) (*Result, error)

RunReaderStream is RunReader with a live per-event sink (see RunStream). It is the single decode path RunStream and the in-memory callers share.

func RunStream

func RunStream(path string, cfg Config, onEvent func(EventRecord)) (*Result, error)

RunStream is Run with a live per-event sink: onEvent (when non-nil) is called for every captured EventRecord as it is observed, in stream order. It backs the JSONL exporter and the TUI's live event feed. The full Result is still returned at EOF.

type RotationStat

type RotationStat struct {
	BestDist int `json:"best_dist" yaml:"best_dist"`
	BestPos  int `json:"best_pos" yaml:"best_pos"`
	Hits     int `json:"hits" yaml:"hits"` // positions with distance ≤ tolerance
}

RotationStat summarizes the FSW Hamming-distance landscape for one of the four cyclic dibit rotations.

type SampleDecoder

type SampleDecoder func(buf []byte, out []complex64)

SampleDecoder converts a byte chunk to complex64 IQ in-place into out.

type SampleFormat

type SampleFormat int

SampleFormat is the on-disk IQ encoding of a capture file.

const (
	// FormatU8 is rtl_sdr's 8-bit unsigned interleaved IQ.
	FormatU8 SampleFormat = iota
	// FormatF32 is GNU Radio's interleaved little-endian float32 cfile.
	FormatF32
)

func ParseSampleFormat

func ParseSampleFormat(s string) (SampleFormat, error)

ParseSampleFormat maps a -format flag value to a SampleFormat. It accepts the same spellings the replay subcommand always has (u8, f32, plus the float32/cfile aliases) so existing muscle memory keeps working.

func (SampleFormat) Decoder

func (f SampleFormat) Decoder() (SampleDecoder, int)

Decoder returns the sample decoder + bytes-per-IQ-pair for the format. This is the shared replacement for replay.go's pickSampleDecoder / decodeU8Replay / decodeF32Replay so the replay subcommand and the siglab engine read captures through one implementation.

func (SampleFormat) String

func (f SampleFormat) String() string

String renders the format as the flag value operators type.

type SignalQuality

type SignalQuality struct {
	// SymbolCardinality is 4 for the dibit (4-level) protocols and 2 for
	// the bit (2-level) protocols.
	SymbolCardinality int `json:"symbol_cardinality" yaml:"symbol_cardinality"`
	// SymbolHistogram counts recovered symbols per value (len ==
	// SymbolCardinality). A clean 4-level C4FM control channel sits near
	// 25% per bin; a near-empty bin means the slicer collapsed, a single
	// dominant bin means the signal is below the slicer thresholds.
	SymbolHistogram []int64 `json:"symbol_histogram" yaml:"symbol_histogram"`
	// SymbolHistogramPct is SymbolHistogram normalised to percentages.
	SymbolHistogramPct []float64 `json:"symbol_histogram_pct" yaml:"symbol_histogram_pct"`

	// Raw (pre-DDC) front-end I/Q imbalance. A clean front-end is balanced
	// (≈0 dB gain, ≈0° phase) with image rejection ≳ 40 dB. Populated only
	// when raw IQ was observed.
	IQGainImbalanceDB   float64 `json:"iq_gain_imbalance_db" yaml:"iq_gain_imbalance_db"`
	IQPhaseImbalanceDeg float64 `json:"iq_phase_imbalance_deg" yaml:"iq_phase_imbalance_deg"`
	IQImageRejectionDB  float64 `json:"iq_image_rejection_db" yaml:"iq_image_rejection_db"`
	IQObserved          bool    `json:"iq_observed" yaml:"iq_observed"`

	// DecodeErrorRate is decode-error events per recovered 1000 symbols — a
	// protocol-neutral proxy for FEC stress.
	DecodeErrorRate float64 `json:"decode_error_rate_per_ksym" yaml:"decode_error_rate_per_ksym"`
}

SignalQuality is the protocol-agnostic demod-quality summary the analyzer produces when Config.CollectIQDiag is set. It generalizes the parts of the historical P25-only iqdiag report that apply to every protocol: the recovered-symbol distribution (which exposes a collapsed or mis-calibrated slicer) and the raw front-end I/Q imbalance (the leading cause of an asymmetric eye). The deeper P25-specific FSW/NID landscape lives in P25P1Detail.

type SoftEye

type SoftEye struct {
	SoftSamples  int     `json:"soft_samples" yaml:"soft_samples"`
	SignedMeanDC float64 `json:"signed_mean_dc" yaml:"signed_mean_dc"`
	StdDev       float64 `json:"std_dev" yaml:"std_dev"`
	MeanAbs      float64 `json:"mean_abs" yaml:"mean_abs"`
	MaxAbs       float64 `json:"max_abs" yaml:"max_abs"`
	// MagnitudeHistogram bins |soft| into 10 deciles of MaxAbs — bimodal ⇒ a
	// clean 4-level eye, unimodal-saturated ⇒ everything piled near ±max.
	MagnitudeHistogram [10]int64 `json:"magnitude_histogram" yaml:"magnitude_histogram"`
	// PerDecidedSymbol is the soft distribution grouped by the slicer's
	// decision (rails -3,-1,+1,+3). Clean ⇒ symmetric, |outer| ≈ 3·|inner|.
	PerDecidedSymbol []RailStat `json:"per_decided_symbol" yaml:"per_decided_symbol"`
	// TrueOuterRail is the slicer-independent outer-rail (+3,-3) distribution
	// from the known FSW symbols.
	TrueOuterRail []RailStat `json:"true_outer_rail" yaml:"true_outer_rail"`
	// OuterSpread splits each true outer rail into steady vs post-transition
	// standard deviation — the ISI / timing discriminator.
	OuterSpread []SpreadStat `json:"outer_spread" yaml:"outer_spread"`
	// Verdict points at the dominant outer-spread mechanism.
	Verdict string `json:"verdict" yaml:"verdict"`
}

SoftEye is the structured pre-slicer soft-sample analysis for the P25 deep C4FM path — the part of the historical iqdiag report that worked on the matched-filter output. It exposes the eye-skew the dibit histogram folds away (issue #402): the DC offset, the per-decided-symbol rail distribution, and the slicer-independent true-outer-rail eye with a mechanism verdict.

type SpreadStat

type SpreadStat struct {
	Label     string  `json:"label" yaml:"label"`
	SteadyStd float64 `json:"steady_std" yaml:"steady_std"`
	SteadyN   int     `json:"steady_n" yaml:"steady_n"`
	TransStd  float64 `json:"trans_std" yaml:"trans_std"`
	TransN    int     `json:"trans_n" yaml:"trans_n"`
	Ratio     float64 `json:"ratio" yaml:"ratio"`
}

SpreadStat compares steady-state vs post-transition spread on one outer rail.

type SyncLandscape

type SyncLandscape struct {
	SyncLen        int        `json:"sync_len" yaml:"sync_len"`
	Tolerance      int        `json:"tolerance" yaml:"tolerance"`
	Stats          []SyncStat `json:"stats" yaml:"stats"`
	WinnerVariant  string     `json:"winner_variant" yaml:"winner_variant"`
	WinnerRotation int        `json:"winner_rotation" yaml:"winner_rotation"`
	WinnerHits     int        `json:"winner_hits" yaml:"winner_hits"`
	// ModalSpacing is the most common symbol delta between consecutive clean
	// hits under the winner — a real control channel emits sync at a fixed
	// frame interval, so a single dominant spacing means genuine frames while
	// scattered spacings mean false positives.
	ModalSpacing int `json:"modal_spacing" yaml:"modal_spacing"`
	// contains filtered or unexported fields
}

SyncLandscape summarizes sync-word correlation across a recovered-symbol stream — the generalization of P25's FSW-correlation landscape. It answers "does the demod produce canonical symbols, which sync/polarity aligns the stream, and at what cadence" for any protocol.

type SyncStat

type SyncStat struct {
	Variant  string `json:"variant" yaml:"variant"`
	Rotation int    `json:"rotation" yaml:"rotation"`
	BestDist int    `json:"best_dist" yaml:"best_dist"`
	BestPos  int    `json:"best_pos" yaml:"best_pos"`
	Hits     int    `json:"hits" yaml:"hits"`
}

SyncStat is the best correlation of one (variant, rotation) against the recovered-symbol stream. rotation is the cyclic symbol offset applied to the stream before comparison: for 4-level streams rotation 2 is the discriminator-polarity flip and 1/3 the I/Q-swap rotations; for 2-level streams rotation 1 is bit inversion.

type SyncVariant

type SyncVariant struct {
	Name    string  `json:"name" yaml:"name"`
	Pattern []uint8 `json:"-" yaml:"-"`
}

SyncVariant is one named candidate sync pattern (e.g. one of DMR's 9 ETSI sync words, or a protocol's single FSW), as a slice of symbols (dibits in 0..3, or bits in 0..1).

type SynthOptions

type SynthOptions struct {
	Protocol trunking.Protocol
	Format   SampleFormat
	// Impairments applied to the ideal IQ (SNR, carrier offset, DC spike,
	// I/Q imbalance, multipath). The zero value is a clean capture.
	Impairments demod.Impairments
	// Modulation optionally overrides the fixture's default modulator. Empty
	// uses the per-protocol default. For P25 Phase 1, "lsm" (or "cqpsk")
	// selects the linear π/4-DQPSK (LSM) waveform instead of C4FM, so a
	// capture can exercise the linear CQPSK demod path (issue #492).
	Modulation string
}

SynthOptions configures a synthesis run: which protocol to build, the front-end impairments to overlay on the ideal modulator output, and the on-disk capture format.

type TopologyProvider added in v0.3.5

type TopologyProvider = trunking.TopologyProvider

TopologyProvider is the optional pipeline hook. See trunking.TopologyProvider.

type TopologySnapshot added in v0.3.5

type TopologySnapshot = trunking.TopologySnapshot

TopologySnapshot is the accumulated system topology attached to a Result.Topology. See trunking.TopologySnapshot.

type Verdict

type Verdict struct {
	Pass     bool     `json:"pass" yaml:"pass"`
	Failures []string `json:"failures" yaml:"failures"`
	Checks   []string `json:"checks" yaml:"checks"`
}

Verdict is the pass/fail outcome of grading a Result against an Acceptance.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL