Skip to main content

comnum_mimo/
stbc.rs

1//! Orthogonal space-time block codes (OSTBC).
2//!
3//! A block code sends $K$ symbols over $T$ channel uses from $n_{t}$ antennas: the
4//! $T \times n_{t}$ codeword $X(s)$ is linear in the real and imaginary parts of the
5//! symbols,
6//!
7//! $$
8//! X(s) = c \sum_{k=1}^{K} \left(\Re(s_{k})\\, A_{k} + \Im(s_{k})\\, B_{k}\right),
9//! $$
10//!
11//! with the dispersion matrices $A_{k} = X(e_{k})$ and $B_{k} = X(j e_{k})$ and the scale
12//! $c$ that makes the average transmitted energy one per channel use (each antenna
13//! sends $1/n_{t}$ of it on average). The code is *orthogonal* when
14//! $X^{H} X \propto \lVert s \rVert^{2} I$: then, whatever the channel, the received block
15//! $Y = H X^{T} + W$ ($n_{r} \times T$) decouples into one scalar channel per symbol, and
16//! linear combining is maximum likelihood. Each estimate is
17//! $\hat s_{k} = s_{k} + n_{k}$ with the noise variance
18//! $\sigma^{2} / (c^{2} \lVert H \rVert_{F}^{2})$ for the codes of unit-norm columns, a
19//! diversity of $n_{t} n_{r}$.
20//!
21//! | Code | $n_{t}$ | $T$ | $K$ | Rate |
22//! |---|---|---|---|---|
23//! | [`Ostbc::alamouti`] | 2 | 2 | 2 | 1 |
24//! | [`Ostbc::rate_3_4`]`(3)` | 3 | 4 | 3 | 3/4 |
25//! | [`Ostbc::rate_3_4`]`(4)` | 4 | 4 | 3 | 3/4 |
26//!
27//! The rows of a codeword are the channel uses, its columns the antennas:
28//!
29//! $$
30//! X_{\mathrm{Alamouti}} = \begin{pmatrix} s_{1} & s_{2} \\\\ -s_{2}^{*} & s_{1}^{*} \end{pmatrix},
31//! \qquad
32//! X_{3/4} = \begin{pmatrix}
33//!   s_{1} & s_{2} & s_{3} & 0 \\\\
34//!   -s_{2}^{*} & s_{1}^{*} & 0 & s_{3} \\\\
35//!   -s_{3}^{*} & 0 & s_{1}^{*} & -s_{2} \\\\
36//!   0 & -s_{3}^{*} & s_{2}^{*} & s_{1}
37//! \end{pmatrix}
38//! $$
39//!
40//! (the three-antenna code keeps the first three columns).
41//!
42//! # Examples
43//! ```
44//! use comnum_mimo::detection::Estimates;
45//! use comnum_mimo::stbc::Ostbc;
46//! use comnum_math::C64;
47//! use ndarray::array;
48//! let code = Ostbc::alamouti();
49//! let s = [C64::new(1.0, 1.0), C64::new(-1.0, 1.0)];
50//! let x = code.encode(&s)?;                                  // 2 antennas × 2 uses
51//! let h = array![[C64::new(0.8, -0.3), C64::new(-0.2, 1.1)]]; // one receive antenna
52//! let Estimates { symbols: est, noise_var: var } = code.decode(&h.view(), &h.dot(&x).view(), 0.01)?;
53//! assert!(est.iter().zip(&s).all(|(a, b)| (a - b).norm() < 1e-12));
54//! // the noise variance of every estimate is σ²/(c² ‖H‖²), with c² = 1/2 for Alamouti
55//! let h2: f64 = h.iter().map(|v| v.norm_sqr()).sum();
56//! assert!((var[0] - 0.01 / (0.5 * h2)).abs() < 1e-15);
57//! # Ok::<(), comnum_core::Error>(())
58//! ```
59//!
60//! # References
61//!
62//! 1. S. M. Alamouti, "A simple transmit diversity technique for wireless
63//!    communications," *IEEE J. Sel. Areas Commun.*, 16(8), 1998.
64//! 2. V. Tarokh, H. Jafarkhani and A. R. Calderbank, "Space-time block codes from
65//!    orthogonal designs," *IEEE Trans. Inf. Theory*, 45(5), 1999.
66//! 3. O. Tirkkonen and A. Hottinen, "Square-matrix embeddable space-time block codes
67//!    for complex signal constellations," *IEEE Trans. Inf. Theory*, 48(2), 2002.
68
69use 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/// An orthogonal space-time block code.
79#[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    /// The code whose codeword is `generator(s)` ($T \times n_{t}$, linear in the real
91    /// and imaginary parts of the $K$ symbols).
92    ///
93    /// # Errors
94    /// [`Error::Config`] if the generator does not return `n_slots × n_tx` matrices or
95    /// the code is not orthogonal.
96    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        // orthogonality: the real equivalent channel has orthogonal columns for any H
118        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    /// Alamouti's code: two antennas, rate one.
136    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    /// The rate-3/4 code for three or four antennas (three symbols over four channel
142    /// uses).
143    ///
144    /// # Errors
145    /// [`Error::Config`] for another number of antennas.
146    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    /// Name of the code.
161    pub fn name(&self) -> &str {
162        &self.name
163    }
164
165    /// Transmit antennas $n_{t}$.
166    pub fn n_tx(&self) -> usize {
167        self.n_tx
168    }
169
170    /// Channel uses per block $T$.
171    pub fn n_slots(&self) -> usize {
172        self.n_slots
173    }
174
175    /// Symbols per block $K$.
176    pub fn n_symbols(&self) -> usize {
177        self.a.len()
178    }
179
180    /// Symbols per channel use $K/T$.
181    pub fn rate(&self) -> f64 {
182        self.n_symbols() as f64 / self.n_slots as f64
183    }
184
185    /// The codeword of one block, $T \times n_{t}$.
186    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    /// Encode symbols (a multiple of $K$) into the signal of the antennas,
195    /// $n_{t} \times T \cdot$ blocks (antennas along the rows, channel uses along the
196    /// columns).
197    ///
198    /// # Errors
199    /// [`Error::Shape`] if the number of symbols is not a multiple of $K$.
200    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    /// The columns of the real equivalent channel: $\mathrm{rv}(H A_{k}^{T})$ then
219    /// $\mathrm{rv}(H B_{k}^{T})$, scaled ($\mathrm{rv}$ stacks real and imaginary parts).
220    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    /// Decode received blocks that share the channel `h` ($n_{r} \times n_{t}$): `y` is
232    /// $n_{r} \times T \cdot$ blocks. Returns the estimate of every symbol and its noise
233    /// variance (the receiver noise has the variance `noise_var` per antenna); the
234    /// equivalent channel is computed once for all the blocks.
235    ///
236    /// # Errors
237    /// [`Error::Shape`] for inconsistent dimensions, [`Error::Numerical`] for a zero
238    /// channel.
239    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    /// [`Ostbc::decode`] with one channel matrix per block.
254    ///
255    /// # Errors
256    /// As [`Ostbc::decode`].
257    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    /// [`Error::Shape`] unless `h` is $n_{r} \times n_{t}$.
271    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    /// The estimates of block `blk` of `y` by the projections on the columns `cols` of
284    /// its real equivalent channel, appended to `out`.
285    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
314/// The code as a precoder: the first stream of `x` (a multiple of $K$ symbols) becomes
315/// the $n_{t}$ streams of the antennas.
316impl 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            // E‖X‖²/T = 1 for unit-energy symbols
348            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        // a non-orthogonal design is rejected
359        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                // the noise variance is σ²/(c² ‖H‖²) for every symbol of a block
381                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            // the former decode: the channel cloned for every block
395            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/// Checks against the published designs (`references/`).
424#[cfg(test)]
425mod literature {
426    use super::*;
427    use comnum_core::rng::Rng;
428
429    /// V. Tarokh, H. Jafarkhani and A. R. Calderbank, "Space-time block codes from
430    /// orthogonal designs," IEEE Trans. Inf. Theory 45(5), 1999, eqs. (39) and (40)
431    /// (pp. 1464-1465, checked term by term against the paper): the rate-3/4 designs for
432    /// three and four antennas, (39) being the first three columns of (40). The library's
433    /// rate-3/4 code is another (sporadic) design; the paper's are accepted by the
434    /// orthogonality check of `from_generator` and decoded exactly without noise.
435    #[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}