Skip to main content

comnum_spec/blocks/
source.rs

1//! Sources: simulated symbols (`source`) and measured recordings (`capture`, `reference_sync`).
2
3use super::{Block, BuildContext, CaptureState, ConstellationParam, Extension, Frame, Layout, parse_constellation};
4use comnum_core::Error;
5use comnum_core::Result;
6use comnum_core::ensure;
7use comnum_core::rng::Rng;
8use comnum_dsp::signal;
9use comnum_dsp::sync::frame;
10use comnum_mapping::constellation::Constellation;
11use comnum_mapping::constellation::indices_to_bits;
12use comnum_mapping::source as sources;
13use comnum_math::C64;
14use serde_json::Value as Json;
15
16/// The blocks of this module: their documentation and their builders.
17pub(super) fn blocks() -> Vec<Extension> {
18    vec![
19        builtin!(
20            start "source",
21            "Draw the bits and symbols of a block (Gray labels of OptiCommPy).",
22            SourceSpec,
23            needs [],
24            gives [Bits, Symbols, Constellation, Samples],
25            |q, _, _| Ok(Box::new(Source::new(&q)?)),
26        ),
27        builtin!(
28            start "capture",
29            "A measured recording as the source: one segment per trial, the reference sequence not yet aligned.",
30            CaptureSpec,
31            needs [],
32            gives [Bits, Symbols, Constellation, Samples, Capture],
33            |q, _, ctx| Ok(Box::new(Capture::new(&q, ctx)?)),
34        ),
35        builtin!(
36            "reference_sync",
37            "Align the periodic reference of a capture on the received symbols (circular correlation); records offset and quality.",
38            ReferenceSyncSpec,
39            needs [Samples, Constellation, Capture],
40            gives [Bits, Symbols],
41            |q, label, _| Ok(Box::new(ReferenceSync { p: q, label })),
42        ),
43    ]
44}
45
46// ------------------------------------------------------------------- transmitter
47
48block_params! {
49    struct SourceSpec {
50        constellation: ConstellationParam ["16-QAM"],
51            "`16-QAM`, `QPSK`, `8-PSK`, `4-PAM`, `OOK`, {kind, m}, or `gaussian` (circular Gaussian symbols, no decisions); unit energy";
52        n_symbols: usize [4096] >= 1, "symbols per trial";
53        symbol_rate: f64 [1.0] "Bd" > 0, "symbol rate; 1 keeps a normalised time axis";
54        mb_lambda: Option<f64> [absent], "Maxwell-Boltzmann shaping p(x) ∝ exp(-λ|x|²)";
55        pols: usize [1] >= 1 <= 2, "polarisations: 2 draws independent symbols for x and y (dual-polarisation optics)";
56    }
57}
58
59struct Source {
60    c: Option<Constellation>,
61    n: usize,
62    symbol_rate: f64,
63    px: Option<Vec<f64>>,
64    pols: usize,
65}
66
67impl Source {
68    fn new(p: &SourceSpec) -> Result<Source> {
69        let c = match p.constellation.json() {
70            Json::String(s) if s.eq_ignore_ascii_case("gaussian") => None,
71            other => Some(parse_constellation(other)?),
72        };
73        ensure(c.is_some() || p.mb_lambda.is_none(), || "source: mb_lambda needs a constellation".into())?;
74        let px = match (&c, p.mb_lambda) {
75            (Some(c), Some(l)) => Some(sources::maxwell_boltzmann(c, l)),
76            _ => None,
77        };
78        Ok(Source { c, n: p.n_symbols, symbol_rate: p.symbol_rate, px, pols: p.pols })
79    }
80}
81
82impl Block for Source {
83    fn apply(&self, s: &mut Frame, rng: &mut Rng) -> Result<()> {
84        let total = self.n * self.pols;
85        let (idx, x, bits) = match &self.c {
86            Some(c) => {
87                let (idx, x) = sources::symbols(c, total, self.px.as_deref(), rng)?;
88                let bits = indices_to_bits(&idx, c.bits_per_symbol());
89                (idx, x, bits)
90            }
91            None => (Vec::new(), (0..total).map(|_| rng.complex_gaussian()).collect(), Vec::new()),
92        };
93        *s = Frame {
94            trial: s.trial,
95            bits,
96            indices: idx,
97            samples: x[..self.n].to_vec(),
98            antennas: if self.pols == 2 { vec![x[self.n..].to_vec()] } else { Vec::new() },
99            symbols: x,
100            sps: 1.0,
101            symbol_rate: self.symbol_rate,
102            excluded: vec![false; total],
103            constellation: self.c.clone(),
104            pmf: self.px.clone(),
105            layout: Layout { polarisations: self.pols == 2, ..Layout::default() },
106            ..Frame::default()
107        };
108        Ok(())
109    }
110}
111
112// ------------------------------------------------------------------ measured data
113
114block_params! {
115    struct CaptureSpec {
116        file: String [required],
117            "SigMF (.sigmf-meta/.sigmf-data or a .sigmf archive), NPY (.npy) or raw samples, relative to the specification";
118        channel: usize [0], "the channel of a multi-channel SigMF recording";
119        datatype: Option<String> [absent], "data type of a raw file: cf32_le, ci16_le, cf64_le, ci8, cu8, …";
120        fs: Option<f64> [absent "from file"] "Hz" > 0,
121            "sampling rate; required when the file does not record it";
122        symbol_rate: f64 [required] "Bd" > 0, "symbol rate";
123        constellation: ConstellationParam ["16-QAM"], "the transmitted constellation";
124        reference: String [required],
125            "one period of the transmitted symbols (.npy complex) or indices (.npy or text integers)";
126        segment: Option<usize> [absent "whole file"], "samples per trial";
127        offset: Offset ["random"], "first sample of a segment: random (drawn per trial) or an integer";
128    }
129}
130
131json_param! {
132    /// The first sample of a segment of `capture`: `random` or an integer.
133    Offset,
134    serde_json::json!({"anyOf": [{"type": "string", "enum": ["random"]}, {"type": "integer", "minimum": 0}]})
135}
136
137struct Capture {
138    samples: std::sync::Arc<Vec<C64>>,
139    fs: f64,
140    symbol_rate: f64,
141    c: Constellation,
142    reference: Vec<usize>,
143    segment: usize,
144    offset: Option<usize>,
145}
146
147/// One period of transmitted indices from a file: complex symbols (mapped to the nearest
148/// points after normalisation) or integer indices, in NPY or as text integers.
149fn read_reference(path: &std::path::Path, c: &Constellation) -> Result<Vec<usize>> {
150    use crate::io::{NpyData, read_npy};
151    let idx: Vec<i64> = if path.extension().is_some_and(|e| e == "npy") {
152        match read_npy(path)?.data {
153            NpyData::Int(v) => v,
154            NpyData::Complex(v) => return Ok(c.decide(&signal::pnorm(&v))),
155            NpyData::Real(v) => {
156                return Ok(c.decide(&signal::pnorm(&v.iter().map(|&x| C64::new(x, 0.0)).collect::<Vec<_>>())));
157            }
158        }
159    } else {
160        std::fs::read_to_string(path)?
161            .split(|ch: char| ch.is_whitespace() || ch == ',')
162            .filter(|t| !t.is_empty())
163            .map(|t| t.parse::<i64>().map_err(|_| Error::Config(format!("{}: {t:?} is not an index", path.display()))))
164            .collect::<Result<_>>()?
165    };
166    idx.iter()
167        .map(|&i| {
168            usize::try_from(i)
169                .ok()
170                .filter(|&i| i < c.order())
171                .ok_or_else(|| Error::Config(format!("{}: index {i} outside the {} points", path.display(), c.order())))
172        })
173        .collect()
174}
175
176impl Capture {
177    fn new(p: &CaptureSpec, ctx: &BuildContext) -> Result<Capture> {
178        let dt = p.datatype.as_deref().map(crate::io::Datatype::parse).transpose()?;
179        let crate::io::Samples { samples, sample_rate: fs_file } =
180            crate::io::read_samples(ctx.path(&p.file), dt, p.channel)?;
181        let fs =
182            p.fs.or(fs_file)
183                .ok_or_else(|| Error::Config(format!("capture {}: the file has no sampling rate, give fs", p.file)))?;
184        ensure(p.symbol_rate > 0.0 && fs >= p.symbol_rate, || "capture: need fs ≥ symbol_rate > 0".into())?;
185        let c = parse_constellation(p.constellation.json())?;
186        let reference = read_reference(&ctx.path(&p.reference), &c)?;
187        ensure(!reference.is_empty(), || "capture: the reference is empty".into())?;
188        let segment = p.segment.unwrap_or(samples.len()).min(samples.len());
189        ensure(segment > 0, || format!("capture {}: no samples", p.file))?;
190        let offset = match &p.offset.0 {
191            Json::String(s) if s == "random" => None,
192            Json::Number(n) => {
193                let o =
194                    n.as_u64().ok_or_else(|| Error::Config("capture: offset must be random or an integer".into()))?;
195                ensure(o as usize + segment <= samples.len(), || "capture: the segment ends after the file".into())?;
196                Some(o as usize)
197            }
198            other => return Err(Error::Config(format!("capture: offset must be random or an integer, got {other}"))),
199        };
200        Ok(Capture {
201            samples: std::sync::Arc::new(samples),
202            fs,
203            symbol_rate: p.symbol_rate,
204            c,
205            reference,
206            segment,
207            offset,
208        })
209    }
210}
211
212impl Block for Capture {
213    fn apply(&self, s: &mut Frame, rng: &mut Rng) -> Result<()> {
214        let start = self.offset.unwrap_or_else(|| rng.below(self.samples.len() - self.segment + 1));
215        let sps = self.fs / self.symbol_rate;
216        let n_sym = (self.segment as f64 / sps).floor() as usize;
217        *s = Frame {
218            trial: s.trial,
219            samples: self.samples[start..start + self.segment].to_vec(),
220            sps,
221            symbol_rate: self.symbol_rate,
222            constellation: Some(self.c.clone()),
223            capture: CaptureState { reference: self.reference.clone(), unaligned: true },
224            ..Frame::default()
225        };
226        let p = self.reference.len();
227        s.set_indices((0..n_sym).map(|k| self.reference[k % p]).collect());
228        Ok(())
229    }
230}
231
232block_params! {
233    struct ReferenceSyncSpec {
234        window: usize [4096], "symbols of the correlation (at most one period)";
235        start: Option<usize> [absent "middle"], "first symbol of the window, or middle";
236        symmetry: u32 [4], "rotational symmetry of the constellation; 0 removes the exact phase";
237    }
238}
239
240struct ReferenceSync {
241    p: ReferenceSyncSpec,
242    label: String,
243}
244
245impl Block for ReferenceSync {
246    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
247        ensure(!s.capture.reference.is_empty(), || "reference_sync needs a capture as the source".into())?;
248        s.require_one_sps("reference_sync")?;
249        let c = s.constellation("reference_sync")?.clone();
250        let refs = c.symbols(&s.capture.reference);
251        let n = s.samples.len();
252        let l = self.p.window.min(refs.len()).min(n);
253        let start = self.p.start.unwrap_or((n - l) / 2);
254        let symmetry = if self.p.symmetry == 0 { None } else { Some(self.p.symmetry) };
255        let r = frame::periodic_offset(&signal::pnorm(&s.samples), &refs, start, l, symmetry)?;
256        let p = s.capture.reference.len();
257        s.samples.iter_mut().for_each(|v| *v *= r.rotation);
258        let idx = (0..n).map(|k| s.capture.reference[(k + r.offset) % p]).collect();
259        s.set_indices(idx);
260        s.capture.unaligned = false;
261        s.record(&self.label, "offset", r.offset as f64);
262        s.record(&self.label, "quality", r.quality);
263        Ok(())
264    }
265}