Wavelength-division multiplexing#

Wavelength-division multiplexing: the frequency plan and the two blocks.

Three objects, and the split between them is the point:

  • WDMGrid – a frozen value object holding where the channels are, in hertz. It carries no signal and no sampling rate, exactly as CarrierAllocation carries a subcarrier plan and no OFDM symbol (D15/D18).

  • WDMMultiplexer – shifts each channel to its slot and sums.

  • WDMDemultiplexer – shifts one slot back to baseband and filters.

Naming and units follow the convention shared by the software-radio libraries (GNU Radio’s freq_xlating_fir_filter, pfb_synthesizer_ccf / pfb_channelizer_ccf): frequencies are absolute, in hertz, and every block that needs one takes an explicit sample rate ``fs``. Nothing here is normalized behind the user’s back. The multiplexer is the synthesis bank, the demultiplexer the analysis bank; the optical names are kept because that is what the field calls them, and the radio names are given here so the equivalence is visible.

Rate conversion is deliberately not part of these blocks. A channel enters the multiplexer already sampled at the composite rate, and leaves the demultiplexer at that same rate; use Upsampler and Downsampler around them. That is what keeps a WDM chain readable: one block, one job.

class WDMGrid(frequencies_Hz: tuple[float, ...], *, bandwidth_Hz: float, center_Hz: float | None = None, standard: str = 'custom', reference: str = '')[source]#

Where the WDM channels sit, in hertz (decision D44).

Signal Model#

A grid of \(C\) channels is the set of absolute centre frequencies \(\nu_c\) and the occupied bandwidth \(B\) each of them uses. What the blocks actually mix with is the offset from the composite band centre \(\nu_0\):

\[f_c = \nu_c - \nu_0, \qquad c = 0, \dots, C-1\]

A grid is representable at a sampling rate \(f_s\) when every channel fits inside the band, edges included:

\[\max_c |f_c| + \frac{B}{2} \leq \frac{f_s}{2}\]
param frequencies_Hz:

Absolute centre frequencies \(\nu_c\), strictly ascending.

type frequencies_Hz:

sequence of float

param bandwidth_Hz:

Occupied bandwidth \(B\) of one channel, in Hz. Equal for all channels: a per-channel bandwidth would make the grid a different object (a flexible-grid allocation), not this one.

type bandwidth_Hz:

float, keyword-only

param center_Hz:

Composite band centre \(\nu_0\), i.e. the frequency the sampled waveform sits at. Defaults to the midpoint of the outermost channels, which is what minimizes the required \(f_s\).

type center_Hz:

float, optional, keyword-only

param standard:

Name of the plan, e.g. "ITU-T G.694.1 50 GHz".

type standard:

str, keyword-only

param reference:

Clause the numbers were copied from (same provenance rule as D15/D20).

type reference:

str, keyword-only

raises ValueError:

If fewer than one channel is given, if the frequencies are not strictly ascending, if the bandwidth is not positive, or if the bandwidth exceeds the tightest channel spacing – overlapping channels cannot be separated by any linear filter, so the grid is rejected at construction rather than producing crosstalk later.

References

ITU-T Recommendation G.694.1 (2020), Spectral grids for WDM applications: DWDM frequency grid.

G. P. Agrawal, Fiber-Optic Communication Systems, 4th ed., Wiley, 2010, Chapter 6.

Examples

>>> grid = WDMGrid.uniform(5, spacing_Hz=50e9, bandwidth_Hz=32e9)
>>> grid.n_channels, float(grid.offsets_Hz[0]) / 1e9, grid.min_fs / 1e9
(5, -100.0, 232.0)
property n_channels: int#

Number of channels \(C\).

property offsets_Hz: ndarray#

Offsets \(f_c\) from the composite centre, in Hz.

property spacing_Hz: float | None#

Tightest spacing between two neighbours; None if C == 1.

property guard_Hz: float | None#

Unused band between two neighbours; None if C == 1.

Zero is the Nyquist-WDM limit (channels touching); negative is impossible here, __post_init__ rejects it.

property min_fs: float#

Smallest sampling rate carrying the whole comb, in Hz.

This is \(2(\max_c |f_c| + B/2)\), the equality case of the condition in the signal model.

plot(ax: Any = None, cut: int | None = None) → Any[source]#

Draw the grid: one box per channel, on the frequency axis.

