1use crate::detection::{Estimates, FLOOR};
70use comnum_core::rng::Rng;
71use comnum_core::signal::Symbols;
72use comnum_core::traits::Precode;
73use comnum_core::{Error, Result, ensure};
74use comnum_math::C64;
75use comnum_math::linalg::CMat;
76use ndarray::{Array2, ArrayView2};
77
78#[derive(Clone, Debug, PartialEq)]
80pub struct Ostbc {
81 name: String,
82 n_tx: usize,
83 n_slots: usize,
84 a: Vec<CMat>,
85 b: Vec<CMat>,
86 scale: f64,
87}
88
89impl Ostbc {
90 pub fn from_generator(
97 name: &str,
98 n_tx: usize,
99 n_slots: usize,
100 n_symbols: usize,
101 generator: impl Fn(&[C64]) -> CMat,
102 ) -> Result<Ostbc> {
103 ensure(n_tx >= 1 && n_slots >= 1 && n_symbols >= 1, || "empty code".into())?;
104 let basis = |k: usize, v: C64| {
105 let mut s = vec![C64::new(0.0, 0.0); n_symbols];
106 s[k] = v;
107 generator(&s)
108 };
109 let a: Vec<CMat> = (0..n_symbols).map(|k| basis(k, C64::new(1.0, 0.0))).collect();
110 let b: Vec<CMat> = (0..n_symbols).map(|k| basis(k, C64::new(0.0, 1.0))).collect();
111 ensure(a.iter().chain(&b).all(|m| m.dim() == (n_slots, n_tx)), || {
112 format!("the codewords must be {n_slots}×{n_tx}")
113 })?;
114 let energy: f64 = a.iter().chain(&b).map(|m| m.iter().map(C64::norm_sqr).sum::<f64>()).sum::<f64>() / 2.0;
115 ensure(energy > 0.0, || "the code sends nothing".into())?;
116 let code = Ostbc { name: name.into(), n_tx, n_slots, a, b, scale: (n_slots as f64 / energy).sqrt() };
117 let mut rng = Rng::new(0x0057_bc00);
119 for _ in 0..3 {
120 let h = Array2::from_shape_fn((2, n_tx), |_| rng.complex_gaussian());
121 let cols = code.equivalent(&h.view());
122 let norms: Vec<f64> = cols.iter().map(|u| u.iter().map(|x| x * x).sum()).collect();
123 for i in 0..cols.len() {
124 for j in 0..i {
125 let dot: f64 = cols[i].iter().zip(&cols[j]).map(|(x, y)| x * y).sum();
126 ensure(dot.abs() <= 1e-9 * (norms[i] * norms[j]).sqrt(), || {
127 format!("the code {name} is not orthogonal")
128 })?;
129 }
130 }
131 }
132 Ok(code)
133 }
134
135 pub fn alamouti() -> Ostbc {
137 Ostbc::from_generator("Alamouti", 2, 2, 2, |s| ndarray::array![[s[0], s[1]], [-s[1].conj(), s[0].conj()]])
138 .unwrap_or_else(|_| unreachable!("Alamouti's code is orthogonal"))
139 }
140
141 pub fn rate_3_4(n_tx: usize) -> Result<Ostbc> {
147 ensure(n_tx == 3 || n_tx == 4, || format!("the rate-3/4 code has 3 or 4 antennas, not {n_tx}"))?;
148 Ostbc::from_generator(&format!("rate 3/4, {n_tx} antennas"), n_tx, 4, 3, move |s| {
149 let z = C64::new(0.0, 0.0);
150 let full = ndarray::array![
151 [s[0], s[1], s[2], z],
152 [-s[1].conj(), s[0].conj(), z, s[2]],
153 [-s[2].conj(), z, s[0].conj(), -s[1]],
154 [z, -s[2].conj(), s[1].conj(), s[0]],
155 ];
156 full.slice_axis(ndarray::Axis(1), ndarray::Slice::from(0..n_tx)).to_owned()
157 })
158 }
159
160 pub fn name(&self) -> &str {
162 &self.name
163 }
164
165 pub fn n_tx(&self) -> usize {
167 self.n_tx
168 }
169
170 pub fn n_slots(&self) -> usize {
172 self.n_slots
173 }
174
175 pub fn n_symbols(&self) -> usize {
177 self.a.len()
178 }
179
180 pub fn rate(&self) -> f64 {
182 self.n_symbols() as f64 / self.n_slots as f64
183 }
184
185 fn codeword(&self, s: &[C64]) -> CMat {
187 let mut x = Array2::<C64>::zeros((self.n_slots, self.n_tx));
188 for (k, sk) in s.iter().enumerate() {
189 x = x + self.a[k].mapv(|v| v * sk.re * self.scale) + self.b[k].mapv(|v| v * sk.im * self.scale);
190 }
191 x
192 }
193
194 pub fn encode(&self, s: &[C64]) -> Result<CMat> {
201 let k = self.n_symbols();
202 if s.len() % k != 0 {
203 return Err(Error::Shape(format!("{} symbols is not a multiple of {k} per block", s.len())));
204 }
205 let blocks = s.len() / k;
206 let mut out = Array2::<C64>::zeros((self.n_tx, blocks * self.n_slots));
207 for (b, chunk) in s.chunks(k).enumerate() {
208 let x = self.codeword(chunk);
209 for t in 0..self.n_slots {
210 for a in 0..self.n_tx {
211 out[[a, b * self.n_slots + t]] = x[[t, a]];
212 }
213 }
214 }
215 Ok(out)
216 }
217
218 fn equivalent(&self, h: &ArrayView2<'_, C64>) -> Vec<Vec<f64>> {
221 self.a
222 .iter()
223 .chain(&self.b)
224 .map(|m| {
225 let v = h.dot(&m.t());
226 v.iter().map(|x| x.re * self.scale).chain(v.iter().map(|x| x.im * self.scale)).collect()
227 })
228 .collect()
229 }
230
231 pub fn decode(&self, h: &ArrayView2<'_, C64>, y: &ArrayView2<'_, C64>, noise_var: f64) -> Result<Estimates> {
240 if y.ncols() % self.n_slots != 0 {
241 return Err(Error::Shape(format!("{} channel uses is not a multiple of {}", y.ncols(), self.n_slots)));
242 }
243 self.check_channel(h, y.nrows())?;
244 let blocks = y.ncols() / self.n_slots;
245 let cols = self.equivalent(h);
246 let mut out = Estimates::default();
247 for blk in 0..blocks {
248 self.decode_block(&cols, y, blk, noise_var, &mut out)?;
249 }
250 Ok(out)
251 }
252
253 pub fn decode_blocks(&self, hs: &[CMat], y: &ArrayView2<'_, C64>, noise_var: f64) -> Result<Estimates> {
258 let t = self.n_slots;
259 if y.ncols() != hs.len() * t {
260 return Err(Error::Shape(format!("{} channel uses for {} blocks of {t}", y.ncols(), hs.len())));
261 }
262 let mut out = Estimates::default();
263 for (blk, h) in hs.iter().enumerate() {
264 self.check_channel(&h.view(), y.nrows())?;
265 self.decode_block(&self.equivalent(&h.view()), y, blk, noise_var, &mut out)?;
266 }
267 Ok(out)
268 }
269
270 fn check_channel(&self, h: &ArrayView2<'_, C64>, n_rx: usize) -> Result<()> {
272 if h.dim() != (n_rx, self.n_tx) {
273 return Err(Error::Shape(format!(
274 "a {}×{} channel for {n_rx} receive and {} transmit antennas",
275 h.nrows(),
276 h.ncols(),
277 self.n_tx
278 )));
279 }
280 Ok(())
281 }
282
283 fn decode_block(
286 &self,
287 cols: &[Vec<f64>],
288 y: &ArrayView2<'_, C64>,
289 blk: usize,
290 noise_var: f64,
291 out: &mut Estimates,
292 ) -> Result<()> {
293 let (t, k) = (self.n_slots, self.n_symbols());
294 let block: Vec<C64> =
295 (0..y.nrows()).flat_map(|r| (0..t).map(move |c| (r, c))).map(|(r, c)| y[[r, blk * t + c]]).collect();
296 let yr: Vec<f64> = block.iter().map(|x| x.re).chain(block.iter().map(|x| x.im)).collect();
297 let project = |u: &[f64]| -> Result<(f64, f64)> {
298 let n2: f64 = u.iter().map(|x| x * x).sum();
299 if n2.is_nan() || n2 <= FLOOR {
300 return Err(Error::Numerical("zero channel".into()));
301 }
302 Ok((u.iter().zip(&yr).map(|(a, b)| a * b).sum::<f64>() / n2, n2))
303 };
304 for i in 0..k {
305 let (re, nu) = project(&cols[i])?;
306 let (im, nw) = project(&cols[k + i])?;
307 out.symbols.push(C64::new(re, im));
308 out.noise_var.push(noise_var / 2.0 * (1.0 / nu + 1.0 / nw));
309 }
310 Ok(())
311 }
312}
313
314impl Precode for Ostbc {
317 fn precode_into(&self, x: &Symbols, out: &mut Symbols) -> Result<()> {
318 ensure(x.n_streams() == 1, || format!("a space-time code encodes one stream, not {}", x.n_streams()))?;
319 out.assign(&self.encode(x.stream(0))?);
320 Ok(())
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use comnum_math::linalg::gram;
328
329 fn random_symbols(n: usize, rng: &mut Rng) -> Vec<C64> {
330 (0..n).map(|_| rng.complex_gaussian()).collect()
331 }
332
333 #[test]
334 fn codewords_are_orthogonal_with_unit_average_energy() {
335 let mut rng = Rng::new(1);
336 for code in [Ostbc::alamouti(), Ostbc::rate_3_4(3).unwrap(), Ostbc::rate_3_4(4).unwrap()] {
337 let s = random_symbols(code.n_symbols(), &mut rng);
338 let x = code.codeword(&s);
339 let g = gram(&x.view());
340 let e: f64 = s.iter().map(C64::norm_sqr).sum();
341 for i in 0..code.n_tx() {
342 for j in 0..code.n_tx() {
343 let want = if i == j { code.scale * code.scale * e } else { 0.0 };
344 assert!((g[[i, j]] - want).norm() < 1e-12, "{}", code.name());
345 }
346 }
347 let n = 20_000;
349 let p: f64 = (0..n)
350 .map(|_| {
351 code.codeword(&random_symbols(code.n_symbols(), &mut rng)).iter().map(C64::norm_sqr).sum::<f64>()
352 })
353 .sum::<f64>()
354 / (f64::from(n) * code.n_slots() as f64);
355 assert!((p - 1.0).abs() < 0.03, "{}: {p}", code.name());
356 }
357 assert!(Ostbc::rate_3_4(2).is_err());
358 assert!(Ostbc::from_generator("bad", 2, 1, 2, |s| ndarray::array![[s[0], s[1]]]).is_err());
360 }
361
362 #[test]
363 fn noiseless_decoding_recovers_the_symbols_over_any_channel() {
364 let mut rng = Rng::new(2);
365 for code in [Ostbc::alamouti(), Ostbc::rate_3_4(3).unwrap(), Ostbc::rate_3_4(4).unwrap()] {
366 for nr in [1, 2, 3] {
367 let s = random_symbols(code.n_symbols() * 5, &mut rng);
368 let x = code.encode(&s).unwrap();
369 let hs: Vec<CMat> =
370 (0..5).map(|_| Array2::from_shape_fn((nr, code.n_tx()), |_| rng.complex_gaussian())).collect();
371 let t = code.n_slots();
372 let mut y = Array2::<C64>::zeros((nr, x.ncols()));
373 for (b, h) in hs.iter().enumerate() {
374 for c in 0..t {
375 y.column_mut(b * t + c).assign(&h.dot(&x.column(b * t + c)));
376 }
377 }
378 let Estimates { symbols: est, noise_var: var } = code.decode_blocks(&hs, &y.view(), 0.1).unwrap();
379 assert!(est.iter().zip(&s).all(|(a, b)| (a - b).norm() < 1e-10), "{} nr={nr}", code.name());
380 let h0: f64 = hs[0].iter().map(C64::norm_sqr).sum();
382 assert!((var[0] - 0.1 / (code.scale * code.scale * h0)).abs() < 1e-12);
383 }
384 }
385 }
386
387 #[test]
388 fn a_constant_channel_decodes_as_one_channel_per_block() {
389 let mut rng = Rng::new(4);
390 for code in [Ostbc::alamouti(), Ostbc::rate_3_4(4).unwrap()] {
391 let h = Array2::from_shape_fn((3, code.n_tx()), |_| rng.complex_gaussian());
392 let y = Array2::from_shape_fn((3, 6 * code.n_slots()), |_| rng.complex_gaussian());
393 let once = code.decode(&h.view(), &y.view(), 0.2).unwrap();
394 let per_block = code.decode_blocks(&vec![h.clone(); 6], &y.view(), 0.2).unwrap();
396 let bits = |e: &Estimates| -> Vec<u64> {
397 e.symbols
398 .iter()
399 .flat_map(|z| [z.re.to_bits(), z.im.to_bits()])
400 .chain(e.noise_var.iter().map(|v| v.to_bits()))
401 .collect()
402 };
403 assert_eq!(bits(&once), bits(&per_block));
404 }
405 }
406
407 #[test]
408 fn alamouti_noise_matches_its_prediction() {
409 let code = Ostbc::alamouti();
410 let mut rng = Rng::new(3);
411 let h = Array2::from_shape_fn((2, 2), |_| rng.complex_gaussian());
412 let n = 20_000;
413 let s = random_symbols(2 * n, &mut rng);
414 let x = code.encode(&s).unwrap();
415 let s2: f64 = 0.3;
416 let y = h.dot(&x).mapv(|v| v + rng.complex_gaussian() * s2.sqrt());
417 let Estimates { symbols: est, noise_var: var } = code.decode(&h.view(), &y.view(), s2).unwrap();
418 let mse: f64 = est.iter().zip(&s).map(|(a, b)| (a - b).norm_sqr()).sum::<f64>() / s.len() as f64;
419 assert!((mse / var[0] - 1.0).abs() < 0.03, "{mse} vs {}", var[0]);
420 }
421}
422
423#[cfg(test)]
425mod literature {
426 use super::*;
427 use comnum_core::rng::Rng;
428
429 #[test]
436 fn tarokh_1999_rate_3_4_designs_are_orthogonal() {
437 let r2 = std::f64::consts::SQRT_2;
438 let eq40 = |s: &[C64]| {
439 let (x1, x2, x3) = (s[0], s[1], s[2]);
440 let (c1, c2, c3) = (x1.conj(), x2.conj(), x3.conj());
441 ndarray::array![
442 [x1, x2, x3 / r2, x3 / r2],
443 [-c2, c1, x3 / r2, -x3 / r2],
444 [c3 / r2, c3 / r2, (-x1 - c1 + x2 - c2) / 2.0, (-x2 - c2 + x1 - c1) / 2.0],
445 [c3 / r2, -c3 / r2, (x2 + c2 + x1 - c1) / 2.0, -(x1 + c1 + x2 - c2) / 2.0],
446 ]
447 };
448 let eq39 = move |s: &[C64]| eq40(s).slice_axis(ndarray::Axis(1), ndarray::Slice::from(0..3)).to_owned();
449 let mut rng = Rng::new(1999);
450 for (n_tx, g) in [(4, Box::new(eq40) as Box<dyn Fn(&[C64]) -> CMat>), (3, Box::new(eq39))] {
451 let code = Ostbc::from_generator("Tarokh 1999", n_tx, 4, 3, g).unwrap();
452 assert!((code.rate() - 0.75).abs() < 1e-12);
453 let s: Vec<C64> = (0..12).map(|_| rng.complex_gaussian()).collect();
454 let x = code.encode(&s).unwrap();
455 let h = Array2::from_shape_fn((2, n_tx), |_| rng.complex_gaussian());
456 let y = h.dot(&x);
457 let decoded = code.decode(&h.view(), &y.view(), 1e-12).unwrap().symbols;
458 assert!(decoded.iter().zip(&s).all(|(a, b)| (a - b).norm() < 1e-9), "{n_tx} antennas");
459 }
460 }
461}