HandoverLab user guide¶
This guide covers version 2.1.0. It explains the ideas behind a run, the built-in scenarios, the configuration keys, the command line, the Python API, how to write and check your own handover rule, event logs at their three levels and their fingerprints, the verification checks, what the metrics mean, and how to run a whole study: equal-budget tuning with automatic gates (including widening a grid), paired tests, stored and resumable results, the status view, and reports. Step-by-step introductions are in the tutorials; the dashboard has its own page.
1. Concepts¶
World. A scenario plus a seed fixes a world: the site layout, the radio map (line of sight,
path loss, shadowing), the trajectories of all users, their service classes and the background
load of every cell. Everything random comes from independent NumPy streams derived from the seed
(handoverlab.seeds).
Rules run on the same world. simulate() builds one world per seed and runs every handover
rule on it with identical radio samples. The protocol engine draws no random numbers, so two rules
on the same seed differ only through their decisions. Compare rules seed by seed (paired), not by
their overall averages alone.
Ground truth and observations. Outcome labels use the true received powers. A rule sees only what a network can observe: filtered measurements, the serving cell, reported position, speed and heading, and the nominal site plan.
Time base. The engine advances all users together in steps of protocol.step_s (10 ms). L1
samples are taken every protocol.l1_period_s (40 ms) and pass through the L3 filter; handover
decisions are made at these measurement instants.
2. Scenarios¶
Street grid (layout.kind = "street_grid")¶
The urban grid of 3GPP TR 37.885 Annex A: street centre lines every 433 m (x) and 250 m (y), streets
20 m wide, building blocks in between. layout.blocks sets the number of blocks; the default
[3, 3] is the minimum area of TR 37.885 Table A-1. Sites stand on the pavement every
layout.site_spacing_m metres along every street, layout.site_offset_m from the centre line,
alternately on the two sides. A link is in line of sight if the segment between site and user
crosses no building and, with radio.clutter_los, if the spatially consistent LOS state of TR 38.901
(clause 7.6.3.3; a random field with radio.los_corr_m correlation) says so. Both are properties of
the place: every user passing a spot sees the same blockage.
Users follow the street centre lines (with a lateral offset per user) and choose straight, left or right at each intersection with probabilities 0.5 / 0.25 / 0.25.
Open area (layout.kind = "open_area")¶
The rectangle of the same grid size without buildings and with a hexagonal site grid. The LOS state
is drawn as a process along each user's path (nothing repeats by place), and users move between
random waypoints. With radio.place_share = 0 all shadowing also moves with the user.
Highway (layout.kind = "highway", since 1.0.0rc3)¶
The straight multi-lane road of the TR 37.885 highway case along the x axis: layout.lanes lanes
(default 3) of layout.lane_width_m (4 m) per direction over layout.road_length_m (2000 m),
eastbound south of the centre line and westbound north of it, no buildings. Roadside sites stand
every layout.site_spacing_m metres along the road, layout.site_offset_m beyond the road edge,
alternately on the two sides. As in the open area, the LOS state is a process along each user's
path. Vehicles keep their lane (the lateral range of their speed group is the displacement from the
lane centre, at most half a lane) and their speed; at the end of the road a vehicle turns into the
same lane of the other direction. The profiles hwy (70-140 km/h), hwy70 (about 70 km/h) and
hwy140 (126-140 km/h) are meant for it. See the how-to highway and vehicular
scenarios.
Indoor (layout.kind = "indoor", since 1.7.0)¶
The indoor office of TR 38.901 Table 7.2-2: a hall of 120 m x 50 m (layout.area_m) centred on
the origin, without walls, with ceiling sites on a rectangular grid every layout.site_spacing_m
metres (20 m gives the 12 sites of the standard). As in the open area, the LOS state is a process
along each user's path; use it with radio.pathloss: inh_office, whose LOS probability stands for
the walls and furniture. Users move between random waypoints at least 2 m from the walls; the
profile ped3 (about 3 km/h) is meant for it. layout.area_m (since 1.7.0) also sets the size
of the open area, for example for large rural cells. See the how-to indoor and rural
scenarios.
Soft LOS state (radio.soft_los, since 1.7.0)¶
By default the LOS state of a link switches hard between LOS and NLOS along a user's path. With
radio.soft_los: true HandoverLab uses the optional soft LOS state of TR 38.901 clause 7.6.3.3
(V17.0.0, Eq. 7.6-18 and 7.6-19): LOS_soft = 1/2 + arctan(sqrt(soft_los_d_m / lambda) (G + F(d))) / pi
with the wavelength lambda, F(d) = sqrt(2) erf^-1(2 Pr_LOS(d) - 1) and G the same spatially
consistent Gaussian process that sets the hard state, so the soft state turns into the hard one for
a very large soft_los_d_m. The channel H = H_LOS LOS_soft + H_NLOS sqrt(1 - LOS_soft^2) becomes,
at the received-power level of HandoverLab, the power mixture LOS_soft^2 P_LOS + (1 - LOS_soft^2) P_NLOS,
each with its own path loss and shadowing standard deviation on the same shadowing field. On the
street grid the buildings still block hard; the soft state applies to the clutter LOS state of the
links that the buildings leave clear. With radio.place_share < 1 on the grid, the part of the
shadowing that moves with the user takes the standard deviation of the dominant state.
Beams and several carriers (since 1.10.0)¶
Two optional models replace the antenna abstraction (bs_gain_db, isolation) and add carriers.
Both are off by default; without them every result of 1.x is unchanged.
SSB beams (radio.beams): every site carries sectors cells; each cell is a panel of
panel = [M, N] elements with the element of TR 38.901 Table 7.3-1 and sends n_ssb SSB beams with
DFT weights (at most L_max: 4 up to 3 GHz, 8 in FR1, 64 in FR2). The cell quality comes from the
beams (TS 38.331 5.5.3.3, ssb_avg, ssb_thresh_dbm). The gNB moves the serving beam (TCI state)
after periodic L1-RSRP reports (report_s, switch_db, tci_delay_s). Beam failure detection
and recovery follow TS 38.213 clause 6, TS 38.321 clause 5.17 and TS 38.133 clause 8.5 (the SSB of the
serving beam as BFD-RS, which TS 38.133 8.5.1 allows): an
indication every max(2 ms, T_SSB) when the serving beam's quality, averaged over the BFD evaluation
period, is below Qout_LR (Qout + 4 dB since 2.1.0, TS 38.133 Tables 8.5.2.1-1 and 8.1.2.1-1; 2.0.0: Qout); bfi_max (beamFailureInstanceMaxCount) indications within
bfd_timer (beamFailureDetectionTimer) periods start random access: contention-free on a
candidate beam above rsrp_thresh_ssb_dbm while bfr_timer_s runs, else contention-based; after
preamble_max failed attempts the random-access problem is a radio-link failure. Radio-link
monitoring uses the serving beam (rlm_rs: tci) or the best N_RLM beams (rlm_rs: beams; up to 8
in FR2). A cell interferes with the mean gain of its beams towards the user, scaled by its load.
scenario:
base: urban-grid-10ghz
radio:
carrier_ghz: 28.0
bandwidth_mhz: 400.0
beams: {sectors: 3, n_ssb: 8, panel: [4, 8], tilt_deg: 100.0}
protocol: {rlm_eval: ts38133}
Several carriers (radio.layers): each entry adds a carrier with its own sites (sites,
sites_file, site_spacing_m, site_offset_m) or, with cosited: true, the sites of the first
layer with the same LOS states, shadowing and blockers (TR 38.901 clause 7.6.5). Unset values come
from radio. Only cells of one carrier interfere with each other. Measurements are then SS-RSRP
per resource element, so that cells of different bandwidths compare correctly. With
protocol.meas_periods: ts38133 the UE measures the other carriers in the gaps of
protocol.gap_pattern (TS 38.133 Table 9.1.2-1) with the inter-frequency periods of TS 38.133
9.3.5 (FR1 320 ms, FR2 1.6 s with gap pattern 0) and receives no data during a gap. The rule
a3a5 hands over with Event A3 within a carrier and Event A5 towards another one. The scenario
cards urban-grid-28ghz-beams and dense-urban-2layer (ITU-R M.2412 two-layer dense urban) use
these models; see also the guide Beams and several carriers.
Sites from a file¶
layout.sites_file (since 1.0.0rc3) names a CSV file with a header row and the columns x and
y in metres (other columns are ignored; a relative path is taken from the current directory). It
is read when the scenario is loaded and fills layout.sites, so the coordinates are part of the
resolved scenario, of result files and of study job IDs. It works with every layout kind.
Built-in scenarios¶
Six named scenarios are ready to use. Each has a scenario card: purpose, layout drawing, every parameter with its source, known limitations and the reference results it was checked against (scenario cards).
| Name | Content |
|---|---|
smoke |
2 x 2-block grid, 150 m sites, 20 users, 30 s: runs in seconds; checks and first steps |
urban-grid-10ghz |
the defaults: 3 x 3-block grid, 100 m sites, 10 GHz, 40 users, 120 s |
open-area-10ghz |
same rectangle without buildings, hexagonal sites, nothing tied to place |
ns3-urban-3.5ghz |
the set-up of the comparison with ns-3 / 5G-LENA: seven sites, 3.5 GHz, light load |
fr3-urban-15ghz |
the default grid in the upper mid-band (FR3): 15 GHz, 400 MHz, 3.5 dB more beam gain (since 1.0.0rc2) |
highway-tr37885-4ghz |
the TR 37.885 highway below 6 GHz: 3 + 3 lanes with wrap-around, sites on one side 1732 m apart (TR 36.885 option 1), RMa LOS at 4 GHz, every vehicle at 140 km/h (since 1.11.0) |
highway-tr37885-30ghz |
the TR 37.885 highway above 6 GHz: the road through 19 hexagonal sites x 3 sectors, ISD 500 m (option 2), UMa LOS at 30 GHz, SSB beams, 140 km/h (since 1.11.0) |
indoor-office-4ghz |
the TR 38.901 indoor office: 120 m x 50 m hall, 12 ceiling sites 20 m apart, InH path loss at 4 GHz, pedestrians at 3 km/h (since 1.7.0) |
rural-700mhz |
rural macro cells: 11 sites 35 m high, 1732 m apart, RMa path loss at 700 MHz, vehicles at 120 km/h (since 1.7.0) |
urban-grid-28ghz-beams |
the urban grid at 28 GHz (FR2) with three sectors per site, 8 SSB beams per sector, beam management and beam failure recovery (since 1.10.0) |
ntn-leo600-2ghz |
a LEO satellite at 600 km with 19 earth-moving beams in the S band, handheld users, the channel of TR 38.811 and the parameters of TR 38.821 (since 2.0.0) |
dense-urban-2layer |
ITU-R M.2412 dense urban, two layers: 4 GHz macro (ISD 200 m, 3 sectors) and 30 GHz micro sites, SSB beams, inter-frequency measurements in gaps, in-car users at 30 km/h (since 1.10.0) |
Use one by name (scenario: smoke) or as the base of your own values
(scenario: {base: smoke, mobility: {users: 40}}); in Python hl.builtin_scenario("smoke").
handoverlab scenarios lists them and handoverlab scenarios smoke prints a card. The card
highway-10ghz (1.0.0rc3 to 1.10.0) was removed in 2.0.0 (see moving from 1.x to 2.0).
3. Configuration¶
An experiment file has four top-level keys, scenario, seeds, rules and output, plus the
optional schema (see stability). Every key is
checked; an unknown key or a value of the wrong type stops the run with a message that names the
key (for example unknown key 'scenario.layout.blokcs'). YAML (.yaml, .yml), TOML (.toml) and
JSON (.json) files are accepted, and hl.load_config() also takes a dictionary.
scenario:
layout: {kind: street_grid, blocks: [3, 3], site_spacing_m: 100.0}
radio: {carrier_ghz: 10.0, pathloss: umi_street_canyon}
mobility: {users: 40, profile: v60}
run: {duration_s: 120.0, warmup_s: 20.0}
seeds: [1, 2, 3] # or {first: 1, last: 10}
rules:
- {rule: a3, name: A3-3dB, off_db: 3.0, ttt_s: 0.16}
- {rule: cho, name: CHO, o_prep_db: -2.0, o_exec_db: 3.0, ttt_exec_s: 0.08, n_max: 4}
output: results/metrics.csv
Scenario keys and defaults¶
| Section | Key | Default | Meaning |
|---|---|---|---|
layout |
kind |
street_grid |
street_grid, open_area or highway |
blocks |
[3, 3] |
number of blocks in x and y | |
pitch_m |
[433, 250] |
distance between street centre lines [m] | |
street_width_m |
20 |
street width [m] | |
site_spacing_m |
100 |
site spacing along the streets / hexagonal spacing [m] | |
site_offset_m |
9 |
site distance from the street centre line (highway: from the road edge) [m] | |
sites |
none | explicit site list [[x, y], ...] [m] |
|
lanes, lane_width_m |
3, 4 |
highway: lanes per direction and their width [m] (since 1.0.0rc3) | |
road_length_m |
2000 |
highway: length of the road [m] (since 1.0.0rc3) | |
sites_file |
none | CSV file with columns x, y that fills sites (since 1.0.0rc3) |
|
area_m |
none | open area and indoor: width and height of the rectangle [m]; unset: the grid size (open area), 120 x 50 (indoor) (since 1.7.0) | |
min_site_distance_m |
0 |
open area and indoor: minimum distance of a user's start and waypoints from every site [m] (since 1.7.0; RMa: 35) | |
wrap |
false |
highway: wrap-around of TR 37.885 Table A-1 (since 1.11.0; see the highway guide) | |
highway_sites |
roadside |
highway: roadside, one_side (TR 36.885 option 1) or hex19 (19 hexagonal sites, option 2) (since 1.11.0) |
|
radio |
carrier_ghz |
10 |
carrier frequency (0.5-100 GHz) |
bandwidth_mhz |
200 |
carrier bandwidth | |
tx_power_dbm |
35 |
transmit power per site | |
bs_gain_db, ue_gain_db |
20, 0 |
beam gain towards a served or measuring user; UE gain | |
noise_figure_db |
7 |
UE noise figure | |
isolation |
0.3 |
mean-to-peak gain of an interfering data beam (1 = no beam isolation) | |
bs_height_m, ue_height_m |
10, 1.5 |
antenna heights; with channel_detail: 3gpp they must lie in the applicability ranges of TR 38.901 Table 7.4.1-1 (UMa, UMi: 1.5 m <= h_UT <= 22.5 m; RMa: 10-150 m and 1-10 m) |
|
pathloss |
umi_street_canyon |
or uma, rma (0.5-30 GHz), inh_office (TR 38.901 Table 7.4.1-1; the last two since 1.7.0; LOS probability of the mixed office), inh_office_open (since 2.1.0: InH-Office with the LOS probability of the open office, as ITU-R M.2412-0 Table A1-9) |
|
shadow_los_db, shadow_nlos_db |
4, 7.82 |
shadowing standard deviation | |
shadow_los_far_db |
none | optional (1.7.0): standard deviation of LOS links beyond the breakpoint distance (RMa: 6) |
|
shadow_decorr_m |
10 |
shadowing decorrelation distance | |
shadow_site_corr |
none | correlation of the shadowing between sites; unset (since 2.1.0): 0 with channel_detail: 3gpp (TR 38.901 clause 7.5 step 4), 0.5 with simple (the value of 2.0.0 and 1.x) |
|
place_share |
1 |
share of shadowing variance tied to place (rest moves with the user) | |
clutter_los |
true |
spatially consistent LOS state on top of the buildings | |
los_corr_m |
50 |
correlation distance of the LOS state | |
breakpoint_c_mps |
3.0e8 |
speed of light in the breakpoint distances (TR 38.901 Table 7.4.1-1 notes 1 and 5; since 2.0.0, 1.x: 299792458) | |
pl_min_distance_m |
10 |
smallest 2D distance the RMa, UMa and UMi formulas are evaluated at (their applicability range; since 2.0.0, 1.x: 0) | |
channel_detail |
3gpp |
since 2.1.0: 3gpp -- place-tied shadowing and LOS fields with the exponential autocorrelation exp(-d / d_cor) of TR 38.901 Eq. (7.4-5), shadowing uncorrelated between sites, UMa with C'(h_UT) and the random effective environment height (h_UT >= 13 m), height and co-sited-layer checks, the tabled NTN beamwidth; simple -- the model of 2.0.0 and 1.x (part of LEGACY_1X) |
|
map_file |
none | radio map from a file: path gain of every site and pixel from a ray tracer or measurements (TR 38.901 clause 8; since 1.12.0; see your own radio map); map_sha256 is filled on loading |
|
los_model |
probability |
los: every link LOS (TR 37.885 Table 6.2.1-2, highway); not on the street grid (since 1.11.0) |
|
soft_los, soft_los_d_m |
false, 20 |
optional (1.7.0): soft LOS state of TR 38.901 clause 7.6.3.3 instead of a hard switch (see below) | |
time_var_db, time_var_tau_s |
1.5, 1 |
time-varying component (moving blockers) | |
l1_error_db |
1 |
error of one L1 RSRP sample | |
energy_p0_w, energy_slope, energy_ntrx |
56, 2.6, 2 |
cell power for the energy metrics (1.5.0; energy_ntrx since 2.1.0): EARTH model of a micro base station, Auer et al. 2011 Eq. 1 and Table 2 |
|
fast_fading, delay_spread_ns, fading_k_db |
null, 100, null |
(2.2.0) TDL fast fading of TR 38.901 clause 7.7.2 (tdl-a .. tdl-e), its RMS delay spread and an optional K-factor; radio-link monitoring and the rate then use effective SINRs (guide) |
|
penetration_db, penetration_sd_db |
0, 0 |
optional (1.2.0): penetration loss of each user on all its links, drawn once per user (TR 38.901 clause 7.4.3.2: 9, 5 for a user in a standard car) |
|
map_res_m |
1 |
radio-map resolution (street grid) | |
scs_khz |
none | optional (1.10.0): subcarrier spacing for SS-RSRP per resource element; unset: 30 kHz up to 7.125 GHz, 120 kHz above | |
beams |
none | optional (1.10.0): sectors with SSB beams, beam management and beam failure recovery (see Beams and several carriers) | |
layers |
none | optional (1.10.0): further carriers with their own cells, e.g. a 28 GHz small-cell layer under a 3.5 GHz macro layer; since 1.11.0 a layer may set its own shadow_los_db, shadow_nlos_db, shadow_decorr_m and los_corr_m |
|
protocol |
step_s |
0.01 |
simulation step |
l1_period_s |
0.04 |
L1 sample period (multiple of step_s) |
|
l3_k |
4 |
L3 filter coefficient k (TS 38.331) | |
label_period_s |
0.2 |
sampling of the outcome-label quantities | |
report_interval_s |
0.12 |
periodic reporting interval (CHO) | |
qout_db, qin_db |
-8, -6 |
out-of-sync / in-sync SINR thresholds | |
qout_window_s, qin_window_s |
0.2, 0.1 |
their evaluation windows | |
n310, n311, t310_s |
1, 1, 1 |
radio-link failure counters and timer | |
prep_s, exec_s |
0.05, 0.04 |
preparation delay; execution interruption | |
reest_s, cho_recovery_s |
1, 0.4 |
outage of a re-establishment; of a recovery into a prepared cell | |
pingpong_s |
1 |
ping-pong window | |
cfg_loss_as_failure |
false |
check mode: a lost conditional configuration fails like a lost handover command | |
lm_load, lm_load_per_ue |
null, 0 |
optional (1.1.0): interferer load at link monitoring, plus a part per user served by the interfering cell; null and 0 keep the load of the data channel (calibration) |
|
exec_can_fail |
true |
false: the execution always joins the target (ideal random access) |
|
rrc_detail |
3gpp |
since 2.1.0: 3gpp -- the procedures as audited against TS 38.331 / TS 38.133 / TS 38.300 (RLM counters and indication interval, DAPS source monitoring, LTM candidates kept, CHO at TTT expiry, outcome classes, RRC messages one by one, ...); simple -- the engine of 2.0.0 (part of LEGACY_1X) |
|
scheduler, harq_max_tx, harq_rtt_slots, l2s_loss_db |
null, 4, 8, 1.0 |
(2.2.0) slot scheduler rr or pf with MCS table 1 and the TBS of TS 38.214, HARQ with 8 processes and chase combining; null keeps the rate model (guide) |
|
rlm_holdoff_s |
0.2 |
no out-of-sync on a newly joined cell for this time, as radio-link monitoring restarts there (an interpretation of TS 38.133 8.1.2.2; since 1.3.0, default since 2.0.0; 1.x: 0) |
|
cmd_during_t310 |
receive |
(1.9.0; default since 2.0.0, 1.x: lost) a handover or cell switch command that reaches the UE while T310 runs is received at the first step with link-monitoring SINR >= Qout and stops T310 (TS 38.331 5.3.5.5.2); since 2.0.0 a CHO or LTM candidate configuration is received the same way, without stopping T310 (5.3.5.13) |
|
t304_s |
1.0 |
T304 (1.9.0; default since 2.0.0, 1.x: none): the execution fails only when T304 expires; the UE joins at the first step with target SINR >= Qout (TS 38.331 5.3.5.8.3) | |
meas_periods |
sample |
ts38133 (1.10.0): every cell's L1 result is the average over its measurement period of TS 38.133 (intra-frequency Tables 9.2.5.2-1/-2, inter-frequency within gaps Tables 9.3.5-1/-2) and enters the L3 filter once per period |
|
ssb_period_s |
0.02 |
SSB and SMTC periodicity T_SSB used by the TS 38.133 formulas (1.10.0) | |
gap_pattern |
0 |
measurement gap pattern id of TS 38.133 Table 9.1.2-1 (0: MGL 6 ms, MGRP 40 ms) (1.10.0) | |
fr2_power_class |
3 |
FR2 UE power class: sets M_meas of TS 38.133 9.2.5.1 and 9.3.4 (1.10.0) | |
rlm_eval |
fixed |
ts38133 (1.10.0): Qout / Qin windows from TS 38.133 Tables 8.1.2.2-1/-2 for the serving cell's frequency range (FR2: 1.6 s / 0.8 s at T_SSB 20 ms) instead of qout_window_s / qin_window_s |
|
traffic |
services |
5 classes | list of {key, fiveqi, gbr, t_min_s, theta_q_db, share, rate_mbps} |
se_max |
7.4 |
spectral-efficiency cap [bit/s/Hz] | |
hotspot_share, hotspot_load, normal_load |
0.25, [0.55, 0.85], [0.05, 0.25] |
background load | |
load_tau_s, rate_scale, label_load |
0.2, 1, 1 |
load smoothing; rate multiplier; load assumed by the label | |
model |
fixed |
traffic model (1.4.0): fixed (constant service rates), full_buffer, periodic, ftp3; see examples/traffic_models.py |
|
period_s, packet_bytes |
0.02, 100 |
periodic: one packet every period |
|
ftp_file_bytes, ftp_rate_hz |
500000, 0.1 |
ftp3: file size (FTP model 3, 0.5 Mbyte as TR 38.821 Table 6.1.1.1-7) and file arrivals per user and second |
|
mobility |
users |
40 |
number of users |
profile |
v60 |
v60 (since 2.0.0, the default: 60 km/h in one of two 3.5 m lanes, TR 37.885), ped, veh8, veh16 (the 1.x default), veh30, mix, c8, c16, c30, ped3, veh120 (about 3 and 120 km/h, +-10 %); p3, v30, v120 (since 2.1.0: exactly 3, 30, 120 km/h, the fixed speeds of ITU-R M.2412-0 Tables 5a-5c); highway: hwy, hwy70, hwy140, v140, v70 (exactly 140 or 70 km/h with up to 0.5 m lateral offset, since 1.11.0), v140c, v70c (since 2.1.0: the same on the lane centre) |
|
groups |
none | own speed groups [{share, v_min, v_max, lateral_m: [lo, hi]}, ...] |
|
run |
duration_s, warmup_s |
120, 20 |
run length; initial part not counted |
ntn |
altitude_km, aperture_m, eirp_dbw_per_mhz, n_beams, centre_elevation_deg, environment, atm_zenith_db, scintillation_db, sf_corr_m, los_corr_m |
600, 2, 34, 19, 30, rural, 0.045, 2.2, 37, 60 |
the satellite of layout.kind: ntn (since 2.0.0; TR 38.821 LEO-600 Set-1 S band; see the NTN guide) |
backend |
numpy |
numba: compiled kernel of the step loop, bit-identical results, needs pip install handoverlab[fast] (since 1.11.0; see performance); not part of the job IDs |
In Python, Scenario.with_overrides({"layout.site_spacing_m": 50}) returns a changed copy.
Scenario.to_dict() leaves out the keys added after 1.0.0rc2 while they have their default value,
so that results and study job IDs of scenarios that do not use them are unchanged;
to_dict(full=True) lists every key.
Built-in rules¶
| Key | Family | Parameters |
|---|---|---|
a3 |
baseline | off_db (3.0), ttt_s (0.16), name |
cho |
conditional | o_prep_db (-2.0), o_exec_db (3.0), ttt_exec_s (0.08), ttt_prep_s (0.0), n_max (4, at most 8), rel_hys_db (3.0), immediate (false), name |
cho_d2 |
conditional | d_thresh1_m (25000), d_thresh2_m (25000), hys_m (1000), o_prep_db (-2.0), o_exec_db (0.0), ttt_exec_s (0.08), ttt_prep_s (0.0), n_max (4), rel_hys_db (3.0), name (since 2.0.0): CHO with condEventD2 of TS 38.331 together with A3; on fixed cells Event D1 |
daps |
daps | off_db (3.0), ttt_s (0.16), interruption_s (0.0), fallback (true), source_data (true), name |
ltm |
ltm | o_prep_db (-3.0), o_switch_db (2.0), ttt_switch_s (0.08), ttt_prep_s (0.0), n_max (4, at most 8), rel_hys_db (3.0), cmd_delay_s (0.01), exec_s (0.02), recovery (true), name |
cltm |
cltm | as ltm without cmd_delay_s: o_prep_db (-3.0), o_switch_db (2.0), ttt_switch_s (0.08), ttt_prep_s (0.0), n_max (4), rel_hys_db (3.0), exec_s (0.02), recovery (true), name |
a3_bandit |
baseline | arms_db ([1, 2, 3, 4, 5]), ttt_s (0.16), epsilon (0.1), epoch (10), w_unnecessary (1.0), w_failure (2.0), name |
a3a5 |
baseline | off_db (3.0), ttt_s (0.16), a5_thresh1_dbm (-110), a5_thresh2_dbm (-105), a5_hys_db (1.0), a5_ttt_s (0.32), a3_inter (false), a5_down_thresh1_dbm, a5_down_thresh2_dbm (none: as above), name (since 1.10.0): Event A3 within a carrier, Event A5 towards other carriers |
dqn |
baseline | offsets_db ([0, 1, 2, 3, 4, 5]), ttt_s (0.16), mode ("train" or "test"), weights (none), hidden (32), lr (0.001), gamma (0.9), epsilon (0.1), batch (32), buffer (5000), train_every (4), target_every (100), w_unnecessary (1.0), w_failure (2.0), name (since 1.11.0; needs pip install handoverlab[torch]; see the learning-rule guide) |
a3_mro |
baseline | off_db (3.0), ttt_s (0.16), step_db (1.0; 0.5 before 2.1.0), window_s (10.0), min_db (-4.0), max_db (4.0), min_events (1), count_pingpong (true), pingpong_s (none: protocol.pingpong_s), count_unnecessary (false), name (since 1.7.0) |
daps, ltm and a3_bandit were added in 1.0.0rc2; they leave the results of a3 and cho
unchanged.
Procedures¶
The family of a rule selects the procedure the engine runs around its decisions:
- baseline (
kindhoin the event log): Event A3 held for the time-to-trigger, measurement report, preparation (prep_s), handover command, execution (exec_s, an interruption), join of the target if its SINR is at least Qout. - conditional (
cho, TS 38.300 clause 9.2.3.4): prepared cells with execution conditions that the UE evaluates itself; faster recovery into a prepared cell after a failure (9.2.3.1, 9.2.7). - daps (
daps, Rel-16 DAPS handover, TS 38.300 clause 9.2.3.2): triggered like a baseline handover, but the source keeps carrying data during the execution, so the execution countsinterruption_s(default 0) instead ofexec_sininterruption_exec_s_pm, and the user keeps its rate. The source link is monitored during the execution; if the target cannot be joined before T304 expires and no radio-link failure of the source was detected, the UE falls back to the source (TS 38.331 clauses 5.3.10.1, 5.3.5.8.3; since 2.1.0, before: if the source SINR was at least Qout). No DAPS from FR2 to FR2 and none for NTN (TS 38.300). The attempt ends astoo_earlywith the causedaps_fallback, without a failure or a re-establishment.too_early_pmtherefore includes fallbacks for DAPS,failures_pmdoes not. Withfallback: false, source_data: falsethe rule gives exactly the results ofa3. - ltm (
ltm, Rel-18 L1/L2-triggered mobility, TS 38.300 clause 9.2.3.5): candidate cells are configured like the prepared cells of a conditional handover (L3 reports witho_prep_db, at mostn_max); the network then switches the UE to a candidate whose unfiltered L1 measurement beats the serving cell byo_switch_dbforttt_switch_s. The cell switch command (MAC CE) reaches the UE aftercmd_delay_s(while T310 runs it is received when the link reaches Qout); the execution (RACH-less, with early timing advance) takesexec_s, 20 ms more between FR1 and FR2; the other candidates stay configured after a switch (TS 38.331 5.3.5.18.6). Preparation counts (preparations_pm,unused_preparations_pm, ...) count candidate configurations;recoveries_pmcounts failures recovered into a candidate. - cltm (
cltm, conditional LTM, Rel-19 work on LTM; since 1.6.0): candidates as forltm, but the UE evaluates the L1 switch condition itself and executes as soon as it holds, without a cell switch command. There is no command that could arrive late (commands_pmis 0), as in conditional handover, and the switch is decided on fast L1 measurements, as in LTM.
See the how-to DAPS handover and L1/L2-triggered mobility and, for the
learning rule, a learning-based rule; for a3_mro, mobility robustness
optimisation.
4. Command line¶
handoverlab run CONFIG [-o OUT] [--seeds S ...] [--events FILE] [--event-level summary|full] [--debug] [-q]
handoverlab verify [--only GROUP ...] [--full] [--ns3 [--ns3-seeds N]] [--results FILE [--sample N]] [--json OUT] [-q]
handoverlab scenarios [NAME] [--markdown] [--svg FILE] [--docs DIR]
handoverlab study STUDY [-o DIR] [--workers N] [--tuning-only] [--dry-run] [--status] [--report] [-q]
handoverlab report DIR [-o OUT] [--format md html] [--no-figures]
handoverlab status STUDY|DIR [-o DIR] [--workers N]
handoverlab gui [--port N] [--address A] [--headless]
handoverlab rules
handoverlab init [PATH] [--force]
handoverlab --version
run writes one row per seed and rule. A .csv output has the columns seed, rule, family,
fingerprint (of the event log), n_events, the rule parameters (param.*) and all metrics; a
.json output also stores the resolved scenario, the rule key, the timing of every run and the
versions of Python, NumPy and SciPy. --events writes the event logs as one table (see section 8):
Parquet for a .parquet name if pyarrow is installed (pip install handoverlab[parquet]), CSV
otherwise; with --event-level full the procedure traces go to a second table next to it
(events.csv -> events-trace.csv). --debug checks the engine invariants while running
(section 9).
verify runs the verification checks and prints PASS, FAIL or NOTE for each (section 9).
scenarios lists the built-in scenarios or prints one card. study runs or resumes a study,
status shows what is left of it and how long that will take, and report writes its report
(section 10). gui starts the browser dashboard (dashboard; needs
pip install handoverlab[gui]). python -m handoverlab works as well. The full option lists are
in the command-line reference.
5. Python API¶
import handoverlab as hl
sc = hl.Scenario() # all defaults
exp = hl.load_config("examples/street_grid.yaml") # or a dict
results = hl.run_experiment(exp) # list of RunResult
world = hl.build_world(sc, seed=1) # one world ...
results = hl.run_world(world, [{"rule": "a3"}, {"rule": "cho"}], log_events="summary") # ... many rules
results[0].metrics["unnecessary_pm"], results[0].events[:3]
results[0].fingerprint # SHA-256 of the event log, always set
hl.simulate(sc, [{"rule": "a3"}], seeds=[1], debug=True) # with invariant checks
hl.write_events(results, "events.parquet") # event table (CSV without pyarrow)
full = hl.run_world(world, [{"rule": "a3"}], log_events="full")[0]
full.trace[:3] # procedure trace (section 8)
hl.run_world(world, [{"rule": "a3"}, {"rule": "cho"}], batch=False) # engines one by one (same results, slower)
run_world advances the engines of all rules of a world in one step loop (EngineBatch); the
results are bit-identical to running them one by one, which batch=False does. Engines in debug
mode always run one by one.
6. Writing a handover rule¶
A rule subclasses BaselineRule (A3-based handover), ConditionalRule (CHO), DAPSRule (DAPS
handover) or LTMRule (L1/L2-triggered mobility) and implements a few hooks. The engine calls the
hooks; the rule never calls the engine. Each hook receives an Observation with arrays over users
(U) and cells (M): meas_dbm, gap_db (neighbour minus serving), serving, connected, x,
y, speed, heading, t, and (since 1.0.0rc2) l1_dbm, the unfiltered L1 samples of the
same instant.
import numpy as np
import handoverlab as hl
@hl.register_rule("a3_speed")
class SpeedScaledA3(hl.BaselineRule):
"""Event A3 whose time-to-trigger shrinks with the reported speed."""
def __init__(self, off_db=3.0, ttt_s=0.32, v_ref=8.0, name="A3-speed"):
super().__init__(name)
self.off_db, self.ttt_s, self.v_ref = off_db, ttt_s, v_ref
def params(self):
return dict(off_db=self.off_db, ttt_s=self.ttt_s, v_ref=self.v_ref)
def a3_offsets(self, obs): # scalar or (U, M)
return self.off_db
def time_to_trigger(self, obs): # scalar or (U,)
return self.ttt_s * np.minimum(1.0, self.v_ref / np.maximum(obs.speed, 1e-6))
results = hl.simulate(hl.Scenario(), [{"rule": "a3"}, {"rule": "a3_speed", "v_ref": 10.0}], seeds=[1])
A ConditionalRule sets report_offset_db, ttt_prep_s, ttt_exec_s, n_max,
release_hys_db and immediate in its constructor and implements prep_offsets(obs) and
exec_offsets(obs) (scalar or (U, M)). A rule that learns from outcomes sets learns = True and
receives on_outcome(OutcomeRecord) and on_link_failure(FailureRecord); memory_key(obs) lets it
attach its own key to each preparation. Randomness must come from ctx.rng (given to reset(ctx)),
never from NumPy's global state.
A DAPSRule has the hooks of BaselineRule and sets interruption_s, fallback and
source_data. An LTMRule sets report_offset_db, ttt_prep_s, n_max, release_hys_db,
ttt_switch_s, cmd_delay_s, exec_s and recovery and implements prep_offsets(obs)
(candidate configuration, on L3 measurements) and switch_offsets(obs) (cell switch, on
obs.l1_dbm), each a scalar or (U, M).
Rules in an installed package are found through the entry-point group handoverlab.rules:
[project.entry-points."handoverlab.rules"]
a3_speed = "my_rules:SpeedScaledA3"
Checking a rule: the contract test kit¶
handoverlab.testing.check_rule runs a rule on the smoke scenario and checks the contract of the
rule interface. Put it in the tests of your plug-in package:
from handoverlab.testing import check_rule
from my_rules import SpeedScaledA3
def test_contract():
check_rule(SpeedScaledA3, params={"off_db": 3.0, "ttt_s": 0.32, "v_ref": 8.0},
reduces_to=("a3", {"off_db": 3.0, "ttt_s": 0.32}, {"v_ref": 1e9}))
| Check | Meaning |
|---|---|
construct |
the rule is built from params; params() is JSON-serialisable and reports them; the family is valid |
recreate |
cls(**rule.params()) reports the same parameters, so stored results can be repeated |
debug run |
a run in debug mode raises nothing: hook values have valid shapes and no NaN, the observation is not written to, all invariants hold |
deterministic |
two runs give identical event logs and metrics |
global random state |
NumPy's and Python's global random state are untouched (use ctx.rng) |
independent |
next to another rule on the same world the rule gives the same event log as alone |
reduces_to |
optional: with the given parameter changes the rule gives the event log of a built-in rule |
check_rule raises AssertionError that lists every failed check; with
raise_on_failure=False it returns the report instead.
7. Metrics¶
Rates are per UE-minute of the measured part of the run (suffix _pm); counts start with n_. The
full catalogue with units is handoverlab.engine.METRICS, listed in the
metric reference; the names are stable (see stability).
Outcome of an attempt (exactly one per attempt; n_attempts equals their sum):
too_late: the command or configuration was lost, or the link failed while the handover was in preparation, and the UE re-established in another cell than its serving cell;too_early: the execution failed, or the link failed in the target before the evaluation window closed, and the UE re-established in the source; for DAPS handover also an execution that fell back to the source (causedaps_fallback, not counted as a failure);wrong_cell: the same, but the UE re-established in a cell other than source and target;coverage(since 2.1.0): a failure of the attempt after which the UE re-established in its serving cell (the target after a handover); TS 38.300 15.5.2.2.2 defines no mobility class for it;unnecessary: completed, the source link would not have failed during the window, and the stay was shorter thant_min_sof the user's service or the mean SINR gain over the window was belowtheta_q_db;useful: completed and not unnecessary;censored: the run ended before the outcome was known.
These are the classes of TS 38.300 V18.11.0 clause 15.5.2.2.2 with protocol.rrc_detail: 3gpp; with
simple (2.0.0) a re-establishment in the target counted as too early and a lost command as too late in
any cell. The evaluation window opens when the handover completes and closes after t_min_s of the
user's service class (2 to 7 s) or at the next execution: it plays the part of the network's
threshold such as Tstore_UE_cntxt (TS 38.300 15.5.2.2.2 and 15.5.2.5: one value configured by OAM, compared
with the UE's timeConnFailure, the time since the execution of the last RRCReconfiguration with
reconfigurationWithSync, TS 38.331 5.3.10.5), but is set per service and counted from completion.
Failures by cause (failures_pm is their sum): command_lost, rlf_in_preparation,
rlf_without_handover (re-established in another cell), coverage_rlf (in the same cell),
execution_failure, rlf_after_handover.
Other metrics: handovers_pm, pingpong_pm, prepared_cells_mean, preparations_pm,
unused_preparations_pm, lost_configurations_pm, recoveries_pm, interruption_s_pm,
outage_pct, rate_mbps, delivered_share, sinr_db, spectral_eff, mean_load.
8. Event logs and fingerprints¶
A run keeps as much of its history as its event-log level asks for:
| Level | Kept | Typical use |
|---|---|---|
off (default) |
metrics and the fingerprint of the event log | tuning, large studies |
summary |
the event log: one row per handover attempt and per failure without a handover | test stages, causes of failures |
full |
the event log and the procedure trace | debugging a rule, following users, the dashboard map |
Set it with log_events="off" | "summary" | "full" in simulate and run_world (True means
summary), with --events FILE --event-level summary|full on the command line, or with
events: off | summary | full in a study file (true means summary). The level never changes a
result or a fingerprint. The event-log columns and units are in
handoverlab.eventlog.EVENT_COLUMNS:
| Column | Meaning |
|---|---|
seed, rule |
the run (in tables) |
t_attempt, t_exec, t_end |
start of the attempt, start of the execution (-1 if none), time of the outcome [s] |
ue, kind |
user; ho baseline handover, cho conditional handover, daps DAPS handover, ltm L1/L2-triggered mobility, cltm conditional LTM, rlf failure without handover |
src, tgt |
source cell; target cell (for rlf: cell of the re-establishment) |
outcome, cause |
outcome label (section 7) and failure cause |
dwell, gain_db |
stay in the target observed in the evaluation window [s] (capped at t_min_s; it ends at the next execution start or the failure), not the full time of stay of TR 36.839 5.2.2; mean SINR gain over the evaluation window [dB] |
n_prepared, o_exec |
prepared cells at the attempt; execution or A3 offset (-99 if none) [dB] |
x, y |
position at t_end [m] |
hl.write_events(results, path) writes the logs of many runs as one table (Parquet with typed
columns if pyarrow is installed and the name ends in .parquet, else CSV) and
hl.read_events(path) reads it back.
The trace (level full, RunResult.trace) has one row per procedure event with the columns
t, ue, event, src (serving cell before the event), cell, value and note:
| Event | Meaning |
|---|---|
serve |
initial serving cell at the first step |
report |
baseline handover: measurement report towards cell (value: A3 offset) |
prepare, release |
conditional handover: preparation of cell requested (value: execution offset), or released |
config, config_lost |
the conditional configuration of cell reached the UE, or was lost |
exec |
execution starts: the UE leaves src for cell |
attach, reestablish |
the UE is connected to cell after a handover or a re-establishment (value: SINR in dB) |
switch |
LTM: cell switch command towards cell sent (value: switch offset) |
fallback |
DAPS: the target could not be joined; the UE stays in src (value: source SINR) |
failure |
mobility failure; cell is the cell chosen for re-establishment, note the cause |
hl.eventlog.serving_cells(trace, n_users, times) turns a trace into the serving cell of every
user at the given times (-1 while not connected); write_trace and read_trace store traces as
tables like the event logs.
The fingerprint of a log is the SHA-256 of its canonical CSV form (hl.fingerprint(events)).
Every run computes it, also when the log is not kept, and it is written into every result row. Two
runs with the same fingerprint have identical event logs. Use it to check that a study gives the
same events on another machine: handoverlab verify --results results.json --sample 5 repeats
five stored runs and compares fingerprints and metrics.
9. Verification and debug mode¶
handoverlab verify runs these check groups (about two minutes) and prints a report:
| Group | Checks |
|---|---|
pathloss |
TR 38.901 path loss, breakpoint and LOS probability against values computed by hand |
radio |
shadowing standard deviation and correlation, LOS share against the LOS probability, time-varying part, L1 error, L3 filter |
invariants |
built-in rules in debug mode on a stressed scenario, conservation of all counts, determinism, independence of rules, global random state |
equivalences |
CHO with immediate execution = A3; a plug-in that reduces to A3 = A3 |
fingerprints |
golden event-log fingerprints of eight short reference runs |
--only GROUP ... selects groups, --full uses more seeds and longer runs, --ns3 runs the
comparison with stored ns-3 / 5G-LENA results (a few minutes; alone, unless --only names groups), --results FILE recomputes a stored
study, --json OUT writes the report. The exit status is 0 if no check failed. On another NumPy or
SciPy build than the reference, golden fingerprints may differ in single events; the report says so
and then requires only the counts to agree. Details and the results of this version:
verification.md.
Debug mode (debug=True in simulate, run_world and Engine; handoverlab run --debug;
or the environment variable HANDOVERLAB_DEBUG=1) checks the invariants of the engine at every
step: valid connection states and serving cells, T310 only while connected, no attempt towards the
serving cell, an execution always belongs to an attempt, finite SINR, one outcome per attempt,
event-log times in order, valid shapes and no NaN in the values a rule returns, and a read-only
observation for the rule. A violation raises hl.InvariantError naming the time, the users and the
rule. Debug mode changes no result and roughly doubles the run time. The conservation checks at the
end of a run (attempts = sum of outcomes, failures = sum of causes, every attempt has one outcome,
preparations = lost + cancelled + used + unused + open) always run.
10. Studies¶
A study compares handover rules in a controlled way: every rule with free parameters is tuned on its own seeds with the same number of evaluated settings, a fixed rule selects one setting per failure budget, automatic gates check that the tuning means something, and the tuned rules are then compared on separate test seeds with paired statistics. One file describes the whole study, one command runs it, and re-running it resumes where it stopped.
handoverlab study examples/study_smoke.yaml --dry-run # what would run
handoverlab study examples/study_smoke.yaml --workers 2 # run or resume
handoverlab status results/study-smoke # finished, failed, remaining jobs, time left
handoverlab report results/study-smoke # report.md, report.html, figures
The study file¶
name: study-smoke
scenario: {base: smoke, mobility: {users: 12, profile: veh30}}
conditions: # full factorial over dotted scenario keys (optional)
layout.site_spacing_m: [100.0, 150.0]
seeds:
tuning: [901, 902] # never used in a test
test: {first: 1, last: 6}
tuning:
settings: 8 # evaluated settings per method: the same for every method
random: 5 # of which drawn at random; the rest refine around the best
reference: {rule: a3, name: A3-ref, off_db: 3.0, ttt_s: 0.16}
budgets: {standard: 1.0, strict: 0.5} # shares of the reference failure rate
group_by: [layout.site_spacing_m] # tune per site spacing (default: per condition)
gates: {grid_edge: block} # warn | block | off per gate
methods:
A3:
rule: a3
grid: {off_db: [0, 1, 2, 3, 4, 5, 6], ttt_s: [0.0, 0.04, 0.08, 0.16, 0.32]}
natural_bounds: {ttt_s: lower} # 0 s is a floor, not an edge of the search
CHO:
rule: cho
fixed: {o_prep_db: -2.0, n_max: 4}
grid: {o_exec_db: [0, 1, 2, 3, 4, 5], ttt_exec_s: [0.0, 0.04, 0.08, 0.16, 0.32]}
compare:
baseline: A3-ref # a method, METHOD@BUDGET or the reference
metrics: [unnecessary_pm, failures_pm, handovers_pm, prepared_cells_mean]
output: results/study-smoke
A complete example, with four tuning seeds and the grid-edge policy extend, is examples/study_smoke.yaml. Every key is
checked; an unknown key or a parameter that the rule does not have stops the study with a message
that names it.
| Key | Meaning |
|---|---|
name, description |
name of the study (default: the file name) and a line for the report |
scenario |
as in an experiment file: a built-in name, {base: NAME, ...} or a full scenario |
conditions |
dotted scenario keys with lists of values; the study runs their full factorial |
seeds.tuning, seeds.test |
lists or {first, last}; tuning seeds are needed only for tuned methods |
tuning.settings |
evaluated settings per method and tuning group (default 12) |
tuning.random |
settings of the first, random round (default two thirds of settings) |
tuning.reference |
the rule whose failure rate sets the budgets (default A3 3 dB / 160 ms) |
tuning.budgets |
failure budgets as shares of the reference failure rate (default {standard: 1.0}) |
tuning.tolerance |
settings within this share of the lowest objective count as equal (0.05) |
tuning.group_by |
condition keys to tune per value of (default: all condition keys) |
tuning.gates |
policy per gate: warn, block or off (defaults below) |
tuning.binding_share, min_reference_failures, flat_share |
thresholds of the gates (0.9, 5, 0.5) |
tuning.objective, constraint, tie_breaks |
metrics of the selection rule (unnecessary_pm, failures_pm, [prepared_cells_mean, failures_pm]) |
tuning.extend_steps, max_extensions |
with gates: {grid_edge: extend}: values added at the edge per extension (2) and the most extension rounds (2) |
methods.NAME.rule |
registered rule key (built-in or plug-in) |
methods.NAME.grid |
parameter -> list of values; a method without a grid is not tuned |
methods.NAME.fixed |
parameters that stay fixed |
methods.NAME.natural_bounds |
grid ends that are physical limits, not search limits: lower, upper or both |
methods.NAME.settings |
an own budget (the equal_budget gate then blocks the study) |
compare.baseline |
the rule every other rule is compared with (default: the first test rule) |
compare.metrics, level, bootstrap, budget |
metrics of the report, interval level (0.95), bootstrap resamples (2000), restriction to one budget |
events |
event-log level of the test stage: off (default), summary (events/<run>.parquet) or full (also events/<run>-trace.parquet) |
output, workers |
study directory (default results/<name>) and worker processes |
Stages¶
tune1: every condition and tuning seed with the reference rule and the first settings of every tuned method, drawn at random from its grid without repetition (seeded by the method name).tune2: the rest of the budget, per tuning group: settings one grid step around the best setting of round 1 (under the first budget), then two steps, then, if the neighbourhood is used up, further random settings. Every method thus evaluates exactlysettingssettings, or its whole grid if that is smaller.- Selection and gates (below). A blocking gate stops the study here:
handoverlab studyexits with status 3 and names every failed gate;tuned/gates.jsonholds the full report. test: every condition and test seed with the reference, every tuned method with its selected setting per budget (namedMETHOD@BUDGET) and every untuned method.
Selection rule¶
The failure budget of a tuning group is a share of the mean failure rate of the reference rule on
the tuning runs of that group. Among the settings whose mean failure rate does not exceed the
budget, those within 5 % of the lowest unnecessary-handover rate count as equal; among them the
lowest preparation overhead (prepared_cells_mean) wins, then the lowest failure rate, then the
lowest unnecessary-handover rate, then the order of evaluation. If no setting meets the budget,
the setting with the lowest failure rate is used and flagged by the budget_met gate. The rule is
fixed before any result is seen; every evaluated setting and its means are stored
(tuned/<group>.json, tables/tuning), so the selection can be redone.
Gates¶
| Gate | Fails when | Default |
|---|---|---|
grid_edge |
the selected value of a numeric parameter is the smallest or largest of its grid and that end is not a declared natural bound (per method, budget and group); policy extend widens the grid and tunes again (below) |
block |
budget_binding |
more than 90 % of the settings of every method meet the budget, or the reference rule has fewer than 5 failures on the tuning runs: the budget does not constrain the trade-off | warn |
budget_met |
no setting of a method meets the budget | warn |
equal_budget |
methods evaluated different numbers of settings (except where a grid is smaller than the budget), or declared different budgets | block |
seed_overlap |
a tuning seed is also a test seed; checked before anything runs | block |
flat_optimum |
more than half of the settings within the budget lie within 5 % of the best: the tie-breaks decide | warn |
A setting on the edge of its grid means that the search may have stopped too early, and a baseline
tuned that way can make another rule look better than it is. With block the study stops before
any test run with a message such as grid_edge (layout.site_spacing_m=100, A3, budget standard):
off_db = 6 is the largest grid value. Extend the grid (or declare a natural bound) and run the
study again; finished jobs are reused.
Policy extend. With gates: {grid_edge: extend} the study widens the grid itself: at every
edge where a selected value sits, extend_steps new values are added with the spacing of the last
two grid values (off_db: [.., 5, 6] gains 7 and 8; a grid of non-negative values is never
extended below zero), and then every method is tuned again with the same budget on the widened
grids, so the budgets stay equal. Rule runs that were already simulated are reused. This repeats
until no selected value sits on an edge or max_extensions extension rounds were made; then a
remaining edge blocks like block, and so does an edge that cannot be widened (the message says
why). Every extension is recorded with its date in amendments.json and AMENDMENTS.md of the
study directory, applied again when the study is resumed, and listed in the report.
Results, resumption and workers¶
results/<study>/
manifest.json versions, platform, sessions (start, end, workers, jobs run), changes of the study file
study.lock.json the resolved study (every value explicit) and its SHA-256
plan.json job IDs of every stage of the current plan
jobs/<stage>/<job>.json one record per job: metrics, fingerprints, parameters, timing, peak memory
failed/<job>.txt traceback of a failed job (removed when it succeeds)
events/<run>.parquet event logs of the test stage (events: summary or full)
events/<run>-trace.parquet procedure traces of the test stage (events: full)
tuned/<group>.json every evaluated setting, the selection per budget, the gates of a group
tuned/gates.json the gate report of the whole study
amendments.json grid extensions made by the policy extend (and AMENDMENTS.md)
tables/runs.parquet one row per job and rule (CSV without pyarrow)
tables/tuning.parquet one row per evaluated setting
report/ report.md, report.html, summary.json, figures/
A job is one world (condition and seed) with its rules; its ID is a hash of its content (the
resolved scenario, the seed, the rules and the model version). A job record is written atomically:
it is complete or absent. Re-running a study skips every job whose record exists, so a study can be
stopped at any time and continued, also with another worker count. Changing the study file changes
only the IDs of the jobs it affects; the others are reused. A rule run (scenario, seed, rule and
parameters) that is already stored in the study, such as the reference rule or a setting that two
budgets select, is reused instead of simulated again, and if nothing is left to simulate the world
is not built at all. Tables are rebuilt from the job records after every run. A failed job leaves
its traceback in failed/ and stops the study after its stage; the next run retries it.
--workers N runs N jobs in parallel, one per process, with BLAS and OpenMP threads limited to one
per process. The default leaves cores free: the logical CPUs minus four (half of them on machines
with fewer than eight). Jobs start longest first. Neither the worker count nor the order changes a
result: two runs of the same study give identical event-log fingerprints.
handoverlab status STUDY (a study file or directory) lists, per stage, the planned, finished,
failed and remaining jobs and the rule runs still to simulate; stages that depend on earlier
results are counted from the study file until they can be planned. It also estimates the time
left from the finished jobs of the study (world-build time and engine time per rule run, scaled by
users and simulated seconds), spread over the workers of the last session or --workers N:
study study-smoke in results/study-smoke
last session (1) 2026-10-15T23:59:30+00:00: stopped after tuning
stage jobs done failed to run rule runs
tune1 8 8 0 0 0
tune2 8 8 0 0 0
test 12 0 0 12 48
gates: pass (0 blocking, 5 warnings)
time left: about 34 s with 2 workers (69 s CPU; measured on 16 jobs: world 2.5 s, 3.36 s per rule run of 1000 user-seconds)
The estimate is a guide: other conditions or rules can be slower or faster, and a busy machine is slower.
Parquet tables need pyarrow (pip install handoverlab[parquet]); without it the tables are CSV
files with the same columns. handoverlab.studies.read_table reads either into a list of
dictionaries; pandas reads both directly.
Report¶
handoverlab report DIR writes report.md, report.html (self-contained, figures embedded),
summary.json with every number, and the figures as PNG files (with matplotlib:
pip install handoverlab[report]). The report contains the provenance (versions, sessions, jobs,
simulated and reused rule runs), the tuning (budgets, selected settings, the gate table and a figure
of every evaluated setting per group), the means of every rule with t intervals (pooled and per
condition), and the paired comparisons with the baseline (section 11). study --report writes it
when the study is complete.
Python¶
from handoverlab.studies import GateError, load_study, run_study, write_report
study = load_study("examples/study_smoke.yaml")
try:
res = run_study(study, workers=2, progress=print)
write_report(res.root)
except GateError as e:
print(e.lines) # every blocking gate
11. Paired statistics¶
handoverlab.stats holds the statistics of the report. Rules compared on the same condition and
seed share the world and the radio samples, so the unit is one run and two rules are compared
through their paired differences (rule minus baseline).
| Function | What it does |
|---|---|
paired_differences(a, b) |
a - b for paired runs |
t_interval(d, level) |
two-sided t interval of the mean difference |
bootstrap_ci(a, b, stat, n_boot, level, seed) |
percentile bootstrap of the mean difference ("mean_diff") or of the ratio of the means ("ratio"); pairs are resampled together with a seeded generator |
wilcoxon_signed_rank(d, method) |
two-sided signed-rank test: zero differences dropped, mid-ranks for ties; "exact" builds the null distribution for the ranks present (valid with ties), "approx" is the normal approximation with tie and continuity corrections, "auto" is exact up to 500 non-zero differences |
holm(p) |
Holm's step-down adjustment of a family of p-values |
paired_summary(a, b) |
all of the above for one comparison |
noninferior(d, margin) |
the upper end of the one-sided interval lies below a margin |
seeds_needed(sd, half_width) |
paired runs for a t interval of the given half-width |
rnd(x, nd) |
decimal rounding half away from zero for printing (0.0975 -> 0.098) |
Per-run rates are ratios of small counts with many ties and zeros, which is why the exact rank test is the default. In the report, the pooled comparisons of one metric form one Holm family and the per-condition comparisons of one metric (all rules and conditions) another. With few seeds the intervals are wide; the report shows them next to every estimate.
12. Limitations¶
- Received-power level by default: no link-level physical layer, no HARQ, no beam management (one beam gain and one isolation factor), no packet-level protocol stack; fast fading with effective SINRs and a slot scheduler with MCS and HARQ are options since 2.2.0 (fading, scheduler).
- The failure model uses the SINR thresholds and timers of TR 36.839 with the procedures of TS 38.331 and TS 38.133 (see standard compliance); it is not calibrated in absolute terms, so failure rates are best read relative to each other.
- The radio map of a large grid takes some time and memory to build (for the default 3 x 3 grid at
100 m spacing, 76 sites and 0.2 million street pixels: about 17 s on a modest machine, see
performance.md). Use a larger
site_spacing_m, a coarsermap_res_mor fewer blocks for quick experiments. - Results are bit-identical for the same seed, version and machine. Across NumPy/SciPy builds or platforms, averaged quantities can differ in the last bits and, rarely, a single threshold event can move; event-log fingerprints show whether this happened.
- Compared with ns-3 / 5G-LENA, handover and unnecessary-handover rates agree (median ratios 0.95 and 1.06 over 24 A3 settings, version 2.1.0), failure rates do not: 0.03 against 0.43 per UE-minute (see the validation section of verification.md). Compare failure rates between rules within HandoverLab, not with absolute values from other simulators or from the field. An attempt to calibrate the failure model against ns-3 found no setting that holds on independent ns-3 results (calibration).
- Tuning gates check the search, not the model: a study that passes every gate can still answer the wrong question if the scenario does not fit it.