> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.nvidia.com/aerial/aodt/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.nvidia.com/aerial/aodt/_mcp/server.

# Intro to Calibration

## Table of Contents

1. [Introduction](#1-introduction)
   * [Simple calibration file bundle](#simple-calibration-file-bundle)
2. [Simulation setup](#2-simulation-setup)
3. [Calibration](#3-calibration)
4. [Outputs](#4-outputs)
5. [Post-processing](#5-post-processing)
6. [Calibration limitations](/limitations#calibration)

## 1. Introduction

Calibration closes the gap between a clean simulation and a measured real-world
site. It uses field measurements from one campaign to tune the scene model, so
later simulations of that same environment start from behaviour that has already
been anchored to reality instead of relying only on default assumptions.

Under the hood, the electromagnetic (EM) engine — ray tracing plus EM field
computation (see the [EM physics primer](/intro-em-physics)) — stays fixed as the
forward model; calibration adjusts its *inputs* (chiefly the materials, and
optionally other set-up quantities such as antenna orientations and beams — see
[Calibration targets](#33-calibration-targets)) until the engine's predictions
match the measurements.

Calibration is always done **from** an environment and **for** that same
environment.

This tie to a single scene is fundamental: the materials describe the
exact place that was measured and hold only within that same geometry, so **the
same scenario is required** throughout — materials calibrated in one place cannot
be applied to another.

End to end, calibration is a four-step loop; the rest of the guide refers back to
these steps:

```mermaid
flowchart LR
    M["1 · Measurement campaign"] --> C["3 · Calibration"]
    S["<div style='text-align:left'>2 · Aligned simulation in AODT<br /></div>"] --> C
    C --> O["Calibrated output"]
    O --> P["4 · Post-processing simulations<br />same environment"]
```

1. **Measurement campaign** — the actual, real-world field measurements **must be
   provided as input**; the tool never synthesizes them, so calibration is only as
   good as the real data supplied:
   * [GPS Exchange Format (GPX) trace](#21-ues) of the User Equipment (UE) positions
   * A [power-level trace](#32-power-measurements) per Radio Unit (RU)–UE link — not
     a single number, but a series of power values sampled along the UE trajectory
     (one CSV file per link)
2. **Aligned simulation in AODT** — reproduce the campaign synthetically:
   * Match the real scenario on map and georeferencing
   * Ensure frequency settings, RU locations, and UE locations are accurately set
   * See [Simulation setup](#2-simulation-setup)
3. **Calibration** — combine the rays exported in the [simulation](#2-simulation-setup) with the measurements from step 1:
   * The main outputs are the **material categories** of the campaign environment:
     [calibrated building-material outputs](#calibrated-building-material-outputs)
     and, when vegetation is present and hit by rays,
     [calibrated vegetation-material outputs](#vegetation-material-outputs)
   * See [Calibration](#3-calibration) and [Training](#35-training)
4. **Post-processing simulations** — reuse the recovered
   [calibrated building-material outputs](#calibrated-building-material-outputs)
   and [calibrated vegetation-material outputs](#vegetation-material-outputs):
   * Generalize any other simulation **in that same environment**
   * See [Post-processing](#5-post-processing)

Calibration is a **model-based** inverse problem, not a black-box curve fit. The
physics [EM engine](/intro-em-physics#3-em-field-modeling) stays fixed as the
forward model: it traces rays through the scene, applies material-dependent
reflection, transmission, diffraction, and scattering effects, and predicts the
received power at each link. Calibration runs that relationship backwards:

```mermaid
flowchart LR
    X["<b>Input</b><br />Materials properties"] --> F["<div style='text-align:left'><b>EM engine — forward model</b><br />• Ray Tracing<br />• EM propagation</div>"]
    F --> Y["<b>Output</b><br />Observable signal"]
    Y -. "Calibration" .-> X
```

**Disclaimer:** calibration is in an early alpha version and has not yet been
tested extensively enough to guarantee results. Treat every output as
experimental and validate it against held-out measurements before relying on it.

The main practical boundaries are maintained in
[Calibration limitations](/limitations#calibration), including data knowledge,
generalization, and recoverability limits.

### Simple calibration file bundle

Use this bundle as a **quick lookup for the files a calibration campaign needs**.
The paths are intentionally general-purpose: replace `path/to/campaign/` with the
folder or object-store prefix used by the run.

Files needed:

* one **GPX trace** excerpt of the UE route — to learn more, see [§2.1 UEs](#21-ues)
* two **Reference Signal Received Power (RSRP) measurement CSV** excerpts under `measurements/` — to learn more, see [§3.2 Power measurements](#32-power-measurements)
* no map files in the bundle; the scene and optional vegetation are referenced by location

```yaml
db:
  db_author: aerial
  db_host: clickhouse
  db_port: 9000
  opt_in_tables:
  - cfrs
  - raypaths
  - cirs
  opt_in_tables_options:
    raypaths: full
  parquet_export:
    compression: zstd
    iceberg:
      catalog_name: default
      catalog_type: rest
      catalog_uri: https://iceberg-catalog.example.com/
      nessie_ref: main
      database: simple_campaign_input
    max_workers: 2
    s3_configs:
    - access_key: STORAGE_ACCESS_KEY
      bucket: my-calibration-bucket
      endpoint_url: https://object-store.example.com
      nodes:
      - node1
      provider: minio
      region: us-east-1
      secret_key: STORAGE_SECRET_KEY
      use_ssl: false
    timesteps_per_file: 100
    verify_exports: true
  sim_id: simple_campaign_input
  s3_config:
    bucket: my-calibration-bucket
    endpoint_url: https://object-store.example.com
    access_key: STORAGE_ACCESS_KEY
    secret_key: STORAGE_SECRET_KEY
    provider: minio
    region: us-east-1
gis:
  scene:
    scene_url: my_scene
  spawn_zone:
    points_ccw:
    - x: -100.0
      'y': -100.0
    - x: 100.0
      'y': -100.0
    - x: 100.0
      'y': 100.0
    - x: -100.0
      'y': 100.0
  vegetation:
    active: true
    geojson:
    - path/to/campaign/vegetation.geojson
    vegetation_asset_path:
    - path/to/assets/street_tree.json
sim:
  DUs:
    add:
    - id: 1
      position:
        pos:
          x: 0.0
          'y': 0.0
          z: 0.0
    default: path/to/assets/du.json
    update:
    - attributes:
        aerial_du_fft_size: 1
        aerial_du_max_channel_bandwidth: 100.0
        aerial_du_num_antennas: 1
        aerial_du_reference_freq: 3619.0
        aerial_du_subcarrier_spacing: 60.0
      ids:
      - 1
  Materials:
    default: path/to/assets/materials.json
  Panels:
    add:
    - id: 1
    - id: 2
    default: path/to/assets/panel.json
    update:
    - attributes:
        antenna_names:
        - halfwave_dipole
        antenna_roll_angle_first_polz_degree: 0.0
        antenna_roll_angle_second_polz_degree: 90.0
        antenna_spacing_horz_mm: 41.419239845261124
        antenna_spacing_vert_mm: 41.419239845261124
        dual_polarized: false
        num_loc_antenna_horz: 1
        num_loc_antenna_vert: 1
        reference_freq_mhz: 3619.0
      ids:
      - 1
    - attributes:
        antenna_names:
        - threeGPP_38901
        antenna_roll_angle_first_polz_degree: 0.0
        antenna_roll_angle_second_polz_degree: 90.0
        antenna_spacing_horz_mm: 41.419239845261124
        antenna_spacing_vert_mm: 41.419239845261124
        dual_polarized: false
        num_loc_antenna_horz: 1
        num_loc_antenna_vert: 1
        reference_freq_mhz: 3619.0
      ids:
      - 2
  RUs:
    add:
    - id: 2
      position:
        pos:
          x: 3420.0
          'y': 3980.0
          z: 3025.0
    - id: 1
      position:
        pos:
          x: -13390.0
          'y': -4090.0
          z: 3275.0
    default: path/to/assets/gnb.json
    update:
    - attributes:
        aerial_gnb_carrier_freq: 3619.0
        aerial_gnb_du_id: 1
        aerial_gnb_du_manual_assign: true
        aerial_gnb_height: 1.5
        aerial_gnb_mech_azimuth: 195.0
        aerial_gnb_mech_tilt: 5.0
        aerial_gnb_panel_type: 2
        aerial_gnb_radiated_power: 43.0
      ids:
      - 2
    - attributes:
        aerial_gnb_carrier_freq: 3619.0
        aerial_gnb_du_id: 1
        aerial_gnb_du_manual_assign: true
        aerial_gnb_height: 1.5
        aerial_gnb_mech_azimuth: 280.0
        aerial_gnb_mech_tilt: 5.0
        aerial_gnb_panel_type: 2
        aerial_gnb_radiated_power: 43.0
      ids:
      - 1
  Scatterers:
    default: path/to/assets/car_small.json
  Scenario:
    default: path/to/assets/scenario.json
    update:
    - attributes:
        sim_batches: 1
        sim_duration: 1.0
        sim_em_diffuse_type: 0
        sim_em_interactions: 5
        sim_em_max_num_paths_per_ant_pair: 500
        sim_em_rays: 1000
        sim_enable_wideband: true
        sim_gnb_panel_type: 2
        sim_interval: 0.51
        sim_is_full: false
        sim_is_seeded: true
        sim_samples_per_slot: 1
        sim_seed: 42
        sim_simulation_mode: 0
        sim_slots_per_batch: 0
        sim_ue_panel_type: 1
        sim_ue_min_speed: 1.5
        sim_ue_max_speed: 1.5
  UEs:
    add:
    - gpx:
        src: path/to/campaign/gpx/route_10.gpx
        use_pathfinding: false
      id: 1
    default: path/to/assets/ue.json
    update:
    - attributes:
        aerial_ue_bler_target: 0.1
        aerial_ue_initial_mech_azimuth: 0.0
        aerial_ue_manual: true
        aerial_ue_mech_tilt: 0.0
        aerial_ue_panel_type: 1
        aerial_ue_radiated_power: 26.0
      ids:
      - 1
    - attributes:
        aerial_ue_radiated_power: 26.0
      ids:
      - '*'
  VegetationMaterials:
    default: path/to/assets/vegetation_materials.json
  BldgExterior:
    update:
    - attributes:
        AerialRFDiffraction: true
        AerialRFDiffuse: true
        AerialRFMesh: true
        AerialRFTransmission: true
        AerialRFdS: 15.0
      ids:
      - '*'
```

The `db:`, `gis:`, and `sim:` blocks are **the same as in `sim.yml`** above and
are omitted here. Only the calibration-specific `cal:` block is shown.

```yaml
# db:, gis:, and sim: — same as sim.yml (omitted)
cal:
  targets:
    Materials: true
    VegMaterials: false
    UEs: true
    RUs: true
    RUsBeams: false
  timeline:
    start: 0
    step: 1
    end: 9
  measurements:
  - ru_id: 1
    ue_id: 1
    measurement_file: path/to/campaign/measurements/ru1_ue1_rsrp.csv
  - ru_id: 2
    ue_id: 1
    measurement_file: path/to/campaign/measurements/ru2_ue1_rsrp.csv
  output:
    folder_key: path/to/calibrated-output/simple-campaign/
```

```xml
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
  <metadata>
    <name>Helsinki Route (10 samples)</name>
    <desc>10-point excerpt, Helsinki</desc>
  </metadata>
  <trk>
    <name>Helsinki Route</name>
    <trkseg>
      <trkpt lat="60.1708822852" lon="24.9359953119">
        <ele>8.596870</ele>
        <time>2024-06-15T10:00:00Z</time>
      </trkpt>
      <trkpt lat="60.1708739291" lon="24.9359748632">
        <ele>8.618520</ele>
        <time>2024-06-15T10:00:01Z</time>
      </trkpt>
      <trkpt lat="60.1708655730" lon="24.9359544145">
        <ele>8.640169</ele>
        <time>2024-06-15T10:00:02Z</time>
      </trkpt>
      <trkpt lat="60.1708572169" lon="24.9359339658">
        <ele>8.661819</ele>
        <time>2024-06-15T10:00:04Z</time>
      </trkpt>
      <trkpt lat="60.1708488608" lon="24.9359135171">
        <ele>8.683468</ele>
        <time>2024-06-15T10:00:05Z</time>
      </trkpt>
      <trkpt lat="60.1708405047" lon="24.9358930684">
        <ele>8.705117</ele>
        <time>2024-06-15T10:00:07Z</time>
      </trkpt>
      <trkpt lat="60.1708321486" lon="24.9358726197">
        <ele>8.726767</ele>
        <time>2024-06-15T10:00:08Z</time>
      </trkpt>
      <trkpt lat="60.1708237925" lon="24.9358521710">
        <ele>8.748416</ele>
        <time>2024-06-15T10:00:10Z</time>
      </trkpt>
      <trkpt lat="60.1708154363" lon="24.9358317223">
        <ele>8.770065</ele>
        <time>2024-06-15T10:00:11Z</time>
      </trkpt>
      <trkpt lat="60.1708070802" lon="24.9358112736">
        <ele>8.791715</ele>
        <time>2024-06-15T10:00:13Z</time>
      </trkpt>
    </trkseg>
  </trk>
</gpx>
```

For more information about the GPX trace and UE setup, see [§2.1 UEs](#21-ues).

```csv
uniqtimestamp,time,Power_PCI_1
,0,-80.10551452636719
,1,-78.56816101074219
,2,-78.04997253417969
,3,-76.04109954833984
,4,-77.53031921386719
,5,-80.73326873779297
,6,-83.16703033447266
,7,-83.58888244628906
,8,-84.93547058105469
,9,-84.13796997070312
```

For more information about power measurements, see [§3.2 Power measurements](#32-power-measurements).

```csv
uniqtimestamp,time,Power_PCI_2
,0,-54.06867980957031
,1,nan
,2,-51.98063278198242
,3,nan
,4,nan
,5,-53.42338943481445
,6,-56.82176971435547
,7,-61.351783752441406
,8,-59.29664993286133
,9,-56.15536117553711
```

For more information about power measurements, see [§3.2 Power measurements](#32-power-measurements).

## 2. Simulation setup

**Goal.** Before calibration starts, reproduce the measurement campaign inside
AODT so the traced rays and real measurements describe the same links. Match the
configuration that determines:

1. map georeferencing
2. link geometry ([§2.1 UE placement](#21-ues) and [§2.2 RU placement](#22-rus))
3. antenna setup
4. [scene RF interactions](#23-scenario)
5. [frequency settings](#24-frequency-settings)

This setup belongs to the simulation that precedes calibration. The fields here
become the fixed context for the following calibration pass, which relies on the
rays, links, and frequency samples produced by that previous simulation. This
connects directly to [Calibration targets](#33-calibration-targets) and
[§4 Outputs](#4-outputs).

**How this section is organized.** Each category below uses the same layout:

1. **Configuration snippet** — YAML fields that the following calibration step
   will rely on for that category.
2. **Using existing campaign data** — if campaign data is already available (GPX traces,
   mounting angles, antenna patterns, site coordinates, and so on) and do not need
   the full background, map the user's inputs onto the snippet fields wherever possible.
   The more accurate the values supplied here, the less ambiguity the
   following calibration step has to resolve.
3. **Limitations** — hard constraints and common mistakes for that category.

For each target, [§3.3 Calibration targets](#33-calibration-targets) summarizes
which quantities are optimized and which remain fixed.

Categories covered: [§2.1 UEs](#21-ues), [§2.2 RUs](#22-rus),
[§2.3 Scenario](#23-scenario), [§2.4 Frequency](#24-frequency-settings). For the material-category flow, the key
setup checks are [scene RF interactions](#23-scenario) and
[frequency settings](#24-frequency-settings).

### 2.1 UEs

In this section, a UE calibration target means a UE from the measured campaign
that can be part of one or more targeted RU-UE links. The same UE can appear in
multiple targeted links, for example when several RUs measured the same UE route.
The following calibration step uses measurements on those links to compare the EM
prediction against the campaign data. From that UE, calibration can later recover
the device **orientation** along the route — its tilt, its azimuth, and the panel
**roll** — while the GPX **position** is always kept exactly as measured (full
detail in [§3.3 UEs](#ue-calibration-target)). The fields below are the setup inputs that feed that
step.

#### Configuration snippet

Calibration-relevant UE and linked panel settings:

```yaml
sim:
  UEs:
    add:
      - id: 1
        gpx:
          src: path/to/route.gpx  # a UE calibration target must be defined from a GPX file
          use_pathfinding: false
    update:
      - ids:
          - 1
        attributes:
          aerial_ue_panel_type: 3  # Links the UE to panel id 3; training details in §3.3 UEs
          aerial_ue_mech_tilt: 39.9414128887659  # Setup value; training details in §3.3 UEs
          aerial_ue_initial_mech_azimuth: 0.0  # Not learnable; imported setup value
  Panels:
    update:
      - ids:
          - 3
        attributes:
          num_loc_antenna_horz: 1  # Important: keep antennas as single element
          num_loc_antenna_vert: 1  # Important: keep antennas as single element
          dual_polarized: false
          antenna_names: [halfwave_dipole]  # built-in name or polarimetric CSV/FFD path
          antenna_roll_angle_first_polz_degree: 0.0  # Setup value; training details in §3.3 UEs
          antenna_roll_angle_second_polz_degree: 90.0  # Setup value; training details in §3.3 UEs
```

This snippet only identifies the UE setup values used by the UE calibration
target.

The full trained / not-trained split is described in [§3.3 UEs](#ue-calibration-target).

#### Using existing campaign data

* **Panel link** — `aerial_ue_panel_type` selects which `Panels` block describes
  the UE antenna (`3` → panel id `3`); the value must match an `id` under
  `Panels`.
* **Roll emits a new panel** — learning the UE panel roll writes a **new** per-UE
  panel rather than editing the base block (see [§3.3 UEs](#ue-calibration-target)).
* **GPX trajectory** — set `gpx.src` to the recorded route file. Each
  `<trkpt>` supplies lat, lon, and elevation for one sample:

```xml
<trkpt lat="60.1708321486" lon="24.9358726197">
  <ele>8.726767</ele>
  <!-- optional — per-time-index ue_tilt / ue_azimuth; see below -->
  <extensions>
    <angles>
      <ue_tilt>0.0</ue_tilt>
      <ue_azimuth>0.0</ue_azimuth>
    </angles>
  </extensions>
</trkpt>
<trkpt lat="60.1708237925" lon="24.9358521710">
  <ele>8.748416</ele>
</trkpt>
```

* **Per-time-index UE angles must be supplied through GPX** — when per-sample UE
  orientation is known, provide it on each `<trkpt>` in the `<extensions>` block
  (the same format written by calibration output — see
  [§4 UE GPX output](#ue-gpx-example)). Section [§3.3 UEs](#ue-calibration-target) explains how these
  GPX values relate to the trained UE terms. Values that can be supplied **per
  time index**:
  * `ue_tilt` — the tilt for that sample
  * `ue_azimuth` — the azimuth for that sample
* **Antenna pattern** — to set up UE antenna types on the panel selected by
  `aerial_ue_panel_type`, use one of:
  * **Custom pattern** — supply a custom pattern file path; see
    [`Panel.create_panel_from_file`](/api/config#create_panel_from_file).
  * **Built-in pattern** — select a built-in pattern constant; see
    [`Panel.create_panel`](/api/config#create_panel).
* **Panel geometry** — keep the UE panel as a single element:
  * `num_loc_antenna_horz: 1`
  * `num_loc_antenna_vert: 1`
  * roll angles — supply as starting values if known.

#### Limitations

UE setup constraints are consolidated under
[Calibration limitations → Simulation before calibration → UEs](/limitations#ues).

### 2.2 RUs

In this section, an RU calibration target means an RU from the measured campaign
that belongs to a targeted RU-UE link. The current calibration ingestion supports
one UE only, so the same RU cannot appear in multiple RU-UE links. The following
calibration step uses measurements on that link to compare the EM prediction
against the campaign data. From that RU, calibration can later recover its
mounting orientation and panel roll, while the RU position and height should
already match the campaign as closely as possible. The fields below are the setup
inputs that anchor every traced link.

#### Configuration snippet

Calibration-relevant RU and linked panel settings:

```yaml
sim:
  RUs:
    add:
      - id: 1
        position:
          pos:
            x: -13390.0
            y: -4090.0
            z: 3275.0
    update:
      - ids:
          - 1
        attributes:
          aerial_gnb_height: 1.5  # meters — see effective-z formula below
          aerial_gnb_panel_type: 4  # Learnable feature through panel roll
          aerial_gnb_mech_azimuth: 0.031  # Learnable feature (seed)
          aerial_gnb_mech_tilt: 39.21  # Learnable feature (seed)
  Panels:
    update:
      - ids:
          - 4
        attributes:
          antenna_names: [threeGPP_38901]
          num_loc_antenna_horz: 1  # Single element so the ray launch sits at the panel phase center (see two-stage note in §2.2)
          num_loc_antenna_vert: 1  # Multi-element here would offset the launch point and misalign the beam array factor
          dual_polarized: false
          antenna_roll_angle_first_polz_degree: -22.75  # Learnable roll angle
          antenna_roll_angle_second_polz_degree: 67.25  # Learnable roll angle
```

#### Using existing campaign data

Calibration reuses the same panel inputs as any AODT custom panel — no
calibration-specific antenna file format. Map the site data directly:

* **Panel link** — `aerial_gnb_panel_type` selects which `Panels` block describes
  the RU antenna (`4` → panel id `4`); the value must match an `id` under
  `Panels`.
* **Roll is the learnable part** — the roll angles on that block
  (`antenna_roll_angle_first_polz_degree` /
  `antenna_roll_angle_second_polz_degree`) are what calibration trains.
* **A new panel is emitted, not edited in place** — the input panel is only the
  **base**; when RU orientation is calibrated, the recovered roll is written as a
  **new panel** for that RU, identical to the input but with rotated roll (see
  [§3.3 RUs](#ru-calibration-target) and
  [§4 Calibrated simulation configuration](#calibrated-simulation-configuration)).
* **Single-polarized, two roll angles** — `dual_polarized: false` is kept on both
  input and output, but the estimate is computed under the hood as a combination
  of two polarizations, so two roll angles appear even though the panel is
  single-polarized.
* **3D location** — place the RU with one of the supported position inputs:
  * **Georeferenced latitude/longitude** — same as the
    [Config API](/api/config#georef) `Position.georef(lat, lon, alt)` path; the
    YAML loader accepts `lat` / `lon` under `position.pos`.
  * **Cartesian coordinates** — set `position.pos` as `(x, y, z)` on the
    georeferenced map.
* **Antenna height** — set `aerial_gnb_height` (meters). The traced antenna
  reference is **not** `position.pos.z` alone. The effective antenna **z** uses:

  * `meters_per_unit` — map-specific scale factor (default `0.01`; see
    [EM solver API](/api/em-solver))
  * `z` — RU `position.pos.z`
  * `aerial_gnb_height` — RU antenna height in meters

  The final z coordinate is:

  $$
  z_\text{antenna} = z + \frac{h / \text{meters\_per\_unit}}{2}
  $$

  Example: with `meters_per_unit: 0.01`, `z: 3275`, and
  `aerial_gnb_height: 1.5`, the final z coordinate is
  `3275 + (1.5 / 0.01) / 2 = 3350`.
* **Mounting angles** — set the best available survey or installation values:
  * `aerial_gnb_mech_azimuth`
  * `aerial_gnb_mech_tilt`
* **Antenna pattern** — to set up RU antenna types on the panel selected by
  `aerial_gnb_panel_type`, use one of:
  * **Custom pattern** — supply a custom pattern file path; see
    [`Panel.create_panel_from_file`](/api/config#create_panel_from_file).
  * **Built-in pattern** — select a built-in pattern constant; see
    [`Panel.create_panel`](/api/config#create_panel).

**Panel geometry uses two stages — this separates ray tracing from the EM/beam
computation.** The simulation that exports ray paths uses a single-element RU
panel: with one element the ray launch/arrival point sits exactly at the panel
**phase center**, so the traced geometry and the later beam array factor share the
same origin. Beam calibration then uses a separate multi-element panel in the
calibration/follow-up config; that array defines the beamforming degrees of freedom
(the array factor) and is reflected in the [§4 Outputs](#4-outputs).

If the simulation panel is *not* single-element, the per-element launch points are
offset from the panel center, the beam array factor applied during calibration is
**misaligned** with the traced geometry, and the resulting beam predictions are not
guaranteed. Conversely, a single-element panel in **calibration** has no array
factor at all, so no beams can be recovered — beam calibration is only possible on
the multi-element array.

```yaml
sim:
  RUs:
    update:
    - ids: [1]
      attributes:
        aerial_gnb_panel_type: 4
  Panels:
    update:
    - ids: [4]
      attributes:
        antenna_names: [threeGPP_38901]
        num_loc_antenna_horz: 1
        num_loc_antenna_vert: 1
```

```yaml
sim:
  RUs:
    update:
    - ids: [1]
      attributes:
        aerial_gnb_panel_type: 4
  Panels:
    update:
    - ids: [4]
      attributes:
        antenna_names: [threeGPP_38901, threeGPP_38901, threeGPP_38901, threeGPP_38901]
        num_loc_antenna_horz: 1
        num_loc_antenna_vert: 4
```

See [§3.3 RUsBeams](#ru-beams-calibration-target) for how to size the calibration array.

#### Limitations

RU setup constraints are consolidated under
[Calibration limitations → Simulation before calibration → RUs](/limitations#rus).

### 2.3 Scenario

The scene geometry only participates in propagation once its RF behaviour is
switched on. These switches decide which paths the ray tracer can generate — and
therefore which interactions calibration can later attribute to a material.

#### Configuration snippet

```yaml
sim:
  BldgExterior:
    update:
    - attributes:
        AerialRFMesh: true          # reflection
        AerialRFTransmission: true  # transmission — DEFAULT IS false
        AerialRFDiffuse: true       # diffuse scattering
        AerialRFdS: 15.0            # scattering surface-element area
        AerialRFDiffraction: true   # diffraction
      ids:
      - '*'

gis:
  vegetation:
    active: true   # vegetation is OFF by default — must be turned on
    geojson:
    - path/to/vegetation.geojson
    vegetation_asset_path:
    - path/to/assets/street_tree.json
```

#### Limitations

Scene RF-interaction constraints are consolidated under
[Calibration limitations → Simulation before calibration → Materials and scenario](/limitations#materials-and-scenario);
the GIS / vegetation-setup constraints are under
[Calibration limitations → Simulation before calibration → Vegetation materials](/limitations#vegetation-materials).

**Choosing `AerialRFdS` (scattering element area `ds`).** `ds` is the area
$\Delta S$ each facade is tiled into; one scattered ray launches per element (see
[Surface diffuse scattering](/intro-em-physics#323-surface-diffuse-scattering)).

* **Large `ds`** — fewer surface elements and lower ray cost, but sparse
  scattering hits can leave weak or vanishing gradients during
  [training](#35-training).
* **Small `ds`** — finer scattering coverage and stronger calibration signal, but
  it burns the ray budget.

### 2.4 DU frequency settings

Calibration is **narrowband** by design: one campaign constrains materials at a
single center frequency. Cross-frequency generalization requires repeating the
campaign at another center frequency (see [Introduction](#1-introduction)); the
material terms affected by this narrowband assumption are summarized under
[§3.3 Materials calibration target](#materials).

#### Configuration snippet

Select the DU FFT size to match how the campaign was measured:

```yaml
sim:
  DUs:
    update:
    - attributes:
        aerial_du_fft_size: 512   # in-band RSRP — use 1 for out-of-band single carrier
```

Set the carrier on each RU to match the campaign:

```yaml
sim:
  RUs:
    update:
    - ids: [1]
      attributes:
        aerial_gnb_carrier_freq: 3619.0
```

#### Using existing campaign data

Map the measurement setup directly:

* **Know the campaign type** — set `aerial_du_fft_size` from the measurement
  modality:
  * **Out-of-band single-tone** — use `aerial_du_fft_size: 1` for one frequency
    sample per ray.
  * **In-band RSRP** — use `aerial_du_fft_size: 512` so power is assessed over
    the 20 lowest PRBs from the narrowband CFR.
* **Carrier frequency** — set `aerial_gnb_carrier_freq` on each RU to the center
  frequency used when the power CSVs were collected.

#### Limitations

Frequency-setting constraints are consolidated under
[Calibration limitations → Simulation before calibration → General](/limitations#general).

### 2.5 Unsupported or ignored settings

Some simulation features are useful in other AODT workflows but should not be
treated as calibration inputs.

Unsupported / ignored-setting constraints are consolidated under
[Calibration limitations → Simulation before calibration → General](/limitations#general).

## 3. Calibration

[Simulation setup](#2-simulation-setup) produced the first required input for the
following calibration run: the **aligned AODT
[simulation](#2-simulation-setup)** with its traced rays. Calibration also
requires the **per-link power measurements** from the real campaign
([§3.2 Power measurements](#32-power-measurements)).

This section is the run that combines the imported rays from the previous
simulation with those measurements and solves the inverse problem — holding the
observed power fixed and recovering the materials that best explain it (see the
inverse arrow in [Introduction](#1-introduction)).

A run is driven by a single calibration config with two halves: a `db:` block
that imports the prior simulation ([3.1](#31-importing-from-the-previous-simulation))
and a `cal:` block that defines the job ([3.4](#34-defining-the-calibration-run)),
which is then optimized during [training](#35-training).

### 3.1 Importing from the previous simulation

Calibration does **not** re-trace the scene. The geometry is fixed and only the
materials are unknown, so the rays exported by the aligned AODT
[simulation](#2-simulation-setup) are read back as the geometric backbone of the
forward model. The `db:`
block points the run at that simulation's database:

```yaml
db:
  sim_id: my_aligned_campaign   # the simulation to import
  opt_in_tables:
  - cfrs          # Optional
  - raypaths      # Required
  - cirs          # Optional
  opt_in_tables_options:
    raypaths: first              # first-antenna only per-path geometry, not just summaries
```

* `sim_id` selects the prior simulation to import — **this is the import**.
* `opt_in_tables` pulls the traced quantities calibration consumes: the
  per-path `raypaths` (with `raypaths: first`), and the `cfrs` / `cirs` derived
  from them.

There is only **one traced realization per path** for each RU–UE link, paired
with that link's measurements in [3.4](#34-defining-the-calibration-run).

**The imported simulation must be the aligned one.** Calibration recovers
materials *for the exact geometry it imports*. If the rays come from a scene that
does not match the real campaign (map, georeferencing, RU/UE positions,
frequency), the recovered materials are meaningless (see
[Introduction](#1-introduction)).

### 3.2 Power measurements

Accurate signal or per-path knowledge is impractical to collect at larger scale:
it usually requires experts and specialized equipment, and does not align with the
in-band, widely available, simple measurements most deployments can actually
capture. For this reason, the choice uses **power** as the reference quantity for
calibration.

Each measurement is a simple CSV, one file per RU–UE link and one PCI per file.
Do not place multiple `Power_PCI_<n>` columns in the same measurement file. The
columns matter more than the row count:

* `time` — sample (time-step) index along the campaign
* `Power_PCI_<n>` — measured power level (RSRP, dBm) for the Physical Cell Identity (PCI) `n`
* `beam_idx_PCI_<n>` — **optional** index of the active beam for PCI `n` at that sample; omit it when the campaign has no beams

```csv
time, Power_PCI_1, beam_idx_PCI_1   # beam_idx_PCI_1 is OPTIONAL
0,   -80.106,      1
1,   -78.568,      3
```

Measurements are attached to the calibration config **per link** — one CSV per
RU–UE pair. That wiring is covered in
[§3.4 Measurements — per-link inputs](#measurements--per-link-inputs) under the calibration run.

Checks before calibration:

* Make sure all measurement files match the same file format (same columns and layout).
* Keep exactly one `Power_PCI_<n>` column, and optionally its matching
  `beam_idx_PCI_<n>` column, per file.
* In the absence of beams, calibration cannot estimate any beam.
* Every `nan` or invalid power level  is skipped.

Power-measurement constraints are consolidated under
[Calibration limitations → Calibration process → Power measurements and training](/limitations#power-measurements-and-training).

### 3.3 Calibration targets

`cal.targets` is a set of per-quantity switches. Only the targets set to `true`
are **trained** and become the unknowns that receive gradient updates during
[training](#35-training); everything left `false` is held fixed at the values
imported from the simulation database. Each enabled target maps to an artifact in
[§4 Outputs](#4-outputs).

```yaml
cal:
  targets:
    Materials:    true       # building / surface materials
    VegMaterials: true       # vegetation materials
    UEs:          true       # UE orientation (tilt, azimuth, panel roll)
    RUs:          true       # radio-unit set-up
    RUsBeams:     true       # RU beam codebook
```

Each target below includes a **trained / locked** summary, then detailed terms
and limitations. This template shows every target turned on; disable any target
that should stay fixed for a specific run.

#### Materials

Recovers the electromagnetic properties of buildings and surfaces.

Starting from the scene's material database, the optimizer refines each surface's properties through differentiable EM field computation (the ray
geometry stays fixed; only the field interactions are differentiated), then writes
them to [`materials_calibrated.json`](#calibrated-building-material-outputs). Enable it when
matching the reflective environment is the priority.

<ul>
  <li>
    <code>a</code>
  </li>

  <li>
    <code>c</code>
  </li>

  <li>
    <code>thickness</code>
  </li>

  <li>
    <code>S</code>
  </li>

  <li>
    <code>k_x</code>
  </li>
</ul>

<ul>
  <li>
    <code>b</code>

     (frequency exponent)
  </li>

  <li>
    <code>d</code>

     (frequency exponent)
  </li>

  <li>
    $\alpha_r$
  </li>

  <li>
    $\alpha_i$
  </li>

  <li>
    $\lambda_r$
  </li>

  <li>
    $\delta$
  </li>
</ul>

The detailed role of each term is:

**Trained terms:**

* **a** (`relative permittivity coefficient`) — sets the carrier-frequency value
  of $\varepsilon$ for [reflection and transmission](/intro-em-physics#321-fresnel-reflection-and-transmission):

  $$
  \varepsilon_r' = a\,f_\text{GHz}^{\,b}.
  $$

* **c** (`conductivity coefficient`) — sets the carrier-frequency value of $\sigma$, the
  lossy part of the complex permittivity:

  $$
  \sigma = c\,f_\text{GHz}^{\,d}\ \ \text{(S/m)}.
  $$

* **slab thickness** (`thickness`) — wall thickness used by the finite-wall
  [slab reflection / transmission](/intro-em-physics#321-fresnel-reflection-and-transmission) model.

* **S** (`scattering coefficient`) — fraction of energy redirected into
  [surface diffuse scattering](/intro-em-physics#323-surface-diffuse-scattering).

* **k\_x** (`cross-polarization ratio`) — how much scattered power couples into
  the orthogonal polarization.

**Not trained terms:**

* **b** (`permittivity frequency exponent`) — the exponent in the $\varepsilon$ equation.
* **d** (`conductivity frequency exponent`) — the exponent in the $\sigma$ equation;
  this is different from slab thickness.
* **$\alpha_r$** (`forward-lobe width exponent`) — how sharply scattered power
  concentrates around the specular direction.
* **$\alpha_i$** (`back-lobe width exponent`) — width of the backward, back-scatter lobe.
* **$\lambda_r$** (`forward/back lobe weight`) — balances the forward and backward lobes.
* **$\delta$** (`surface roughness`) — roughness stays at its database value because the
  diffuse term is adjusted through the scattering coefficient.

Calibration is narrowband at the carrier frequency, so it adjusts the
carrier-frequency material coefficients rather than learning the full frequency
law for each material.

Material-recovery constraints are consolidated under
[Calibration limitations → Calibration process → Building / surface material recovery](/limitations#building-surface-material-recovery).

#### Vegetation Materials

Recovers the propagation properties of vegetation, kept separate from hard
surfaces so that trees never distort the building materials.

Calibration defines
one new vegetation material per tree, and the current interaction model uses the
trunk contribution only; foliage is not part of propagation for now. Enable it
for campaigns with significant vegetation — the scene must actually contain trees
and rays must hit them, otherwise this target is skipped.

<ul>
  <li>
    <code>A</code>
  </li>

  <li>
    <code>B</code>
  </li>

  <li>
    <code>C</code>
  </li>

  <li>
    <code>E</code>
  </li>

  <li>
    <code>G</code>
  </li>

  <li>
    <code>S_v</code>
  </li>

  <li>
    <code>k_x</code>
  </li>
</ul>

<ul>
  <li>
    Vegetation placement (

    <code>gis.vegetation</code>

    )
  </li>

  <li>
    Tree asset geometry
  </li>

  <li>
    Trunk interaction model (trunk contribution only)
  </li>
</ul>

**Calibrated properties.** Each trained term drives the current trunk-only
vegetation attenuation or the isotropic diffuse-scattering model described in the
[EM physics primer](/intro-em-physics#324-vegetation-diffuse-scattering):

For the attenuation part, the
[EM physics primer](/intro-em-physics#324-vegetation-diffuse-scattering) describes
the ITU-R P.833-10 vegetation loss term:

$$
L_{\text{veg}}(\mathrm{dB}) = A f^B d^C(\theta + E)^G
$$

where `f` is the frequency in MHz, `d` is the vegetation depth in meters, and
$\theta$ is the elevation angle in degrees. The fitted vegetation terms are:

* **A** (`ITU-R P.833-10 A`) — main vegetation-loss coefficient controlling the vegetation attenuation scale.
* **B** (`ITU-R P.833-10 B`) — frequency exponent paired with `A`.
* **C** (`ITU-R P.833-10 C`) — distance / depth exponent for vegetation penetration.
* **E** (`ITU-R P.833-10 E`) — secondary coefficient for attenuation shaping.
* **G** (`ITU-R P.833-10 G`) — secondary exponent paired with `E`.
* **S\_v** (`scattering_power_factor`) — scale for how much vegetation-interaction power is redirected into diffuse scattering.
* **k\_x** (`k_xpol_veg`) — cross-/co-polarized power ratio for vegetation scattering.

Vegetation-material-recovery constraints are consolidated under
[Calibration limitations → Calibration process → Vegetation material recovery](/limitations#vegetation-material-recovery).

#### UE calibration target

Recovers the **orientation** of each UE along its trajectory — its **tilt**, its
**azimuth**, and the panel **roll**.

The UE position is **not** changed — only the
device pointing at each waypoint. The learned corrections sit on top of the
GPX-derived orientation so the predicted power matches the measurements, while a
temporal-smoothness penalty keeps the tilt and azimuth trajectories physically
plausible. Enable it when the orientation in the GPX traces is uncertain; the
final learned offsets are written back into the per-UE GPX files (see
[§2.1 UE setup](#21-ues) and [§4 UE GPX output](#ue-gpx-example)).

<ul>
  <li>
    $\theta_{\mathrm{mech}}$

     (trainable route-level average, 0° to 75°)
  </li>

  <li>
    $d\theta(t)$

     (-30° to +30°)
  </li>

  <li>
    $d\phi(t)$

     (-45° to +45°)
  </li>

  <li>
    $\alpha$

     (panel roll)
  </li>
</ul>

<ul>
  <li>
    GPX position (lat, lon, ele)
  </li>

  <li>
    $\phi_{\mathrm{trajectory}}(t)$

     (trajectory-derived azimuth)
  </li>

  <li>
    <code>antenna_names</code>

     (element pattern)
  </li>

  <li>
    UE beamforming
  </li>

  <li>
    Panel array size (

    <code>1 × 1</code>

     required)
  </li>
</ul>

* **Tilt** — the recovered tilt is:

  $$
  \theta(t) = \theta_\text{mech} + d\theta(t)
  $$

  $\theta_{\mathrm{mech}}$ is initialized from the static mechanical tilt in the config and is
  trainable as the route-level average, while $d\theta(t)$ is the learned
  per-time-index offset.
* **Azimuth** — the recovered azimuth is:

  $$
  \phi(t) = \phi_\text{trajectory}(t) + d\phi(t)
  $$

  $\phi_{\mathrm{trajectory}}(t)$ comes from the trajectory orientation (direction of travel)
  and is not learned, while $d\phi(t)$ is the learned per-time-index offset. The GPX
  output stores the final learned offsets, not a replacement trajectory.
* **Roll** — $\alpha$ is a single scalar on the linked panel, not a per-time-index
  quantity; learning it emits a new panel for the UE.

UE-orientation-recovery constraints are consolidated under
[Calibration limitations → Calibration process → UE orientation](/limitations#ue-orientation).

#### RU calibration target

Recovers the rigid mechanical orientation of each Radio Unit panel
($\theta$, $\phi$, $\alpha$) as a
**bounded correction on top of the orientation supplied in the config** — it
does not discover the orientation from scratch. Because RU antennas are highly
directional, even a few degrees of mounting error shifts the whole power
footprint, so this correction is applied before the UE angles and beams. Enable it
when RU mounting or aiming is uncertain, and keep the seed orientation as accurate
as possible: if the true angle falls outside the correction window, calibration
cannot reach it and everything downstream silently compensates.

<p>
  Bounded ±30° correction on seeded values:
</p>

<ul>
  <li>
    $\theta$

     — 

    <code>aerial_gnb_mech_tilt</code>
  </li>

  <li>
    $\phi$

     — 

    <code>aerial_gnb_mech_azimuth</code>
  </li>

  <li>
    $\alpha$

     — panel roll on the linked panel
  </li>
</ul>

<ul>
  <li>
    <code>position.pos</code>

     (3D location)
  </li>

  <li>
    <code>antenna_names</code>

     (element pattern)
  </li>
</ul>

The recovered RU terms are applied as follows:

* $\theta$ and $\phi$ are corrections to the values already placed in the simulation.
* $\alpha$ is handled separately: changing it defines a new panel for that specific RU.

RU-orientation-recovery constraints are consolidated under
[Calibration limitations → Calibration process → RU orientation](/limitations#ru-orientation).

#### RU beams calibration target

Recovers the RU beam codebook, not the rigid panel orientation. `RUs` estimates
the physical mounting angles of the panel; `RUsBeams` estimates the phases and
weights that produce beamforming on that panel. The optimizer learns a bank of
candidate beam solutions and assigns each physical beam index to one of them,
fitting the beam shapes against the measured per-beam power. Enable it to
calibrate beamforming; this requires the measurements to carry the active
`beam_idx` per sample (see [§3.2 Power measurements](#32-power-measurements)).

<ul>
  <li>
    Per-beam steering directions (

    $\theta$

     / 

    $\phi$

    )
  </li>

  <li>
    Per-beam weights
  </li>
</ul>

<ul>
  <li>
    <code>position.pos</code>

     (3D location)
  </li>

  <li>
    <code>antenna_names</code>

     (element pattern)
  </li>

  <li>
    Beams with no 

    <code>beam_idx</code>

     in the 

    <a href="#32-power-measurements">§3.2 measurements</a>

     — an unobserved beam carries no loss signal, so it stays at its codebook default
  </li>
</ul>

**Sizing the calibration array.** Beams live in the **calibration** stage, not the
`1 × 1` feeding simulation: the codebook is recovered for the **multi-element
array defined in the calibration config**, so that array must be large enough
to represent the intended beams. A `1 × 1` calibration panel has **no array
factor and therefore produces no beams at all** — a multi-element array is
mandatory to recover any codebook. Above that floor, with `L` antenna elements the
array can resolve up to `L` orthogonal spatial frequencies (3 elements → 3
orthogonal directions); under-sizing the array — e.g. a `2 × 1` panel for 5
distinct beams — is very unlikely to produce a meaningful codebook. This choice is
**crucial for correctness**.

Beam-calibration constraints are consolidated under
[Calibration limitations → Calibration process → RU beams](/limitations#ru-beams).

### 3.4 Defining the calibration run

The `cal:` block ties the run together — the [targets](#33-calibration-targets) to
solve for, *over which samples* ([timeline](#timeline--time-step-selection)),
*against which measurements* ([measurements](#measurements--per-link-inputs)), and
*where results are written* (`cal.output.folder_key`).

`cal.output.folder_key` sets the root directory all artifacts are written under
(see [§4 Outputs](#4-outputs)). This folder lives in the **same S3 bucket** where the
original [simulation](#2-simulation-setup) saved its results:

```yaml
cal:
  output:
    folder_key: path/to/calibrated_campaign/
```

#### Timeline — time-step selection

`cal.timeline` chooses which campaign samples are used, as a range of **time-step
indices** over the campaign axis:

* `start` — first index used (inclusive)
* `step` — stride between indices (`1` uses every sample)
* `end` — last index used (inclusive)

The GPX trajectory points, the power measurement rows, and the time-step indices
line up **one-to-one**: each column below is a single campaign realization, and
the timeline range simply selects which of those aligned triples are used. Drag
the sliders — the range bar and the `cal.timeline` YAML update together (shown on
a short illustrative campaign; real campaigns can have hundreds of samples):

Each index corresponds to one campaign realization — one GPX sample and its
matching measurement row (see [§2.1 UEs](#21-ues)). Because the
rows are paired by index, their counts must match: if the power rows and GPX
points disagree, calibration stops (see the one-to-one rule in
[§2.1 UEs](#21-ues)) — toggle **simulate misaligned counts** above
to see that case. Use the range to restrict calibration to a stable portion of
the trajectory or to subsample a long campaign for faster runs.

#### Measurements — per-link inputs

`cal.measurements` attaches the real field data **per link** — one entry per
RU–UE pair, each pointing at that link's CSV (the file format is described in
[§3.2 Power measurements](#32-power-measurements)). There is only **one traced
realization per path** for a link, and each link's measurements are paired with
the rays imported in [3.1](#31-importing-from-the-previous-simulation):

```yaml
cal:
  measurements:
  - ru_id: 1
    ue_id: 1
    measurement_file: path/to/ru1_ue1_measurements.csv
  - ru_id: 2
    ue_id: 1
    measurement_file: path/to/ru2_ue1_measurements.csv
```

* `ru_id` / `ue_id` — identify the RU–UE link the file belongs to
* `measurement_file` — path to that link's measurement CSV

All measurement files in a run must share the same column layout; `nan` or
invalid power levels are skipped.

### 3.5 Training

With the rays imported and the job defined, training runs the inverse arrow as a
gradient-based loop:

1. The **forward model** propagates the imported rays through the EM engine using
   the current material estimates and predicts the power at each link.
2. A **loss** compares those predictions against the measured power (plus
   physics-informed regularization, e.g. the ITU-R P.2040 constraint that keeps
   recovered materials physically plausible).
3. **Backpropagation** updates the active unknowns (the enabled `cal.targets`);
   fixed quantities are left at their database values.

The loop repeats for a hardcoded number of epochs while the predictions are
optimized to match the measurements. On completion the recovered quantities and the run's checkpoints
are written under `cal.output.folder_key` — see [§4 Outputs](#4-outputs).

#### Understanding the loss

Training searches for the calibrated values that make the **predicted power**
match the **measured power** as closely as possible, while staying physically
sensible:

$$
\min_{\theta}\;\; L\big(P_{\text{measured}},\, \hat{P}(\theta)\big) \;+\; \lambda \, R(\theta)
$$

* $\theta$ — the quantities being calibrated (the enabled `cal.targets`).
* $\hat{P}(\theta)$ — predicted power from the EM engine for the current estimate.
* $P_{\text{measured}}$ — the real campaign power.
* $L(P, \hat{P})$ — a generic discrepancy between measured and predicted power
  (for example, mean squared error).
* Power is compared **centered** (average level removed), so training matches the
  *shape* of the power along the route, not an absolute level.
* $\lambda R(\theta)$ — regularization that keeps the solution realistic.

The quantity being minimized is the **loss**: a lower loss means the calibrated
scene explains the measured campaign better. It is the training score, not a
guarantee — a low loss on the selected samples does not by itself prove the
result will generalize (see the disclaimer in [Introduction](#1-introduction)).

![Calibration comparison curve](https://files.buildwithfern.com/aerial.docs.buildwithfern.com/aerial/aodt/4572ea4eff50acb46e5c2b1929253bb422054355de6e91f7e5f040822394c591/docs/assets/intro_cal/calibration_comparison_curve.png)

**What the regularization $R(\theta)$ encodes**

* **Material priors** — stay close to known, physically plausible material values.
* **Codebook characteristics** — keep recovered beams well-formed.
* **Temporal smoothness** — nearby time steps should have similar azimuth and tilt.
* **L2 penalty** — keep corrections small to avoid basic overfitting.

#### Limitations

Training constraints and open questions are consolidated under
[Calibration limitations → Calibration process → Power measurements and training](/limitations#power-measurements-and-training).

## 4. Outputs

A calibration run writes all of its artifacts under a single root directory,
named by `cal.output.folder_key` in the calibration config. Which artifacts are
produced is controlled by the per-quantity switches under `cal.targets`
(see [Defining the calibration run](#34-defining-the-calibration-run)): only the
targets set to `true` are calibrated and written. The sections below describe the
artifact each target produces. Some targets also require supporting ray
interactions; for example, [§4 Vegetation material outputs](#vegetation-material-outputs)
are turned off automatically if the imported rays contain no vegetation hits.

### Output folder tree

* [`materials_calibrated.json`](#calibrated-building-materials-json) — calibrated building-material definitions
* [`association.json`](#calibrated-building-materials-association-json) — maps scene objects to those materials

- [`veg_materials_calibrated.json`](#calibrated-vegetation-materials-json) — calibrated vegetation-material definitions
- [`association.json`](#calibrated-vegetation-materials-association-json) — maps scene objects to those vegetation materials

One [§4 UE GPX output `.gpx`](#ue-gpx-example) trajectory per calibrated UE.

A single calibrated beam codebook (e.g. [§4 RU beams output `RU_1_codebook.csv`](#ru-codebook-csv)).

* [`sim_config_calibrated.yml`](#calibrated-simulation-configuration) — §4 Calibrated simulation configuration used for follow-up runs; RU orientation outputs from `cal.targets.RUs` are written here

### Calibrated building-material outputs

The primary calibration product: the recovered electromagnetic properties of the
scene's buildings and surfaces. Enabled by `cal.targets.Materials` and written to
`Calibrated/Assets/Materials/`:

These two files feed the follow-up run through the `sim.Materials.calibration`
block of the [§4 Calibrated simulation configuration](#calibrated-simulation-configuration)
(`definition` ← `materials_calibrated.json`, `assignment` ← `association.json`):

```yaml
sim:
  Materials:
    calibration:
      definition:
      - path/to/calibrated_campaign/Calibrated/Assets/Materials/materials_calibrated.json
      assignment:
      - path/to/calibrated_campaign/Calibrated/Assets/Materials/association.json
```

Calibrated material definitions.

```json
{
    "calibrated_material_1": {
        "id": {"value": 101},
        "a": {"value": 1.0},
        "b": {"value": 0.0},
        "c": {"value": 10000000.0},
        "d": {"value": 0.0},
        "roughness_rms": {"value": 0.013},
        "scattering_coeff": {"value": 0.99},
        "thickness": {"value": 0.01},
        "k_xpol": {"value": 0.05},
        "lambda_r": {"value": 1.0},
        "exponent_alpha_i": {"value": 10},
        "exponent_alpha_r": {"value": 10}
    },
    "calibrated_material_2": {
        "id": {"value": 102},
        "a": {"value": 1.0},
        "b": {"value": 0.0},
        "c": {"value": 10002.0},
        "d": {"value": 0.0},
        "roughness_rms": {"value": 0.013},
        "scattering_coeff": {"value": 0.99},
        "thickness": {"value": 0.01},
        "k_xpol": {"value": 0.05},
        "lambda_r": {"value": 1.0},
        "exponent_alpha_i": {"value": 10},
        "exponent_alpha_r": {"value": 10}
    }
}
```

Mapping from scene objects to calibrated material names.

```json
{
    "11037382648522604548": "calibrated_material_1",
    "11037382648522604566": "calibrated_material_2"
}
```

### Vegetation material outputs

The vegetation counterpart of the building materials, calibrated separately so
that trunk attenuation and scattering are not absorbed into the hard-surface
materials. Enabled by `cal.targets.VegMaterials` and written to
`Calibrated/Assets/Vegetation/` (`veg_materials_calibrated.json`,
`association.json`).

These feed the follow-up run through the `sim.VegetationMaterials.calibration`
block of the [§4 Calibrated simulation configuration](#calibrated-simulation-configuration):

```yaml
sim:
  VegetationMaterials:
    calibration:
      definition:
      - path/to/calibrated_campaign/Calibrated/Assets/Vegetation/veg_materials_calibrated.json
      assignment:
      - path/to/calibrated_campaign/Calibrated/Assets/Vegetation/association.json
```

Calibrated vegetation-material definitions.

```json
{
    "calibrated_tree_material_1": {
        "id": {"value": 0},
        "A": {"value": 0.24},
        "B": {"value": 0.1},
        "C": {"value": 0.22},
        "E": {"value": 0.12},
        "G": {"value": 0.08},
        "k_xpol": {"value": 0.1},
        "scattering_power_factor": {"value": 0.75}
    },
    "calibrated_tree_material_2": {
        "id": {"value": 1},
        "A": {"value": 0.26},
        "B": {"value": 0.11},
        "C": {"value": 0.24},
        "E": {"value": 0.14},
        "G": {"value": 0.09},
        "k_xpol": {"value": 0.11},
        "scattering_power_factor": {"value": 0.77}
    }
}
```

Mapping from vegetation objects to vegetation material names.

```json
{
    "14272982631376748572": "calibrated_tree_material_1",
    "14272982643040845852": "calibrated_tree_material_2",
    "14272982643031015452": "calibrated_tree_material_3",
    "14272982643038945308": "calibrated_tree_material_4"
}
```

### Calibrated RU outputs

Calibrated radio-unit set-up recovered during the run, enabled by
`cal.targets.RUs`. There is no separate `Calibrated/RUs/` folder in this flow:
the recovered rigid RU panel angles are written into
[§4 Calibrated simulation configuration](#calibrated-simulation-configuration).

When `cal.targets.RUs: true`, calibration creates **one new panel per calibrated
RU** and reassigns that RU to the new panel id through `aerial_gnb_panel_type`.
The recovered $\theta$ / $\phi$ orientation feeds the follow-up run through `sim.RUs.update`;
the calibrated panel roll $\alpha$ is carried by the newly assigned panel:

```yaml
sim:
  RUs:
    update:
    - ids: [1]
      attributes:
        aerial_gnb_panel_type: 4  # reassigned to the calibrated panel for RU 1
        aerial_gnb_mech_azimuth: 0.03139941348107338
        aerial_gnb_mech_tilt: 39.21267268546056
    - ids: [2]
      attributes:
        aerial_gnb_panel_type: 5  # reassigned to the calibrated panel for RU 2
        aerial_gnb_mech_azimuth: 195.214
        aerial_gnb_mech_tilt: 5.128
  Panels:
    update:
    - ids: [4]
      attributes:
        antenna_names: [threeGPP_38901]
        num_loc_antenna_horz: 1
        num_loc_antenna_vert: 1
        antenna_roll_angle_first_polz_degree: -22.731
        antenna_roll_angle_second_polz_degree: 67.269
    - ids: [5]
      attributes:
        antenna_names: [threeGPP_38901]
        num_loc_antenna_horz: 1
        num_loc_antenna_vert: 1
        antenna_roll_angle_first_polz_degree: -18.442
        antenna_roll_angle_second_polz_degree: 71.558
```

### Calibrated UE outputs

The calibrated UE **orientation**. Enabled by `cal.targets.UEs` and written to
`Calibrated/UEs_gpx/` as one `.gpx` file per calibrated UE — the GPX **positions
are unchanged**; what is recovered is the per-sample pointing offset.

How the recovered angles are composed:

* **Tilt** output $\theta(t) = \theta_{\mathrm{mech}} + d\theta(t)$ — $\theta_{\mathrm{mech}}$ starts from the mechanical
  tilt `aerial_ue_mech_tilt` in the config and is trainable as the static,
  per-route average; the final $d\theta(t)$ offset is stored in the `.gpx`.
* **Azimuth** output $\phi(t) = d\phi(t)$ + trajectory-derived $\phi$ — there is no
  mechanical azimuth term; the final $d\phi(t)$ offset is stored in the `.gpx` and
  the base $\phi$ comes from the trajectory orientation.
* **Roll** output $\alpha$ — emitted as a **new panel**: every time UE orientation is
  learned, calibration writes one new panel definition per calibrated UE and
  reassigns that UE to the new panel id through `aerial_ue_panel_type`.

The result feeds the follow-up run through the `sim.UEs` block of the
[§4 Calibrated simulation configuration](#calibrated-simulation-configuration). The `update`
attributes below are a **template** carried from setup — not the training result;
the trained $\theta_{\mathrm{mech}}$ and final per-sample offsets $d\theta(t)$ /
$d\phi(t)$ live in the calibrated `.gpx` that `gpx.src` points to. The panel id is
the new per-UE calibrated panel assignment:

```yaml
sim:
  UEs:
    update:
    - ids: [1]
      attributes:
        aerial_ue_panel_type: 3  # reassigned to the calibrated panel for UE 1
        aerial_ue_initial_mech_azimuth: 0.0
        aerial_ue_mech_tilt: 39.9414128887659
    add:
    - id: 1
      gpx:
        src: path/to/calibrated_campaign/Calibrated/UEs_gpx/ue_1_trajectory.gpx
        use_pathfinding: false
  Panels:
    update:
    - ids: [3]
      attributes:
        antenna_names: [halfwave_dipole]
        num_loc_antenna_horz: 1
        num_loc_antenna_vert: 1
        antenna_roll_angle_first_polz_degree: 1.25
        antenna_roll_angle_second_polz_degree: 91.25
```

The `ue_tilt` and `ue_azimuth` entries below store the final learned offsets.

```xml
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1">
  <metadata>
    <name>UE 1 Trajectory</name>
  </metadata>
  <trk>
    <name>UE_1_Route</name>
    <trkseg>
      <trkpt lat="60.16974701002502" lon="24.93724776999778">
        <ele>9.702031033142703</ele>
        <extensions>
          <angles>
            <ue_tilt>2.146512</ue_tilt>
            <ue_azimuth>-3.872104</ue_azimuth>
          </angles>
        </extensions>
      </trkpt>
      <trkpt lat="60.16973804512210" lon="24.93722901324517">
        <ele>9.681442001284503</ele>
        <extensions>
          <angles>
            <ue_tilt>1.984233</ue_tilt>
            <ue_azimuth>-4.105872</ue_azimuth>
          </angles>
        </extensions>
      </trkpt>
    </trkseg>
  </trk>
</gpx>
```

### Calibrated RU beam outputs

The calibrated beam codebook for the radio units. Enabled by
`cal.targets.RUsBeams` and written to `Calibrated/Codebooks/` as a single
codebook (e.g. `RU_1_codebook.csv`). This output is produced whenever
`cal.targets.RUsBeams: true`, alongside any other enabled targets.

This codebook exists only because calibration ran on the **multi-element** array
defined in the calibration config — not on the `1 × 1` simulation panel that
exported the ray paths. The single-element simulation panel keeps the traced
geometry at the panel phase center; the multi-element calibration array supplies
the array factor that the beams are recovered from (see [§2.2 RUs](#22-rus) and
[§3.3 RUsBeams](#ru-beams-calibration-target)).

```csv
HorizontalElements 4
VerticalElements 4
DualPolarized 0
HorizontalSpacing 0.5
VerticalSpacing 0.5
Frequency_Hz 3619000000.0
Beam_idx, Tilt_deg, Azimuth_deg, Ant_re_0, Ant_im_0, Ant_re_1, Ant_im_1, Ant_re_2, Ant_im_2, Ant_re_3, Ant_im_3, Ant_re_4, Ant_im_4, Ant_re_5, Ant_im_5, Ant_re_6, Ant_im_6, Ant_re_7, Ant_im_7, Ant_re_8, Ant_im_8, Ant_re_9, Ant_im_9, Ant_re_10, Ant_im_10, Ant_re_11, Ant_im_11, Ant_re_12, Ant_im_12, Ant_re_13, Ant_im_13, Ant_re_14, Ant_im_14, Ant_re_15, Ant_im_15
0, 100.000000, 0.000000, 0.683465, -0.729984, 0.963029, -0.269396, 0.963029, 0.269396, 0.683465, 0.729984, 0.683465, -0.729984, 0.963029, -0.269396, 0.963029, 0.269396, 0.683465, 0.729984, 0.683465, -0.729984, 0.963029, -0.269396, 0.963029, 0.269396, 0.683465, 0.729984, 0.683465, -0.729984, 0.963029, -0.269396, 0.963029, 0.269396, 0.683465, 0.729984
1, 106.000000, 0.000000, 0.268548, -0.963266, 0.907724, -0.419569, 0.907724, 0.419569, 0.268548, 0.963266, 0.268548, -0.963266, 0.907724, -0.419569, 0.907724, 0.419569, 0.268548, 0.963266, 0.268548, -0.963266, 0.907724, -0.419569, 0.907724, 0.419569, 0.268548, 0.963266, 0.268548, -0.963266, 0.907724, -0.419569, 0.907724, 0.419569, 0.268548, 0.963266
2, 112.000000, 0.000000, -0.193272, -0.981145, 0.831813, -0.555056, 0.831813, 0.555056, -0.193272, 0.981145, -0.193272, -0.981145, 0.831813, -0.555056, 0.831813, 0.555056, -0.193272, 0.981145, -0.193272, -0.981145, 0.831813, -0.555056, 0.831813, 0.555056, -0.193272, 0.981145, -0.193272, -0.981145, 0.831813, -0.555056, 0.831813, 0.555056, -0.193272, 0.981145
3, 118.000000, 0.000000, -0.598427, -0.801177, 0.740189, -0.672398, 0.740189, 0.672398, -0.598427, 0.801177, -0.598427, -0.801177, 0.740189, -0.672398, 0.740189, 0.672398, -0.598427, 0.801177, -0.598427, -0.801177, 0.740189, -0.672398, 0.740189, 0.672398, -0.598427, 0.801177, -0.598427, -0.801177, 0.740189, -0.672398, 0.740189, 0.672398, -0.598427, 0.801177
4, 124.000000, 0.000000, -0.874468, -0.485083, 0.638400, -0.769705, 0.638400, 0.769705, -0.874468, 0.485083, -0.874468, -0.485083, 0.638400, -0.769705, 0.638400, 0.769705, -0.874468, 0.485083, -0.874468, -0.485083, 0.638400, -0.769705, 0.638400, 0.769705, -0.874468, 0.485083, -0.874468, -0.485083, 0.638400, -0.769705, 0.638400, 0.769705, -0.874468, 0.485083
```

### Calibrated simulation configuration

The root output folder also includes `sim_config_calibrated.yml`. This is the
**§4 Calibrated simulation configuration**: the post-calibration simulation
configuration that points the follow-up run at the recovered materials,
vegetation materials, UE trajectory, RU orientation, and antenna panel settings.

Panel definitions are preserved first, then calibration appends one emitted panel
per calibrated UE and one emitted panel per calibrated RU. In the simple bundle
above, the input config starts with panels `1` and `2`; with one calibrated UE
and two calibrated RUs, the final panel ids are ordered as:

```yaml
sim:
  Panels:
    update:
    - ids: [1]  # original panel
    - ids: [2]  # original panel
    - ids: [3]  # calibrated UE panel 1
    - ids: [4]  # calibrated RU panel 1
    - ids: [5]  # calibrated RU panel 2
```

The corresponding `sim.UEs.update` and `sim.RUs.update` entries point each object
to its emitted panel through `aerial_ue_panel_type` and `aerial_gnb_panel_type`.

Example focused `sim_config_calibrated.yml` excerpt, gathering the per-section
pieces shown above into the single configuration the follow-up run consumes,
using the simple bundle's one-UE / two-RU shape:

```yaml
db:
  sim_id: calibrated_campaign
  db_host: clickhouse
  db_port: 9000
  db_author: aerial
  db_notes: ''
  opt_in_tables:
  - cfrs
  - raypaths
  - cirs
  opt_in_tables_options:
    raypaths: first
  parquet_export:
    max_workers: 2
    compression: zstd
    timesteps_per_file: 100
    verify_exports: true
sim:
  RUs:
    update:
    - ids: [1]
      attributes:
        aerial_gnb_panel_type: 4
        aerial_gnb_mech_azimuth: 0.03139941348107338
        aerial_gnb_mech_tilt: 39.21267268546056
    - ids: [2]
      attributes:
        aerial_gnb_panel_type: 5
        aerial_gnb_mech_azimuth: 195.214
        aerial_gnb_mech_tilt: 5.128
  UEs:
    update:
    - ids: [1]
      attributes:
        aerial_ue_panel_type: 3
        aerial_ue_initial_mech_azimuth: 0.0
        aerial_ue_mech_tilt: 39.9414128887659
    add:
    - id: 1
      gpx:
        src: path/to/calibrated_campaign/Calibrated/UEs_gpx/ue_1_trajectory.gpx
        use_pathfinding: false
  Panels:
    update:
    - ids: [1]
      attributes:
        antenna_names: [halfwave_dipole]
        dual_polarized: false
        num_loc_antenna_horz: 1
        num_loc_antenna_vert: 1
    - ids: [2]
      attributes:
        antenna_names: [threeGPP_38901]
        dual_polarized: false
        reference_freq_mhz: 3619.0
        num_loc_antenna_horz: 1
        num_loc_antenna_vert: 1
    - ids: [3]
      attributes:
        antenna_names: [halfwave_dipole]
        dual_polarized: false
        num_loc_antenna_horz: 1
        num_loc_antenna_vert: 1
        antenna_roll_angle_first_polz_degree: 1.25
        antenna_roll_angle_second_polz_degree: 91.25
    - ids: [4]
      attributes:
        antenna_names: [threeGPP_38901]
        dual_polarized: false
        reference_freq_mhz: 3619.0
        num_loc_antenna_horz: 1
        num_loc_antenna_vert: 1
        antenna_roll_angle_first_polz_degree: -22.731
        antenna_roll_angle_second_polz_degree: 67.269
    - ids: [5]
      attributes:
        antenna_names: [threeGPP_38901]
        dual_polarized: false
        reference_freq_mhz: 3619.0
        num_loc_antenna_horz: 1
        num_loc_antenna_vert: 1
        antenna_roll_angle_first_polz_degree: -18.442
        antenna_roll_angle_second_polz_degree: 71.558
  Materials:
    calibration:
      definition:
      - path/to/calibrated_campaign/Calibrated/Assets/Materials/materials_calibrated.json
      assignment:
      - path/to/calibrated_campaign/Calibrated/Assets/Materials/association.json
  VegetationMaterials:
    calibration:
      definition:
      - path/to/calibrated_campaign/Calibrated/Assets/Vegetation/veg_materials_calibrated.json
      assignment:
      - path/to/calibrated_campaign/Calibrated/Assets/Vegetation/association.json
```

## 5. Post-processing

Post-processing is the **next stage after calibration**. It reuses the
[§4 Outputs](#4-outputs) in a new **post-calibration simulation** of the
**same environment**. This simulation is different from the first aligned
simulation in [§2 Simulation setup](#2-simulation-setup): the first simulation
produces the rays used by calibration, while the post-calibration simulation
consumes the calibrated outputs and produces the predicted power used for
comparison.

### 5.1 Reproducing the predicted-vs-measured curve overlap

The calibration comparison curve in [§3.5 Training](#35-training) overlays the
**measured** power against the **predicted** power along the UE route. It is produced in this post-processing
stage by running the **post-calibration simulation** with the calibration outputs
applied, then comparing the result against the original field measurements; a
good calibration makes the two curves overlap.

#### How post-processing works

At a high level the stage takes the calibration outputs and field measurements as
**input**, then runs four steps — fetch the beamforming artifacts, run a
simulation from the
[§4 Calibrated simulation configuration](#calibrated-simulation-configuration),
apply beamforming to the simulation results, and plot the predicted power against
the measurements:

```mermaid
flowchart LR
    I["Input"] --> F["1 · Fetching"]
    F --> S["2 · Run simulation"]
    S --> B["3 · Apply beamforming"]
    B --> P["4 · Plotting"]
```

The rest of this section details the input and each step.

#### Input

Everything the stage starts from lives in the calibration output location
(S3/MinIO):

1. the beam **codebook** `RU_*_codebook.csv` — the per-RU beam weights, present
   only when beam training was enabled (shown below);
2. the **measurement traces** — one RSRP (Reference Signal Received Power) CSV per
   RU–UE link, plus the optional per-link beam CSVs when beams are involved;
3. the **§4 Calibrated simulation configuration** `sim_config_calibrated.yml` —
   points a follow-up simulation at the recovered materials, vegetation, UE
   trajectory, RU orientation, and panels.

Running that follow-up simulation (step 2 below) produces the **channel data**
(Iceberg/Parquet) carrying the CFR (Channel Frequency Response) that beamforming
(step 3) consumes.

The codebook is the same artifact described in [§4 RU beams output](#calibrated-ru-beam-outputs):

```csv
HorizontalElements 4
VerticalElements 4
DualPolarized 0
HorizontalSpacing 0.5
VerticalSpacing 0.5
Frequency_Hz 3619000000.0
Beam_idx, Tilt_deg, Azimuth_deg, Ant_re_0, Ant_im_0, ... , Ant_re_15, Ant_im_15
0, 100.000000, 0.000000, 0.683465, -0.729984, ... , 0.683465, 0.729984
1, 106.000000, 0.000000, 0.268548, -0.963266, ... , 0.268548, 0.963266
2, 112.000000, 0.000000, -0.193272, -0.981145, ... , -0.193272, 0.981145
```

#### Workflow

Each step is a block with its own input and output.

**1 · Fetching** — pull the beamforming artifacts from their store.

```mermaid
flowchart LR
    IN1["<b>Input</b><br />S3/MinIO location"] --> S1["1 · Fetching"]
    S1 --> OUT1["<b>Output</b><br />§4 Calibrated simulation configuration<br />RU_*_codebook.csv<br />measurement CSVs"]
```

After fetching, the output folder is organized like this:

```text
beamforming_outputs/
├── sim_config_calibrated.yml      # §4 Calibrated simulation configuration
├── RU_1_codebook.csv              # beam codebook (only when beams were trained)
├── ru1_ue1_with_beams_filled.csv  # per-link measurement-beam CSVs
└── ru2_ue1_with_beams_filled.csv
```

**2 · Run simulation** — run the **post-calibration simulation** with
[§4 Calibrated simulation configuration](#calibrated-simulation-configuration),
the fetched `sim_config_calibrated.yml` file. This is the simulation immediately
after calibration, not the initial simulation from [§2 Simulation setup](#2-simulation-setup).
Point it at fresh output locations so it does not overwrite the baseline
simulation database. This regenerates the channel data for the calibrated scene
and produces the **predicted** power.

```mermaid
flowchart LR
    IN2["<b>Input</b><br />§4 Calibrated simulation configuration<br />sim_config_calibrated.yml"] --> S2["2 · Run simulation"]
    S2 --> OUT2["<b>Output</b><br />channel data<br />(Iceberg/Parquet, CFR)"]
```

**3 · Apply beamforming** — beamform the channel data from step 2 using the
fetched codebook from step 1. When no codebook exists (`RUsBeams` was not `true`),
a single antenna element is used as a baseline.

```mermaid
flowchart LR
    IN3["<b>Input</b><br />channel data (step 2)<br />RU_*_codebook.csv (step 1, optional)"] --> S3["3 · Apply beamforming"]
    S3 --> OUT3["<b>Output</b><br />beamformed_rsrp.json"]
```

The output `beamformed_rsrp.json` holds the predicted RSRP per RU → UE → beam, as
a time array and the matching power values in dBm:

```json
{
  "RU_1": {
    "UE_1": {
      "BEAM_0": {
        "time_array": [0, 1, 2, 3],
        "rsrp_dbm": [-65.2, -64.8, -66.1, -65.5]
      },
      "BEAM_1": {
        "time_array": [0, 1, 2, 3],
        "rsrp_dbm": [-67.0, -66.4, -67.9, -67.2]
      }
    }
  }
}
```

**4 · Plotting** — overlay the curves; the degree of overlap is the visual check
on the calibration result.

```mermaid
flowchart LR
    IN4["<b>Input</b><br />beamformed_rsrp.json<br />measured RSRP traces"] --> S4["4 · Plotting"]
    S4 --> OUT4["<b>Output</b><br />predicted-vs-measured plot (PNG)"]
```