A grid is a layout, so looking at it should not require synthesising a signal and estimating its spectrum – that shows the same information through an unnecessary detour, and buries the guard band under the roll-off of whatever pulse shape was chosen. Each channel is drawn over the bandwidth it occupies, so the gaps between the boxes are the guard.

Parameters:
  • ax (matplotlib axis, optional) – Axis to draw on; a new figure is created when omitted (decision D25).

  • cut (int, optional) – Index of the channel to single out – the cut in the sense of a nonlinear-interference model, the one whose noise is being counted. Drawn filled where the others are outlined.

Returns:

matplotlib axis – The axis, so the caller keeps control of the figure.

classmethod uniform(n_channels: int, *, spacing_Hz: float, bandwidth_Hz: float, center_Hz: float = 193100000000000.0, standard: str = 'custom', reference: str = '') → WDMGrid[source]#

Evenly spaced comb centred on center_Hz.

The comb is symmetric about the centre, so an odd n_channels puts one channel exactly on it and an even count straddles it – the convention every WDM transmitter uses.

Parameters:
  • n_channels (int) – Number of channels \(C\).

  • spacing_Hz (float, keyword-only) – Channel spacing \(\Delta \nu\) in Hz.

  • bandwidth_Hz (float, keyword-only) – Occupied bandwidth \(B\) of one channel, in Hz.

  • center_Hz (float, optional, keyword-only) – Composite centre \(\nu_0\). Defaults to 193.1 THz, the ITU-T G.694.1 anchor.

  • standard (str, optional, keyword-only) – Provenance, forwarded to the constructor.

  • reference (str, optional, keyword-only) – Provenance, forwarded to the constructor.

Examples

>>> grid = WDMGrid.uniform(4, spacing_Hz=50e9, bandwidth_Hz=40e9)
>>> grid.offsets_Hz / 1e9
array([-75., -25.,  25.,  75.])
classmethod itu(n_indices: Sequence[int], *, bandwidth_Hz: float, m: int = 4, center_Hz: float | None = None) → WDMGrid[source]#

Grid built from ITU-T G.694.1 flexible-grid indices.

A channel is the integer couple \((n, m)\) of D19: centre \(193.1\,\text{THz} + n \times 6.25\,\text{GHz}\), slot width \(12.5 m\) GHz. The fixed 50 GHz grid is m = 4 with n a multiple of 8.

Parameters:
  • n_indices (sequence of int) – Signed channel indices \(n\), on the 6.25 GHz granularity.

  • bandwidth_Hz (float, keyword-only) – Occupied bandwidth \(B\). Not the slot width: a channel may under-fill its slot, and the guard band is the point of the difference.

  • m (int, optional, keyword-only) – Slot width multiplier. Default 4, i.e. the 50 GHz slot.

  • center_Hz (float, optional, keyword-only) – Composite centre; defaults to the midpoint of the comb.

Raises:

ValueError – If the occupied bandwidth exceeds the slot width, which would mean a channel spilling outside the spectrum it was allocated.

Examples

>>> grid = WDMGrid.itu([-8, 0, 8], bandwidth_Hz=32e9)
>>> grid.spacing_Hz / 1e9, grid.guard_Hz / 1e9
(50.0, 18.0)
validate_fs(fs: float) → None[source]#

Raise if fs cannot carry the comb without aliasing.

class WDMMultiplexer(grid: WDMGrid, *, fs: float, name: str = 'wdm mux')[source]#

Combine per-channel baseband signals into one full-field waveform.

Signal Model#

Each channel is shifted to its slot and the slots are summed:

\[x[n] = \sum_{c=0}^{C-1} x_c[n] \, e^{j 2 \pi f_c n / f_s}\]

with \(f_c\) the offset of channel \(c\) from the composite band centre (see WDMGrid). This is the synthesis bank of a software radio (GNU Radio’s pfb_synthesizer_ccf), written directly rather than through a filter bank because the channels already arrive at the composite rate.

The output is the waveform the fibre propagates: nonlinear propagation is meaningful only on this single array, since XPM and FWM are interactions between the channels and a per-channel array can only ever produce SPM.

Axes: declared axis – expects (..., C, N) with channels on axis −2 and time on axis −1 (the MIMO antenna convention of D2), and returns (..., N).

param grid:

Frequency plan. Its channel order is the order of axis −2.

type grid:

WDMGrid

param fs:

Sampling rate \(f_s\) in Hz, of the input and of the output – this block does not resample.

