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.
- handoverlab/__init__.py +30 -0
- handoverlab/__main__.py +7 -0
- handoverlab/channel/__init__.py +8 -0
- handoverlab/channel/pathloss.py +122 -0
- handoverlab/channel/radiomap.py +198 -0
- handoverlab/channel/source.py +130 -0
- handoverlab/cli.py +334 -0
- handoverlab/config.py +448 -0
- handoverlab/engine/__init__.py +10 -0
- handoverlab/engine/batch.py +200 -0
- handoverlab/engine/core.py +546 -0
- handoverlab/engine/invariants.py +141 -0
- handoverlab/engine/metrics.py +107 -0
- handoverlab/engine/procedures.py +452 -0
- handoverlab/eventlog.py +335 -0
- handoverlab/gui/__init__.py +53 -0
- handoverlab/gui/app.py +139 -0
- handoverlab/gui/logic.py +466 -0
- handoverlab/io.py +79 -0
- handoverlab/layout.py +214 -0
- handoverlab/measurement.py +115 -0
- handoverlab/mobility.py +169 -0
- handoverlab/py.typed +0 -0
- handoverlab/rules/__init__.py +107 -0
- handoverlab/rules/base.py +249 -0
- handoverlab/rules/builtin.py +278 -0
- handoverlab/scenarios/__init__.py +247 -0
- handoverlab/scenarios/cards/fr3-urban-15ghz.yaml +33 -0
- handoverlab/scenarios/cards/highway-10ghz.yaml +39 -0
- handoverlab/scenarios/cards/ns3-urban-3.5ghz.yaml +52 -0
- handoverlab/scenarios/cards/open-area-10ghz.yaml +28 -0
- handoverlab/scenarios/cards/smoke.yaml +31 -0
- handoverlab/scenarios/cards/urban-grid-10ghz.yaml +27 -0
- handoverlab/seeds.py +40 -0
- handoverlab/simulate.py +141 -0
- handoverlab/stability.py +186 -0
- handoverlab/stats.py +249 -0
- handoverlab/studies/__init__.py +33 -0
- handoverlab/studies/config.py +464 -0
- handoverlab/studies/report.py +538 -0
- handoverlab/studies/runner.py +307 -0
- handoverlab/studies/status.py +223 -0
- handoverlab/studies/store.py +251 -0
- handoverlab/studies/study.py +559 -0
- handoverlab/studies/tuning.py +337 -0
- handoverlab/testing.py +171 -0
- handoverlab/traffic.py +30 -0
- handoverlab/verify/__init__.py +74 -0
- handoverlab/verify/data/golden.json +114 -0
- handoverlab/verify/data/ns3_urban_a3.json +1471 -0
- handoverlab/verify/engine_checks.py +105 -0
- handoverlab/verify/golden.py +126 -0
- handoverlab/verify/ns3.py +125 -0
- handoverlab/verify/radio.py +190 -0
- handoverlab/verify/report.py +74 -0
- handoverlab/verify/stored.py +70 -0
- handoverlab/world.py +55 -0
- handoverlab-1.0.0.dist-info/METADATA +363 -0
- handoverlab-1.0.0.dist-info/RECORD +63 -0
- handoverlab-1.0.0.dist-info/WHEEL +5 -0
- handoverlab-1.0.0.dist-info/entry_points.txt +2 -0
- handoverlab-1.0.0.dist-info/licenses/LICENSE +21 -0
- handoverlab-1.0.0.dist-info/top_level.txt +1 -0
handoverlab/__init__.py
ADDED
|
@@ -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"]
|
handoverlab/__main__.py
ADDED
|
@@ -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)
|