1use 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
16pub(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
46block_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
112block_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 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
147fn 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}