type fs:

float, keyword-only

param name:

Block name.

type name:

str, optional, keyword-only

phasors_#

(C, N) mixing phasors, built in prepare because they depend on the block length (hence the trailing underscore, D23).

Type:

np.ndarray

raises ShapeError:

If the input is not at least 2D, or if axis −2 does not have one entry per channel of the grid.

raises ValueError:

If fs is too low to carry the grid.

References

G. P. Agrawal, Fiber-Optic Communication Systems, 4th ed., Wiley, 2010, Section 6.1.

Examples

>>> grid = WDMGrid.uniform(3, spacing_Hz=50e9, bandwidth_Hz=32e9)
>>> x = np.ones((3, 64), dtype=complex)
>>> y = WDMMultiplexer(grid, fs=200e9)(x)
>>> y.shape
(64,)
class WDMDemultiplexer(grid: WDMGrid, *, fs: float, channel: int | None = None, name: str = 'wdm demux')[source]#

Split a full-field waveform back into per-channel baseband signals.

Signal Model#

Channel \(c\) is shifted back to baseband and low-pass filtered to its own bandwidth:

\[y_c[n] = \mathrm{LPF}_{B/2}\left\{ y[n] \, e^{-j 2 \pi f_c n / f_s} \right\}\]

The filter is the ideal brick wall applied on the DFT of the block, the same mask as BWFilter: bins with \(|f| > B/2\) are zeroed. Being a DFT mask it is a circular operation, exact when each offset lands on an integer bin and leaky otherwise – the block warns when it does not.

This is channel selection, not matched filtering. \(B\) is the grid’s occupied bandwidth, so for a pulse shaped at roll-off \(\rho\) it must be \(R_s(1 + \rho)\) and not \(R_s\); setting it to the symbol rate clips the skirts of the pulse, which costs far more than it looks – measured on an 11-channel 32 GBd comb at \(\rho = 0.01\), an implementation floor of 33.5 dB instead of 54.1 dB. That mistake is caught by WDMMultiplexer, not here: at the receiver the amplified spontaneous emission is white and fills the guard band, so measuring the energy this mask rejects measures the noise, not the clipping. Same comb, same grid: 0.12 % of the rejected energy after 700 km, against 0.0004 % of genuinely clipped signal.

Conversely, once the mask is wide enough to pass the channel it changes nothing: the matched filter downstream does the selecting. Measured on the same comb, a mask at \(R_s(1+\rho)\), one at the full 37.5 GHz slot, and no mask at all give 54.13, 54.14 and 54.13 dB. That is why there is no filter-shape option here – a root-raised-cosine in this block would be the receiver’s matched filter wearing the demultiplexer’s name.

This is the analysis bank of a software radio (GNU Radio’s pfb_channelizer_ccf), minus the decimation: the output stays at \(f_s\), so follow it with Downsampler when the per-channel rate is what you want.

Axes: declared axis – expects (..., N) and returns (..., C, N), channels on axis −2; with channel=c it returns (..., N) for that channel alone.

param grid:

Frequency plan, the same object the multiplexer used.

type grid:

WDMGrid

param fs:

Sampling rate \(f_s\) in Hz, unchanged by this block.

type fs:

float, keyword-only

param channel:

Extract this channel only, by index into the grid. None (default) returns all of them. A receiver that tunes to one wavelength is the common case, and returning (..., N) there keeps the rest of the chain shape-agnostic.

type channel:

int, optional, keyword-only

param name:

Block name.

type name:

str, optional, keyword-only

phasors_#

(C, N) mixing phasors, built in prepare (D23).

Type:

np.ndarray

mask_#

(N,) boolean DFT mask of the channel filter.

Type:

np.ndarray

raises ShapeError:

If the input has no time axis.

raises ValueError:

If fs is too low to carry the grid, or if channel is not a valid index into it.

References

G. P. Agrawal, Fiber-Optic Communication Systems, 4th ed., Wiley, 2010, Section 6.1.

Examples

>>> grid = WDMGrid.uniform(3, spacing_Hz=50e9, bandwidth_Hz=32e9)
>>> rng = np.random.default_rng(0)
>>> x = rng.normal(size=(3, 256)) + 1j * rng.normal(size=(3, 256))
>>> y = WDMMultiplexer(grid, fs=200e9)(x)
>>> WDMDemultiplexer(grid, fs=200e9, channel=1)(y).shape
(256,)