handoverlab 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. handoverlab/__init__.py +30 -0
  2. handoverlab/__main__.py +7 -0
  3. handoverlab/channel/__init__.py +8 -0
  4. handoverlab/channel/pathloss.py +122 -0
  5. handoverlab/channel/radiomap.py +198 -0
  6. handoverlab/channel/source.py +130 -0
  7. handoverlab/cli.py +334 -0
  8. handoverlab/config.py +448 -0
  9. handoverlab/engine/__init__.py +10 -0
  10. handoverlab/engine/batch.py +200 -0
  11. handoverlab/engine/core.py +546 -0
  12. handoverlab/engine/invariants.py +141 -0
  13. handoverlab/engine/metrics.py +107 -0
  14. handoverlab/engine/procedures.py +452 -0
  15. handoverlab/eventlog.py +335 -0
  16. handoverlab/gui/__init__.py +53 -0
  17. handoverlab/gui/app.py +139 -0
  18. handoverlab/gui/logic.py +466 -0
  19. handoverlab/io.py +79 -0
  20. handoverlab/layout.py +214 -0
  21. handoverlab/measurement.py +115 -0
  22. handoverlab/mobility.py +169 -0
  23. handoverlab/py.typed +0 -0
  24. handoverlab/rules/__init__.py +107 -0
  25. handoverlab/rules/base.py +249 -0
  26. handoverlab/rules/builtin.py +278 -0
  27. handoverlab/scenarios/__init__.py +247 -0
  28. handoverlab/scenarios/cards/fr3-urban-15ghz.yaml +33 -0
  29. handoverlab/scenarios/cards/highway-10ghz.yaml +39 -0
  30. handoverlab/scenarios/cards/ns3-urban-3.5ghz.yaml +52 -0
  31. handoverlab/scenarios/cards/open-area-10ghz.yaml +28 -0
  32. handoverlab/scenarios/cards/smoke.yaml +31 -0
  33. handoverlab/scenarios/cards/urban-grid-10ghz.yaml +27 -0
  34. handoverlab/seeds.py +40 -0
  35. handoverlab/simulate.py +141 -0
  36. handoverlab/stability.py +186 -0
  37. handoverlab/stats.py +249 -0
  38. handoverlab/studies/__init__.py +33 -0
  39. handoverlab/studies/config.py +464 -0
  40. handoverlab/studies/report.py +538 -0
  41. handoverlab/studies/runner.py +307 -0
  42. handoverlab/studies/status.py +223 -0
  43. handoverlab/studies/store.py +251 -0
  44. handoverlab/studies/study.py +559 -0
  45. handoverlab/studies/tuning.py +337 -0
  46. handoverlab/testing.py +171 -0
  47. handoverlab/traffic.py +30 -0
  48. handoverlab/verify/__init__.py +74 -0
  49. handoverlab/verify/data/golden.json +114 -0
  50. handoverlab/verify/data/ns3_urban_a3.json +1471 -0
  51. handoverlab/verify/engine_checks.py +105 -0
  52. handoverlab/verify/golden.py +126 -0
  53. handoverlab/verify/ns3.py +125 -0
  54. handoverlab/verify/radio.py +190 -0
  55. handoverlab/verify/report.py +74 -0
  56. handoverlab/verify/stored.py +70 -0
  57. handoverlab/world.py +55 -0
  58. handoverlab-1.0.0.dist-info/METADATA +363 -0
  59. handoverlab-1.0.0.dist-info/RECORD +63 -0
  60. handoverlab-1.0.0.dist-info/WHEEL +5 -0
  61. handoverlab-1.0.0.dist-info/entry_points.txt +2 -0
  62. handoverlab-1.0.0.dist-info/licenses/LICENSE +21 -0
  63. handoverlab-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,30 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """HandoverLab: system-level simulation of handover and mobility procedures.
3
+
4
+ Quick start::
5
+
6
+ import handoverlab as hl
7
+
8
+ sc = hl.Scenario() # 3 x 3-block street grid, 10 GHz
9
+ res = hl.simulate(sc, [{"rule": "a3"}, {"rule": "cho"}], seeds=[1])
10
+ for r in res:
11
+ print(r.rule, r.metrics["handovers_pm"], r.metrics["failures_pm"])
12
+ """
13
+ __version__ = "1.0.0"
14
+
15
+ from .config import (ConfigError, Experiment, LayoutConfig, MobilityConfig, ProtocolConfig, RadioConfig, RunConfig,
16
+ Scenario, Service, SpeedGroup, TrafficConfig, load_config)
17
+ from .engine import InvariantError
18
+ from .eventlog import fingerprint, read_events, write_events
19
+ from .rules import (A3, CHO, DAPS, LTM, BaselineRule, ConditionalRule, DAPSRule, LTMRule, Rule, available_rules,
20
+ make_rule, register_rule)
21
+ from .scenarios import builtin_scenario, scenario_card, scenario_names
22
+ from .simulate import RunResult, run_experiment, run_world, simulate
23
+ from .world import World, build_world
24
+
25
+ __all__ = ["__version__", "ConfigError", "Experiment", "LayoutConfig", "MobilityConfig", "ProtocolConfig",
26
+ "RadioConfig", "RunConfig", "Scenario", "Service", "SpeedGroup", "TrafficConfig", "load_config",
27
+ "A3", "CHO", "DAPS", "LTM", "BaselineRule", "ConditionalRule", "DAPSRule", "LTMRule", "Rule",
28
+ "available_rules", "make_rule", "register_rule",
29
+ "RunResult", "run_experiment", "run_world", "simulate", "World", "build_world", "InvariantError",
30
+ "fingerprint", "read_events", "write_events", "builtin_scenario", "scenario_card", "scenario_names"]
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: MIT
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -0,0 +1,8 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Radio channel: TR 38.901 path loss, place-tied radio map, per-run radio samples."""
3
+ from .pathloss import breakpoint_distance, p_los, p_los_uma, p_los_umi, path_loss, uma_los, uma_nlos, umi_los, umi_nlos
4
+ from .radiomap import RadioMap
5
+ from .source import Chunk, RadioSource
6
+
7
+ __all__ = ["breakpoint_distance", "p_los", "p_los_uma", "p_los_umi", "path_loss", "uma_los", "uma_nlos",
8
+ "umi_los", "umi_nlos", "RadioMap", "Chunk", "RadioSource"]
@@ -0,0 +1,122 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Path loss and LOS probability of 3GPP TR 38.901 (Tables 7.4.1-1 and 7.4.2-1).
3
+
4
+ Implemented: UMi street canyon and UMa, LOS and NLOS, outdoor users.
5
+ Distances in metres, carrier frequency in GHz, results in dB. The formulas
6
+ are specified for 0.5-100 GHz.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+ import numpy as np
13
+
14
+ C0 = 299792458.0
15
+
16
+
17
+ def breakpoint_distance(fc_ghz: float, h_bs: float, h_ut: float, h_e: float = 1.0) -> float:
18
+ """d'_BP = 4 h'_BS h'_UT f_c / c with effective heights h' = h - h_E [m]."""
19
+ return 4.0 * (h_bs - h_e) * (h_ut - h_e) * fc_ghz * 1e9 / C0
20
+
21
+
22
+ def umi_los(d2d, fc_ghz: float, h_bs: float = 10.0, h_ut: float = 1.5):
23
+ """UMi street canyon, LOS (PL1 below the breakpoint distance, PL2 above)."""
24
+ dh2 = (h_bs - h_ut) ** 2
25
+ d3 = np.sqrt(d2d * d2d + dh2)
26
+ dbp = breakpoint_distance(fc_ghz, h_bs, h_ut)
27
+ lf = 20.0 * math.log10(fc_ghz)
28
+ pl1 = 32.4 + 21.0 * np.log10(d3) + lf
29
+ pl2 = 32.4 + 40.0 * np.log10(d3) + lf - 9.5 * math.log10(dbp * dbp + dh2)
30
+ return np.where(d2d <= dbp, pl1, pl2)
31
+
32
+
33
+ def _umi_nlos_prime(d2d, fc_ghz, h_bs, h_ut):
34
+ d3 = np.sqrt(d2d * d2d + (h_bs - h_ut) ** 2)
35
+ return 35.3 * np.log10(d3) + 22.4 + 21.3 * math.log10(fc_ghz) - 0.3 * (h_ut - 1.5)
36
+
37
+
38
+ def umi_nlos(d2d, fc_ghz: float, h_bs: float = 10.0, h_ut: float = 1.5):
39
+ """UMi street canyon, NLOS: max(PL_LOS, PL'_NLOS)."""
40
+ return np.maximum(umi_los(d2d, fc_ghz, h_bs, h_ut), _umi_nlos_prime(d2d, fc_ghz, h_bs, h_ut))
41
+
42
+
43
+ def uma_los(d2d, fc_ghz: float, h_bs: float = 25.0, h_ut: float = 1.5):
44
+ """UMa, LOS (effective environment height 1 m)."""
45
+ dh2 = (h_bs - h_ut) ** 2
46
+ d3 = np.sqrt(d2d * d2d + dh2)
47
+ dbp = breakpoint_distance(fc_ghz, h_bs, h_ut)
48
+ lf = 20.0 * math.log10(fc_ghz)
49
+ pl1 = 28.0 + 22.0 * np.log10(d3) + lf
50
+ pl2 = 28.0 + 40.0 * np.log10(d3) + lf - 9.0 * math.log10(dbp * dbp + dh2)
51
+ return np.where(d2d <= dbp, pl1, pl2)
52
+
53
+
54
+ def _uma_nlos_prime(d2d, fc_ghz, h_bs, h_ut):
55
+ d3 = np.sqrt(d2d * d2d + (h_bs - h_ut) ** 2)
56
+ return 13.54 + 39.08 * np.log10(d3) + 20.0 * math.log10(fc_ghz) - 0.6 * (h_ut - 1.5)
57
+
58
+
59
+ def uma_nlos(d2d, fc_ghz: float, h_bs: float = 25.0, h_ut: float = 1.5):
60
+ """UMa, NLOS: max(PL_LOS, PL'_NLOS)."""
61
+ return np.maximum(uma_los(d2d, fc_ghz, h_bs, h_ut), _uma_nlos_prime(d2d, fc_ghz, h_bs, h_ut))
62
+
63
+
64
+ _MODELS = {"umi_street_canyon": (umi_los, _umi_nlos_prime), "uma": (uma_los, _uma_nlos_prime)}
65
+
66
+ # coefficients of the formulas above: LOS (A + B log10 d3 + 20 log10 fc; A + 40 log10 d3 + 20 log10 fc - C
67
+ # log10(d'BP^2 + dh^2)) and NLOS' (D log10 d3 + E + F log10 fc - G (h_UT - 1.5))
68
+ _COEF = {"umi_street_canyon": (32.4, 21.0, 9.5, 35.3, 22.4, 21.3, 0.3),
69
+ "uma": (28.0, 22.0, 9.0, 39.08, 13.54, 20.0, 0.6)}
70
+
71
+
72
+ def path_loss(d2d, los, model: str, fc_ghz: float, h_bs: float, h_ut: float):
73
+ """Path loss of ``model`` ("umi_street_canyon" or "uma") for a boolean LOS state.
74
+
75
+ Gives exactly the values of ``np.where(los, X_los(...), X_nlos(...))``,
76
+ but computes the logarithm of the distance once, the second breakpoint
77
+ branch only where it applies and the rest in place (the radio map of a
78
+ large grid evaluates tens of millions of links).
79
+ """
80
+ if model not in _COEF:
81
+ raise ValueError("unknown path-loss model %r; known: %s" % (model, ", ".join(_MODELS)))
82
+ a, b, c, d, e, f, g = _COEF[model]
83
+ d2d = np.asarray(d2d, dtype=float)
84
+ scalar = d2d.ndim == 0
85
+ if scalar:
86
+ d2d = d2d.reshape(1)
87
+ dh2 = (h_bs - h_ut) ** 2
88
+ lg = np.log10(np.sqrt(d2d * d2d + dh2))
89
+ lf = 20.0 * math.log10(fc_ghz)
90
+ dbp = breakpoint_distance(fc_ghz, h_bs, h_ut)
91
+ pl = b * lg
92
+ pl += a # same rounding as a + b * lg
93
+ pl += lf
94
+ far = d2d > dbp
95
+ if far.any():
96
+ pl[far] = a + 40.0 * lg[far] + lf - c * math.log10(dbp * dbp + dh2)
97
+ los = np.broadcast_to(np.asarray(los, dtype=bool), pl.shape)
98
+ if not los.all():
99
+ nl = d * lg # NLOS' over the whole array, in place
100
+ nl += e
101
+ nl += f * math.log10(fc_ghz)
102
+ nl -= g * (h_ut - 1.5)
103
+ np.maximum(pl, nl, out=nl)
104
+ np.copyto(pl, nl, where=~los)
105
+ return pl.reshape(()) if scalar else pl
106
+
107
+
108
+ def p_los_umi(d2d):
109
+ """LOS probability, UMi street canyon."""
110
+ d = np.maximum(np.asarray(d2d, dtype=float), 1e-9)
111
+ return np.where(d <= 18.0, 1.0, 18.0 / d + np.exp(-d / 36.0) * (1.0 - 18.0 / d))
112
+
113
+
114
+ def p_los_uma(d2d):
115
+ """LOS probability, UMa, for h_UT <= 13 m."""
116
+ d = np.maximum(np.asarray(d2d, dtype=float), 1e-9)
117
+ return np.where(d <= 18.0, 1.0, 18.0 / d + np.exp(-d / 63.0) * (1.0 - 18.0 / d))
118
+
119
+
120
+ def p_los(d2d, model: str):
121
+ """LOS probability for the named path-loss model."""
122
+ return p_los_umi(d2d) if model == "umi_street_canyon" else p_los_uma(d2d)
@@ -0,0 +1,198 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Static radio map of one world: everything that is tied to place.
3
+
4
+ For every map pixel in a street and every site the map stores
5
+
6
+ * the line-of-sight state: blocked if the straight segment crosses a building
7
+ block, and (``clutter_los``) additionally the spatially consistent LOS state
8
+ of TR 38.901 clause 7.6.3.3 -- a Gaussian random field with ``los_corr_m``
9
+ correlation compared with the LOS probability -- standing for obstacles in
10
+ the street (vehicles, trees, street furniture). The state is a property of
11
+ the place, the same for every user who passes there ("place-tied blockage");
12
+ * the path loss for that state, and a static, spatially correlated shadowing
13
+ field shared by all users (share ``place_share`` of the shadowing variance).
14
+
15
+ It also holds the operator's nominal best-server map (nearest site, no
16
+ buildings, no shadowing), which is the only map a handover rule may use.
17
+
18
+ In the open area there are no buildings; the map holds only the place-tied
19
+ shadowing field, and the LOS state is drawn along each user's path by
20
+ :class:`handoverlab.channel.source.RadioSource`.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import numpy as np
25
+ from scipy.ndimage import gaussian_filter
26
+ from scipy.spatial import cKDTree
27
+ from scipy.special import ndtri
28
+
29
+ from ..layout import Layout, los_matrix
30
+ from ..seeds import streams
31
+ from .pathloss import p_los, path_loss
32
+
33
+ FLOOR_DB = -300.0
34
+
35
+
36
+ class GaussianField:
37
+ """Unit-variance Gaussian random fields, one per site, on a raster.
38
+
39
+ Correlation exp(-(d / d_corr)^2); the fields of different sites share a
40
+ common component so that their mutual correlation is ``site_corr``.
41
+ """
42
+
43
+ def __init__(self, rng, layout: Layout, n_sites: int, d_corr: float, res: float, site_corr: float):
44
+ self.res = res
45
+ self.x0, self.y0 = layout.xmin - 2 * res, layout.ymin - 2 * res
46
+ self.nx = int((layout.xmax - layout.xmin) / res) + 6
47
+ self.ny = int((layout.ymax - layout.ymin) / res) + 6
48
+ s_pix = (d_corr / 2.0) / res
49
+ ca, cb = np.sqrt(site_corr), np.sqrt(1.0 - site_corr)
50
+
51
+ def one():
52
+ g = gaussian_filter(rng.standard_normal((self.ny, self.nx), dtype=np.float32), s_pix, mode="wrap")
53
+ return g / g.std()
54
+
55
+ common = one() if site_corr > 0 else 0.0
56
+ f = np.empty((n_sites, self.ny, self.nx), dtype=np.float32) # one contiguous plane per site
57
+ for m in range(n_sites):
58
+ f[m] = ca * common + cb * one()
59
+ self.f = np.ascontiguousarray(f.transpose(1, 2, 0)) # (ny, nx, M) for fast lookups
60
+ self._flat = self.f.reshape(-1, n_sites)
61
+
62
+ def _weights(self, x, y):
63
+ gx = np.clip((x - self.x0) / self.res, 0.0, self.nx - 1.001)
64
+ gy = np.clip((y - self.y0) / self.res, 0.0, self.ny - 1.001)
65
+ ix, iy = gx.astype(int), gy.astype(int)
66
+ return iy * self.nx + ix, (gx - ix).astype(np.float32), (gy - iy).astype(np.float32)
67
+
68
+ def at(self, x, y) -> np.ndarray:
69
+ """Bilinear interpolation at points (P,) -> (P, M).
70
+
71
+ Evaluated as ``f00 (1-tx)(1-ty) + f10 tx (1-ty) + f01 (1-tx) ty + f11 tx ty``
72
+ from left to right, with the products in that order.
73
+ """
74
+ k, tx, ty = self._weights(x, y)
75
+ tx, ty = tx[:, None], ty[:, None]
76
+ ux, uy = 1 - tx, 1 - ty
77
+ f, nx = self._flat, self.nx
78
+ v = np.take(f, k, axis=0)
79
+ v *= ux
80
+ v *= uy
81
+ for kk, a, b in ((k + 1, tx, uy), (k + nx, ux, ty), (k + nx + 1, tx, ty)):
82
+ w = np.take(f, kk, axis=0)
83
+ w *= a
84
+ w *= b
85
+ v += w
86
+ return v
87
+
88
+ def at_pairs(self, x, y, p, m) -> np.ndarray:
89
+ """Same values as ``at(x, y)[p, m]`` for the point/site pairs ``(p, m)`` only."""
90
+ k, tx, ty = self._weights(x, y)
91
+ k, tx, ty = k[p], tx[p], ty[p]
92
+ ux, uy = 1 - tx, 1 - ty
93
+ f, nx = self._flat, self.nx
94
+ v = f[k, m] * ux
95
+ v *= uy
96
+ for kk, a, b in ((k + 1, tx, uy), (k + nx, ux, ty), (k + nx + 1, tx, ty)):
97
+ w = f[kk, m] * a
98
+ w *= b
99
+ v += w
100
+ return v
101
+
102
+
103
+ class RadioMap:
104
+ """Place-tied part of the radio environment of one world."""
105
+
106
+ def __init__(self, layout: Layout, radio, seed: int):
107
+ self.layout, self.radio = layout, radio
108
+ self.is_grid = layout.is_grid
109
+ res = float(radio.map_res_m) if self.is_grid else max(float(radio.map_res_m), 2.0)
110
+ pad = 3.0 * res
111
+ self.res = res
112
+ self.x0, self.y0 = layout.xmin - pad, layout.ymin - pad
113
+ self.nx = int(np.ceil((layout.xmax - layout.xmin + 2 * pad) / res)) + 1
114
+ self.ny = int(np.ceil((layout.ymax - layout.ymin + 2 * pad) / res)) + 1
115
+ M = layout.n_sites
116
+ XX, YY = np.meshgrid(self.x0 + np.arange(self.nx) * res, self.y0 + np.arange(self.ny) * res)
117
+ rng_shadow, rng_los = streams(seed, "map", 2)
118
+
119
+ # nominal plan on a 2 m raster: nearest site (equal power and height, no buildings)
120
+ self.nres = 2.0
121
+ nnx = int((layout.xmax - layout.xmin + 2 * pad) / self.nres) + 2
122
+ nny = int((layout.ymax - layout.ymin + 2 * pad) / self.nres) + 2
123
+ NX, NY = np.meshgrid(self.x0 + np.arange(nnx) * self.nres, self.y0 + np.arange(nny) * self.nres)
124
+ nearest = cKDTree(layout.sites).query(np.c_[NX.ravel(), NY.ravel()])[1]
125
+ self.best_nominal = nearest.reshape(nny, nnx).astype(np.int16)
126
+
127
+ mask = layout.street_mask(XX, YY, margin=2.0 * res)
128
+ px, py = XX[mask], YY[mask]
129
+ P = len(px)
130
+ self.n_pix = P
131
+ self.idx = np.full((self.ny, self.nx), P, dtype=np.int64) # pixel -> row; off-street -> dummy row P
132
+ self.idx[mask] = np.arange(P)
133
+ self.has_field = radio.place_share > 0.0
134
+ shadow = None
135
+ if self.has_field:
136
+ fres = min(2.0, radio.shadow_decorr_m / 4.0) if radio.shadow_decorr_m >= 4.0 else res
137
+ shadow = GaussianField(rng_shadow, layout, M, radio.shadow_decorr_m, max(fres, res),
138
+ radio.shadow_site_corr).at(px, py)
139
+ shadow *= np.float32(np.sqrt(radio.place_share))
140
+ self.los = None
141
+ self.los_share = float("nan")
142
+ self.gain = np.empty((P + 1, M), dtype=np.float32) # last row: off-street dummy
143
+ gain = self.gain[:P]
144
+ if self.is_grid:
145
+ los = los_matrix(px, py, layout.sites, layout.buildings)
146
+ d2 = np.hypot(px[:, None] - layout.sites[None, :, 0], py[:, None] - layout.sites[None, :, 1])
147
+ if radio.clutter_los:
148
+ # spatially consistent LOS state; only links that the buildings leave clear need it
149
+ ip, im = np.nonzero(los)
150
+ lat = GaussianField(rng_los, layout, M, radio.los_corr_m, 5.0, 0.0).at_pairs(px, py, ip, im)
151
+ dd = d2[ip, im]
152
+ los[ip, im] = lat < ndtri(np.clip(p_los(dd, radio.pathloss), 1e-9, 1.0 - 1e-9))
153
+ gain[...] = path_loss(d2, los, radio.pathloss, radio.carrier_ghz, radio.bs_height_m,
154
+ radio.ue_height_m)
155
+ np.negative(gain, out=gain)
156
+ if shadow is not None:
157
+ shadow *= np.where(los, np.float32(radio.shadow_los_db), np.float32(radio.shadow_nlos_db))
158
+ gain += shadow
159
+ self.los = np.zeros((P + 1, M), dtype=bool)
160
+ self.los[:P] = los
161
+ self.los_share = float(los.mean())
162
+ elif shadow is not None:
163
+ gain[...] = shadow
164
+ else:
165
+ gain[...] = 0.0
166
+ self.gain[P] = FLOOR_DB if self.is_grid else 0.0
167
+
168
+ # ------------------------------------------------------------------
169
+ def _grid_coords(self, x, y):
170
+ gx = np.clip((np.asarray(x, dtype=float) - self.x0) / self.res, 0.0, self.nx - 1.001)
171
+ gy = np.clip((np.asarray(y, dtype=float) - self.y0) / self.res, 0.0, self.ny - 1.001)
172
+ return gx, gy
173
+
174
+ def lookup(self, x, y) -> np.ndarray:
175
+ """Bilinear interpolation of the static gain (or shadowing) map at points (P,) -> (P, M)."""
176
+ gx, gy = self._grid_coords(x, y)
177
+ ix, iy = gx.astype(np.int64), gy.astype(np.int64)
178
+ tx = (gx - ix).astype(np.float32)
179
+ ty = (gy - iy).astype(np.float32)
180
+ idx = self.idx
181
+ w = np.stack([(1 - tx) * (1 - ty), tx * (1 - ty), (1 - tx) * ty, tx * ty]).astype(np.float32)
182
+ p = np.stack([idx[iy, ix], idx[iy, ix + 1], idx[iy + 1, ix], idx[iy + 1, ix + 1]])
183
+ v = np.take(self.gain, p[0], axis=0) * w[0][:, None]
184
+ for q in (1, 2, 3):
185
+ v += np.take(self.gain, p[q], axis=0) * w[q][:, None]
186
+ return v
187
+
188
+ def los_at(self, x, y) -> np.ndarray:
189
+ """Line-of-sight state at points (nearest pixel) -> (P, M) bool (street grid only)."""
190
+ gx, gy = self._grid_coords(x, y)
191
+ return self.los[self.idx[np.rint(gy).astype(int), np.rint(gx).astype(int)]]
192
+
193
+ def nominal_cell(self, x, y) -> np.ndarray:
194
+ """Nominal best server (nearest site) at points of any shape."""
195
+ ny, nx = self.best_nominal.shape
196
+ ix = np.clip(np.rint((np.asarray(x) - self.x0) / self.nres).astype(int), 0, nx - 1)
197
+ iy = np.clip(np.rint((np.asarray(y) - self.y0) / self.nres).astype(int), 0, ny - 1)
198
+ return self.best_nominal[iy, ix]
@@ -0,0 +1,130 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Received powers and L1 samples of a run, produced chunk by chunk.
3
+
4
+ :class:`RadioSource` turns trajectories into received powers:
5
+
6
+ * street grid: lookup of the place-tied radio map;
7
+ * open area: path loss with a LOS state that is an AR(1) process along each
8
+ user's own path (nothing repeatable by place), plus the place-tied
9
+ shadowing field if any;
10
+ * plus, if ``place_share < 1``, a shadowing process along each user's path;
11
+ * plus a time-varying component (moving blockers), an AR(1) process updated
12
+ once per L1 period.
13
+
14
+ It also draws the noisy L1 samples that feed the measurement filter. Every
15
+ random number of the radio part comes from here, so all rules run on the same
16
+ world see identical samples.
17
+
18
+ Antenna abstraction: each site forms a beam of gain ``bs_gain_db`` towards the
19
+ user it serves and towards a user that measures it. A site that serves other
20
+ users interferes with the mean gain of its data beams (``isolation`` times the
21
+ peak gain) scaled by its load. No beam management and no fast fading.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from typing import Iterator
26
+
27
+ import numpy as np
28
+ from scipy.signal import lfilter
29
+ from scipy.special import ndtri
30
+
31
+ from ..seeds import streams
32
+ from .pathloss import p_los, path_loss
33
+
34
+ LN10_10 = np.log(10.0) / 10.0
35
+
36
+
37
+ class Chunk:
38
+ """Radio samples of the consecutive steps ``k0 .. k0 + n - 1``.
39
+
40
+ ``rsrp`` (n, U, M) received power [dBm]; ``lin`` the same in mW; ``l1`` the
41
+ noisy L1 samples at the measurement instants (steps with ``k % mp == 0``).
42
+ """
43
+ __slots__ = ("k0", "n", "rsrp", "lin", "l1", "mp")
44
+
45
+ def __init__(self, k0, n, rsrp, lin, l1, mp):
46
+ self.k0, self.n, self.rsrp, self.lin, self.l1, self.mp = k0, n, rsrp, lin, l1, mp
47
+
48
+ @classmethod
49
+ def from_rsrp(cls, k0: int, rsrp: np.ndarray, mp: int, l1: np.ndarray = None) -> "Chunk":
50
+ """Chunk from given received powers (used by tests and custom radio sources)."""
51
+ rsrp = np.asarray(rsrp, dtype=float)
52
+ if l1 is None:
53
+ l1 = rsrp[::mp].copy()
54
+ return cls(k0, rsrp.shape[0], rsrp, np.exp(rsrp * LN10_10), l1, mp)
55
+
56
+
57
+ def _ar1(w, r, z):
58
+ """Unit-variance AR(1) along axis 0 with coefficient ``r`` and previous value ``z``."""
59
+ y, _ = lfilter([np.sqrt(1.0 - r * r)], [1.0, -r], w, axis=0, zi=(r * z)[None])
60
+ return y
61
+
62
+
63
+ class RadioSource:
64
+ """Received power of every site at every user and step."""
65
+
66
+ def __init__(self, world, chunk_steps: int = 1000):
67
+ sc = world.scenario
68
+ self.rmap = world.radio_map
69
+ self.X, self.Y, self.V = world.x, world.y, world.speed
70
+ self.S, self.U = self.X.shape
71
+ self.M = world.layout.n_sites
72
+ self.R = R = sc.radio
73
+ dt = sc.protocol.step_s
74
+ self.mp = max(1, int(round(sc.protocol.l1_period_s / dt)))
75
+ self.chunk = max(self.mp, (chunk_steps // self.mp) * self.mp) # whole L1 periods per chunk
76
+ self.rng_t, self.rng_o, self.rng_m, self.rng_l = streams(world.seed, "radio", 4)
77
+ self.r_t = float(np.exp(-self.mp * dt / R.time_var_tau_s)) # per L1 period
78
+ self.zt = self.rng_t.standard_normal((self.U, self.M)) # stationary start
79
+ self.own = R.place_share < 1.0
80
+ self.r_o = np.exp(-self.V * dt / R.shadow_decorr_m) # per user and step
81
+ self.zo = self.rng_o.standard_normal((self.U, self.M)) if self.own else None
82
+ self.open = not self.rmap.is_grid
83
+ if self.open:
84
+ self.r_l = np.exp(-self.V * dt / R.los_corr_m)
85
+ self.zl = self.rng_l.standard_normal((self.U, self.M))
86
+ self.p0 = R.tx_power_dbm + R.bs_gain_db + R.ue_gain_db
87
+
88
+ def _ar1_users(self, rng, r_u, z, n):
89
+ """AR(1) along time with one coefficient per user -> (n, U, M)."""
90
+ out = np.empty((n, self.U, self.M))
91
+ for u in range(self.U):
92
+ out[:, u, :] = _ar1(rng.standard_normal((n, self.M)), r_u[u], z[u])
93
+ return out
94
+
95
+ def chunks(self) -> Iterator[Chunk]:
96
+ """Yield :class:`Chunk` objects covering the whole run in order."""
97
+ R, rmap = self.R, self.rmap
98
+ sites = rmap.layout.sites
99
+ for k0 in range(0, self.S, self.chunk):
100
+ k1 = min(self.S, k0 + self.chunk)
101
+ n = k1 - k0
102
+ x = self.X[k0:k1].ravel()
103
+ y = self.Y[k0:k1].ravel()
104
+ shape = (n, self.U, self.M)
105
+ sig = None
106
+ if rmap.is_grid:
107
+ rs = rmap.lookup(x, y).astype(np.float64).reshape(shape)
108
+ if self.own:
109
+ sig = np.where(rmap.los_at(x, y), R.shadow_los_db, R.shadow_nlos_db).reshape(shape)
110
+ else:
111
+ d2 = np.hypot(x[:, None] - sites[None, :, 0], y[:, None] - sites[None, :, 1])
112
+ g = self._ar1_users(self.rng_l, self.r_l, self.zl, n)
113
+ self.zl = g[-1].copy()
114
+ los = g.reshape(d2.shape) < ndtri(np.clip(p_los(d2, R.pathloss), 1e-9, 1.0 - 1e-9))
115
+ rs = -path_loss(d2, los, R.pathloss, R.carrier_ghz, R.bs_height_m, R.ue_height_m).reshape(shape)
116
+ sig = np.where(los, R.shadow_los_db, R.shadow_nlos_db).reshape(shape)
117
+ if rmap.has_field:
118
+ rs += rmap.lookup(x, y).astype(np.float64).reshape(shape) * sig
119
+ if self.own:
120
+ o = self._ar1_users(self.rng_o, self.r_o, self.zo, n)
121
+ self.zo = o[-1].copy()
122
+ rs += np.sqrt(1.0 - R.place_share) * sig * o
123
+ nm = -(-n // self.mp)
124
+ if R.time_var_db > 0:
125
+ zt = _ar1(self.rng_t.standard_normal((nm, self.U, self.M)), self.r_t, self.zt)
126
+ self.zt = zt[-1].copy()
127
+ rs += R.time_var_db * np.repeat(zt, self.mp, axis=0)[:n]
128
+ rs += self.p0
129
+ l1 = rs[::self.mp] + R.l1_error_db * self.rng_m.standard_normal((nm, self.U, self.M))
130
+ yield Chunk(k0, n, rs, np.exp(rs * LN10_10), l1, self.mp)