Skip to content

Beamforming - #5

Draft
krzysztofrusek wants to merge 11 commits into
mainfrom
beamforming
Draft

krzysztofrusek wants to merge 11 commits into
mainfrom
beamforming

Conversation

@krzysztofrusek

@krzysztofrusek krzysztofrusek commented Jul 15, 2024 •

Copy link
Copy Markdown
Contributor

This pr introduces beam forming and better walls handling in the simulator.

TODO

  • Refactor walls argument
  • Add isotropic antena and free space loss
  • Add monte-carlo walls
  • Improve documentation. In particular we must decide whether to use classical antena gain or relative to the maximum. (classical dBi, see docs/antennas.rst)
  • Add walls visualization

@krzysztofrusek krzysztofrusek added the enhancement New feature or request label Jul 15, 2024
@krzysztofrusek
krzysztofrusek requested a review from m-wojnar July 15, 2024 12:33
@krzysztofrusek krzysztofrusek self-assigned this Jul 15, 2024

@m-wojnar m-wojnar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good 👍 For the further steps, I propose to add some class or function wrapper for a whole process. This will allow the user to call just one function instead of preparation of the wall matrix, antena gain matrix, and finally simulator.

Do you possibly know why unit tests are failing? It looks like a tfp or jax bug?

@krzysztofrusek

Copy link
Copy Markdown
Contributor Author

Looks good 👍 For the further steps, I propose to add some class or function wrapper for a whole process. This will allow the user to call just one function instead of preparation of the wall matrix, antena gain matrix, and finally simulator.

I agree.

My idea is to have simulator as a callable with differentiable parameters.

This callable can be either :

  • a pytree with __call__
  • or a factory function returning differentiable simulator (optax style).

I am not sure right now what is better at this point, as we need to modularize the sim function.

Do you possibly know why unit tests are failing? It looks like a tfp or jax bug?

no, but indeed this looks like tfp issue, maybe we need to bump version.

m-wojnar and others added 3 commits July 15, 2024 15:55
Describe obstacles geometrically as (rotated) rectangles with an
attenuation coefficient in dB/m and estimate the attenuation of a link by
sampling points uniformly along it and counting how many fall inside each
wall. The estimator is unbiased, branch-free (jit/vmap/grad friendly), and
symmetric, and it handles arbitrarily many overlapping obstacles.

Also document that the binomial sampling noise of the estimator can play
the role of the shadowing term of the fading model, add plot_walls for
visualization, and add tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFmELrvX87L5zFiKPYGwVt
@krzysztofrusek

Copy link
Copy Markdown
Contributor Author

Monte Carlo walls (pushed as e57387d)

mapc_sim/experimental/walls.py now implements the geometric wall model.

Model. An obstacle is a rotated rectangle with an attenuation coefficient $\alpha_w$ in dB per meter of traversed material. A link is the segment $\overline{p_i p_j}$, and its attenuation is

$$L_{ij} = \sum_w \alpha_w \left| \overline{p_i p_j} \cap W_w \right|.$$

So the loss depends on how the link crosses the wall (a grazing crossing is attenuated more than a perpendicular one, a corner clip is almost free) instead of on a binary "is there a wall in between".

Estimation. Clipping a segment against a rotated rectangle analytically needs branches, sorted intersection points and degenerate cases — none of it jit/vmap/grad friendly. Instead we sample $N$ points uniformly along the link and count how many land inside each wall:

$$\hat L_{ij} = d_{ij} \sum_w \alpha_w \frac{K_w}{N}, \qquad K_w = \sum_n \mathbb{1}[p_i + t_n (p_j - p_i) \in W_w], \quad t_n \sim \mathcal U(0,1).$$

Point-in-rotated-rectangle is a rotation plus two comparisons, so the whole estimator is one branch-free expression mapped over node pairs and obstacles. It is unbiased, supports arbitrarily many overlapping walls for free, and the matrix is symmetric by construction (the same samples are used for both directions of a link, so the channel stays reciprocal — the key is folded on the unordered pair index).

Sampling noise as fading. $K_w \sim \mathrm{Binomial}(N, p_w)$ with $p_w = |\overline{p_i p_j} \cap W_w| / d_{ij}$, hence

$$\operatorname{Var} \hat L_{ij} = d_{ij}^2 \sum_w \alpha_w^2 \frac{p_w (1 - p_w)}{N}.$$

With a fresh key per simulation step this is a zero-mean, approximately Gaussian perturbation of the link budget with a standard deviation we control through $N$ — i.e. exactly the shape of the shadowing term the simulator adds as sigma (the value fitted to ns-3 Nakagami runs). So the sampling noise can replace the explicit random normal: choose $N$ so that attenuation_std(...) matches the target sigma and drop (or reduce) sigma. There is a physical argument for it too: shadowing largely is uncertainty about the geometry, and here that uncertainty is made explicit and propagated with the right magnitude, instead of being bolted on top of a deterministic geometry.

Caveats (documented in docs/walls.rst): the noise vanishes for links that miss every wall or lie entirely inside one ($p_w \in {0,1}$ — arguably a feature, LOS links do fade less, but a residual sigma is still needed for them); it is discrete and skewed for small $p_w$ (Gaussian approximation wants $N p_w (1-p_w) \gtrsim 10$); it is spatially correlated (like real shadowing, but the links are then not independent); and its magnitude is set by geometry and by a single shared $N$, so a per-link target sigma cannot be matched exactly, only on average. The scale is not tiny: for a 10 m link crossing 0.35 m of 25 dB/m and 0.1 m of 30 dB/m material, $N = 32$ gives 7.7 dB and $N \approx 470$ gives 2 dB.

API

walls = stack([
    Wall.create(xy=jnp.array([4., -10.]), wh=jnp.array([0.3, 20.]), attenuation=25., angle=30.),
    Wall.create(xy=jnp.array([7., -10.]), wh=jnp.array([0.1, 20.]), attenuation=30.),
])
loss_gain = wall_attenuation(key, pos, walls, n_samples=256)   # (n, n) dB, symmetric
std = attenuation_std(key, pos, walls, n_samples=256)          # (n, n) dB, for calibrating N
ax = plot_walls(walls)                                         # visualization (TODO #5)

Wall is a chex.dataclass, hence a pytree: environments pass through jit/vmap and the matrix is differentiable w.r.t. the attenuation coefficients (the geometry enters through a hard indicator, so wall positions/sizes get a zero gradient a.e. — a smoothed indicator is possible if we ever want to optimize the layout).

11 tests in test/test_walls.py (containment, rotation, unbiasedness against the analytic length, the binomial variance formula, symmetry, jit/grad, end-to-end with network_data_rate), all passing, docs build clean.


Open points

  1. This branch is 31 commits behind main and the diff no longer applies cleanly in spirit: main has since changed network_data_rate to take a 0/1 walls matrix scaled by a scalar wall_loss, plus a path_loss_fn hook, while this branch renamed the argument to loss_gain (a plain additive dB matrix). The MC module is self-contained and produces a dB matrix, so it fits the loss_gain semantics directly. Options: (a) merge main here and keep loss_gain as the API (walls * wall_loss becomes one way of building it), (b) keep main's walls argument and pass MC losses through path_loss_fn. I'd go with (a) — it is the more general one and it is what this PR already started. Say the word and I'll do the merge.
  2. Antenna gain convention (remaining TODO): I'd use the classical convention (gain in dBi, relative to isotropic) rather than relative to the maximum, because it keeps the link budget additive and physically interpretable and because it composes with loss_gain without an extra normalization. Relative-to-maximum only saves subtracting a constant from tx_power.
  3. Wall attenuation in dB/m vs dB per crossing. I chose dB/m; the TGax WALL_LOSS = 7 dB per wall corresponds to $\alpha = 7/\delta$ for a wall of thickness $\delta$. If you prefer to keep the TGax parameterization, I can add a helper that builds walls from a per-crossing loss and a nominal thickness.

krzysztofrusek and others added 2 commits August 26, 2026 21:44
Bring in 31 commits from main (greedy MCS selection, channel width,
Internals, path_loss_fn hook, dependency and CI updates) and reconcile
them with the walls refactor of this branch:

- `network_data_rate` takes `loss_gain`, an additive dB matrix of losses
  (walls) and gains (antennas), instead of the binary `walls` matrix,
- `tgax_path_loss` adds `loss_gain` directly and no longer scales it by
  `wall_loss`; `utils.binary_walls` builds a `loss_gain` matrix from the
  old binary representation,
- matplotlib is now an optional `viz` dependency (`plot_walls`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFmELrvX87L5zFiKPYGwVt
Antenna gains are absolute (dBi, relative to an isotropic radiator) rather
than relative to the maximum of the pattern, so that the link budget stays
additive, tx_power keeps meaning conducted power, and antennas with
different beamwidths remain comparable. The reasoning is documented in
docs/antennas.rst.

Add the 3GPP/TGax parabolic sector pattern, the bearing matrix, a planar
peak gain estimate, and link_gain, which sums the transmitter and receiver
gains into the (symmetric) term subtracted from loss_gain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFmELrvX87L5zFiKPYGwVt
@krzysztofrusek

Copy link
Copy Markdown
Contributor Author

Branch updated to main + remaining TODOs

1. Merged main (5b89f7a)

The 31 commits from main (greedy MCS selection, channel_width, Internals, the path_loss_fn hook, dependency and CI bumps) are in, reconciled with the walls refactor along option (a) from the previous comment:

  • network_data_rate(..., loss_gain, ...) — one additive dB matrix: losses positive, gains negative.

  • tgax_path_loss(distance, loss_gain, breaking_point) adds loss_gain directly and no longer scales it by wall_loss; the partials residential_/enterprise_tgax_path_loss only fix the breaking point now.

  • utils.binary_walls(walls, wall_loss=ENTERPRISE_WALL_LOSS) rebuilds the old representation, so the migration for existing callers is one call:

    # before
    network_data_rate(key, tx, pos, mcs, tx_power, sigma, walls)
    # after
    network_data_rate(key, tx, pos, mcs, tx_power, sigma, binary_walls(walls))

    ⚠️ This is a silent behavioural change for anyone who keeps passing a 0/1 matrix — it now means 0/1 dB instead of 0/7 dB. Worth a minor version bump and a line in the release notes. README and the docs examples are updated.

  • matplotlib moved to an optional viz extra (only plot_walls needs it, and it imports lazily).

2. Antenna gain convention — classical dBi (260f91c)

Decided in favour of absolute gains relative to an isotropic radiator, documented with the reasoning in docs/antennas.rst:

  • the link budget stays additive and matches data sheets: $P_{rx} = P_{tx} - PL + G_{tx} + G_{rx}$, and tx_power keeps meaning conducted power instead of silently becoming EIRP;
  • with the relative-to-maximum convention the main lobe is 0 dB, so narrowing the beam buys nothing unless the directivity is folded back into tx_power — re-normalized per antenna and per beamwidth, which defeats the purpose of a beamforming study;
  • antennas with different beamwidths (or directional vs omni) are only comparable in absolute terms.

The cost is having to supply a peak gain; when it is unknown, sector_max_gain(beamwidth) = 10 log10(360 / θ_3dB) gives the power-conserving planar estimate (an isotropic antenna, θ = 360°, correctly lands on 0 dBi), and it is documented as a lower bound since the elevation pattern is ignored.

mapc_sim/experimental/antenas.py now has:

bearing(pos)                                   # (n, n) angles from i to j, degrees
isotropic(pos, max_gain=0.)                    # (n, n) constant gain, dBi
sector(pos, boresight, beamwidth=65., max_gain=None, front_back_ratio=20.)
sector_max_gain(beamwidth)
link_gain(gain)                                # G_ij + G_ji, symmetric

loss_gain = wall_attenuation(key, pos, walls) - link_gain(sector(pos, boresight))

sector is the 3GPP/TGax parabolic-in-dB pattern $G(\theta) = G_{\max} - \min(12 ((\theta - \theta_0)/\theta_{3dB})^2, A_m)$, differentiable w.r.t. boresight (away from the front-to-back cap), so beam directions can be optimized directly.

3. Walls in dB/m

Kept as is; binary_walls covers the TGax per-crossing parameterization for the binary model, and for a geometric wall the per-crossing loss of TGax corresponds to $\alpha = 7 / \delta$ for thickness $\delta$. Happy to add a named constructor if you would rather write the per-crossing value directly.

Status

8 new antenna tests + 11 wall tests; the full suite (24 tests) passes once the environment matches pyproject.toml (jax~=0.4.38 — with the older 0.4.30 test_graph fails on register_dataclass, unrelated to this PR). Docs build with no new warnings. All TODO boxes are ticked; ready for review.

krzysztofrusek and others added 2 commits August 26, 2026 21:50
matplotlib is an optional dependency, so the annotations must not resolve
its name at runtime (flake8 F821 in CI).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFmELrvX87L5zFiKPYGwVt
`accepts_walls` adds `walls` and `wall_loss` keyword arguments to any
function taking a `loss_gain` matrix and converts them with `binary_walls`,
raising a DeprecationWarning. `network_data_rate` is decorated with it, so
the previous call style keeps working.

The legacy argument is keyword-only: a matrix passed positionally is always
`loss_gain`, as the two representations cannot be told apart (under jit the
values are tracers, and a binary matrix is a valid `loss_gain` matrix).
Passing both raises a TypeError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFmELrvX87L5zFiKPYGwVt
@krzysztofrusek

Copy link
Copy Markdown
Contributor Author

Compatibility layer for walls (9708c23)

Implemented as the decorator you suggested — mapc_sim.utils.accepts_walls, applied to network_data_rate:

@accepts_walls
def network_data_rate(key, tx, pos, mcs, tx_power, sigma, loss_gain, ...): ...

It adds two keyword-only parameters and converts them with binary_walls:

network_data_rate(key, tx, pos, mcs, tx_power, sigma, walls=walls)                              # 7 dB per wall
network_data_rate(key, tx, pos, mcs, tx_power, sigma, walls=walls, wall_loss=RESIDENTIAL_WALL_LOSS)

Design decisions:

  • Decorator over an if inside the simulator — the compatibility shim stays out of sim.py, is reusable for any other function taking loss_gain (there is a test for that), and can be deleted in one line when the deprecation period ends.
  • walls is keyword-only, and positional always means loss_gain. The two cannot be told apart at runtime: under jit the values are tracers, so no value sniffing is possible, and a 0/1 matrix is a perfectly valid loss_gain matrix anyway. So network_data_rate(key, tx, pos, mcs, tx_power, sigma, walls) — the old positional call — now means 0/1 dB, silently. This is the one case the shim cannot rescue; it is called out in the README and the docs, and it argues for a minor version bump.
  • DeprecationWarning on the legacy path (silent by default for end users, visible in test suites). Note that under jit it fires at trace time only.
  • TypeError when both are given, and the normal missing-argument TypeError when neither is.
  • __signature__ is patched, so inspect.signature and the Sphinx docs show ..., loss_gain, channel_width=20, ..., *, walls=None, wall_loss=7.0.

wall_loss defaults to ENTERPRISE_WALL_LOSS, which is what default_path_loss used before the merge, so the legacy call reproduces the old numbers exactly (there is a test asserting it against binary_walls).

9 new tests in test/test_compat.py (equivalence with the explicit loss_gain call for both wall losses, the deprecation warning fires / does not fire, both-arguments and no-argument errors, positional-means-loss_gain, jit, and the decorator on a foreign function). Full suite: 33 tests, all passing; docs build with no new warnings.

Annotate with jax.Array and declare pytree dataclasses with
@jax.tree_util.register_dataclass on top of a standard @DataClass, as in
mapc_sim.experimental.graph. This covers the Wall obstacle, the Internals
of the simulator, and the type annotations of the walls and antenas
modules, so chex is no longer needed at all.

Wall is now a plain dataclass, so it is updated with dataclasses.replace
instead of the chex .replace method.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFmELrvX87L5zFiKPYGwVt
@krzysztofrusek

Copy link
Copy Markdown
Contributor Author

Dropped chex (3a22d6e)

The whole package now follows the convention already used in experimental/graph.py:

@jax.tree_util.register_dataclass
@dataclass
class Wall:
    xy: jax.Array
    wh: jax.Array
    rot: jax.Array
    attenuation: jax.Array
  • Wall and Internals are standard dataclasses registered as pytrees; chex.dataclass is gone from sim.py as well.
  • Annotations are jax.Array, with float | jax.Array where a Python scalar is equally valid (angles, beamwidths, attenuation coefficients) — replacing chex.Array, chex.Numeric, and chex.PRNGKey.
  • chex is removed from pyproject.toml — the runtime dependencies are now just jax and tensorflow-probability[jax].

One behavioural note: Wall no longer has the chex .replace() method, so obstacles are updated with dataclasses.replace(wall, ...). That is documented in docs/walls.rst and used in the tests.

Full suite still 33 tests passing, flake8 clean, docs build with no new warnings.

Take main's version of every conflicting file and re-apply only what the
loss_gain argument and the accepts_walls decorator require:

- sim.py: main's Nakagami fading, keeping the loss_gain parameter and the
  decorator,
- utils.py: main's nakagami_fading_db, keeping tgax_path_loss additive in
  loss_gain plus binary_walls and accepts_walls,
- pyproject.toml: main's jax 0.8 and tfp-nightly, plus the viz extra and
  mapc_sim.experimental in the package list, which main's explicit
  packages entry would otherwise drop from the distribution.

Also document how the sampling noise of the wall estimator relates to the
new Nakagami fading, and stop asserting the absence of unrelated
DeprecationWarnings in test_compat, since tfp-nightly emits them on jax 0.8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFmELrvX87L5zFiKPYGwVt
@krzysztofrusek

Copy link
Copy Markdown
Contributor Author

Merged main @ 0.2.3 (5114409)

Resolved by taking main's version of every conflicting file and re-applying only what the loss_gain argument and the accepts_walls decorator actually need.

  • sim.py — main's Nakagami fading (nakagami_m, the extra key split, the Internals pytree, the warning about sigma) verbatim; the only deviations are loss_gain in place of walls, the @accepts_walls decorator, and the two docstring entries that describe them.
  • utils.py — main's nakagami_fading_db verbatim; tgax_path_loss stays additive in loss_gain (no wall_loss factor, partials fix only the breaking point), plus binary_walls and accepts_walls.
  • pyproject.toml — main's jax~=0.8.0 and tfp-nightly[jax], version 0.2.3, plus this branch's viz extra.
  • constants.py — main's, unchanged (DEFAULT_NAKAGAMI_M, DEFAULT_NAKAGAMI_SIGMA).

Two things worth your attention:

  1. packages = ["mapc_sim"] (added on main in 0.2.2) drops mapc_sim.experimental from the built distribution — the graph module included, so this already affects main, not just this branch. I resolved it to packages = ["mapc_sim", "mapc_sim.experimental"]. Worth porting to main as a fix.
  2. test_compat no longer turns DeprecationWarning into an error to check that the modern call path is silent — tfp-nightly on jax 0.8 emits its own deprecation warnings from jax.numpy.shape, so the test now filters by message instead. Not a problem with the merge, but it is a warning stream you will see in every run until tfp catches up.

Also documented in docs/walls.rst how the two randomness sources relate, since main's Nakagami work overlaps with the sampling-noise idea: nakagami_m is multipath (fast) fading, independent per link and per slot; the sampling noise of the wall estimator is shadowing (slow) fading, driven by geometry uncertainty and correlated across nearby links. They compose — the term not to double count is sigma, which is exactly why main sets DEFAULT_NAKAGAMI_SIGMA = 0.

Full suite on jax 0.8 + tfp-nightly: 38 tests, all passing (main's 5 Nakagami tests included), flake8 clean, docs build with no new warnings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds beamforming and geometric wall modeling through a generalized additive loss_gain matrix.

Changes:

  • Adds antenna gain and Monte Carlo wall models.
  • Preserves keyword-based compatibility for legacy wall matrices.
  • Adds packaging, documentation, visualization, and tests.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
mapc_sim/sim.py Integrates loss_gain into simulation.
mapc_sim/utils.py Adds compatibility and wall conversion helpers.
mapc_sim/experimental/walls.py Implements geometric wall attenuation.
mapc_sim/experimental/antenas.py Implements directional antenna gains.
test/test_walls.py Tests wall geometry and attenuation.
test/test_compat.py Tests legacy API compatibility.
test/test_antenas.py Tests antenna and beamforming behavior.
README.md Updates usage and compatibility guidance.
pyproject.toml Packages experimental modules and visualization extras.
docs/walls.rst Documents the wall model.
docs/antennas.rst Documents antenna gain conventions.
docs/index.rst Adds features and documentation navigation.
docs/api.rst Exposes experimental APIs.
Suppressed comments (1)

mapc_sim/experimental/walls.py:314

  • _pair_hits reuses each sampled point for every wall, so the wall indicators are correlated. Summing only the individual binomial variances omits covariance terms and returns the wrong standard deviation for links crossing multiple walls (for two identical overlapping walls, this underestimates the variance by a factor of two). Compute the variance of the per-sample weighted sum, or include pairwise joint hit probabilities, before using this value to calibrate sigma.
    var = ((walls.attenuation ** 2) * p * (1. - p) / n_samples).sum(axis=-1)

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

idx = jnp.arange(n)
lo = jnp.minimum(idx[:, None], idx[None, :])
hi = jnp.maximum(idx[:, None], idx[None, :])
return jax.vmap(jax.vmap(jax.random.fold_in, in_axes=(None, 0)), in_axes=(None, 0))(key, lo * n + hi)
Comment thread mapc_sim/sim.py
It is added to the path loss, so walls contribute a positive value and antenna gains a
negative one. It can be built from a geometric description of the environment with
:func:`mapc_sim.experimental.walls.wall_attenuation`, from antenna patterns with
:func:`mapc_sim.experimental.antenas.gain`, or from a binary adjacency matrix of walls
Comment thread docs/walls.rst
Comment on lines +202 to +204
* The sampling noise of the wall estimator is **shadowing (slow) fading** -- driven
by the uncertainty about the obstacles the link crosses, and correlated for links
that share geometry.
Comment thread docs/api.rst
This module is experimental and its API may change. See :doc:`antennas` for
a discussion of the gain convention.

.. automodule:: mapc_sim.experimental.antenas

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants