Skip to content

API reference

Auto-generated from source code. See the user guide for usage examples.

Core

DosimetryEngine

aegis.engine.DosimetryEngine

Compute absorbed power density on a body mesh from propagation paths.

Parameters

tissue : TissueModel Tissue electromagnetic properties at the operating frequency.

Source code in src/aegis/engine.py
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
class DosimetryEngine:
    """Compute absorbed power density on a body mesh from propagation paths.

    Parameters
    ----------
    tissue : TissueModel
        Tissue electromagnetic properties at the operating frequency.
    """

    # Class-level LRU cache for averaging matrices. Keyed by a content hash of
    # the body geometry (centroid checksum + n_triangles) and target area.
    # Shared across all engine instances so the expensive build persists across requests.
    # Bounded to _G_CACHE_MAX entries to prevent unbounded memory growth in
    # long-running viewer sessions with many body switches.
    # Protected by _G_lock for thread safety (Flask serves concurrent requests).
    _G_cache: OrderedDict = OrderedDict()
    _G_lock: threading.Lock = threading.Lock()
    _G_computing: dict[tuple, threading.Event] = {}
    _G_CACHE_MAX: int = 16

    # Per-body visibility LUT cache (the self-shadowing bake), same in-flight
    # dedup pattern as the averaging matrix. Keyed by the pose-dependent
    # vertex_hash, so a yawed body misses (visibility is direction-dependent).
    _vis_lut_cache: OrderedDict = OrderedDict()
    _vis_lock: threading.Lock = threading.Lock()
    _vis_computing: dict[tuple, threading.Event] = {}
    _VIS_CACHE_MAX: int = 8

    def __init__(self, tissue: TissueModel) -> None:
        self.tissue = tissue
        self.T0 = tissue.T0
        self.n_tilde = tissue.n_complex
        self.freq_hz = tissue.freq_hz

    def _active_em_params(self, freq_hz: float | None) -> tuple[float, complex, float, float]:
        """Resolve per-call EM parameters, honoring an optional frequency override."""
        if freq_hz is None:
            return self.freq_hz, self.n_tilde, self.T0, self.tissue.sigma
        if freq_hz <= 0:
            raise ValueError(f"freq_hz must be positive (in Hz), got {freq_hz}")
        if 0 < freq_hz < 1e3:
            import warnings

            warnings.warn(
                f"freq_hz={freq_hz} looks like GHz or MHz, not Hz. "
                f"Did you mean {freq_hz * 1e9:.0f} Hz ({freq_hz} GHz)?",
                stacklevel=3,
            )

        active_n_tilde = fresnel_n_complex(self.tissue.eps_r, self.tissue.sigma, freq_hz)
        active_T0 = fresnel_T0(active_n_tilde)
        return float(freq_hz), active_n_tilde, active_T0, self.tissue.sigma

    @staticmethod
    def _resolve_diffraction_model(diffraction_model: str | None, diffraction: bool | None) -> str:
        """Resolve the engine-layer gate selector.

        An explicit ``diffraction_model`` always wins. Otherwise the legacy
        ``diffraction`` bool maps ``True -> "fock"`` (the new default, D7) and
        ``False -> "none"``; when neither is given (``diffraction is None``) the
        default is ``"fock"``. This differs from the kernel's own bool mapping
        (``True -> "gelu"``) on purpose: the engine always supplies ``fock_R``,
        direct kernel callers do not (DECISIONS.md L8).
        """
        if diffraction_model is not None:
            if diffraction_model not in _ENGINE_DIFFRACTION_MODELS:
                raise ValueError(
                    f"diffraction_model must be one of {_ENGINE_DIFFRACTION_MODELS}, got {diffraction_model!r}"
                )
            return diffraction_model
        if diffraction is None:
            return "fock"
        return "fock" if diffraction else "none"

    @staticmethod
    def _validate_inter_body(inter_body: str) -> None:
        """Validate the inter-body backend selector.

        ``"off"`` (default) and ``"specular1"`` (single specular recapture
        bounce) are the implemented options; anything else is a ValueError.
        """
        if inter_body not in ("off", "specular1"):
            raise ValueError(f"inter_body must be 'off' or 'specular1', got {inter_body!r}")

    def _fock_params(
        self,
        body: BodyMesh,
        k_hat: np.ndarray,
        model: str,
        freq_hz: float,
        n_tilde: complex,
    ) -> tuple[np.ndarray | None, complex | None, complex | None]:
        """Fock radius and the representative impedance-corrected eigenvalues.

        Thin wrapper over :func:`aegis.geometry.fock_gate.fock_params`, the
        shared source of truth used by the MIMO compute path too.
        """
        return fock_gate.fock_params(body, k_hat, model, freq_hz, n_tilde)

    @staticmethod
    def _body_cache_key(body: BodyMesh) -> int:
        """Content-based hash of body geometry for cache keying.

        Delegates to BodyMesh.geometry_hash which is computed once at
        construction time and cached, avoiding O(M) SHA256 on every call.
        """
        return body.geometry_hash

    def _get_G(self, body, target_area_m2):
        key = (self._body_cache_key(body), target_area_m2)
        with self._G_lock:
            if key in self._G_cache:
                self._G_cache.move_to_end(key)
                return self._G_cache[key]
            # Another thread is already computing this matrix, wait for it
            if key in self._G_computing:
                event = self._G_computing[key]
                self._G_lock.release()
                event.wait()
                self._G_lock.acquire()
                if key in self._G_cache:
                    self._G_cache.move_to_end(key)
                    return self._G_cache[key]
            # Mark this key as being computed
            event = threading.Event()
            self._G_computing[key] = event

        from aegis.geometry.averaging import precompute_averaging_matrix

        try:
            G = precompute_averaging_matrix(
                body.centroids,
                body.areas,
                target_area_m2,
            )
        finally:
            with self._G_lock:
                self._G_computing.pop(key, None)
                event.set()

        with self._G_lock:
            # Evict least-recently-used entries if cache is full
            while len(self._G_cache) >= self._G_CACHE_MAX:
                self._G_cache.popitem(last=False)
            self._G_cache[key] = G

        return G

    def _get_vis_lut(self, body: BodyMesh, resolution: int, gate: str):
        """Cached per-body visibility LUT (double-checked in-flight dedup).

        Mirrors :meth:`_get_G`; the worker calls ``visibility.get_or_bake`` which
        checks the disk cache before baking. Keyed by the pose-dependent
        ``vertex_hash`` so a yawed body misses.
        """
        key = (body.vertex_hash, resolution, gate)
        with self._vis_lock:
            if key in self._vis_lut_cache:
                self._vis_lut_cache.move_to_end(key)
                return self._vis_lut_cache[key]
            if key in self._vis_computing:
                event = self._vis_computing[key]
                self._vis_lock.release()
                event.wait()
                self._vis_lock.acquire()
                if key in self._vis_lut_cache:
                    self._vis_lut_cache.move_to_end(key)
                    return self._vis_lut_cache[key]
            event = threading.Event()
            self._vis_computing[key] = event

        from aegis.geometry import visibility as _vis

        try:
            lut = _vis.get_or_bake(body, resolution, gate)
        finally:
            with self._vis_lock:
                self._vis_computing.pop(key, None)
                event.set()

        with self._vis_lock:
            while len(self._vis_lut_cache) >= self._VIS_CACHE_MAX:
                self._vis_lut_cache.popitem(last=False)
            self._vis_lut_cache[key] = lut

        return lut

    def _distal_inputs(
        self,
        body: BodyMesh,
        paths: PropagationPaths,
        *,
        self_shadow: bool,
        self_shadow_directional: bool = False,
        source_pos: np.ndarray | None,
        vis_resolution: int,
        occlusion: np.ndarray | None,
    ) -> dict[str, np.ndarray] | None:
        """Build the distal-gate kwargs ``{clearance, R_occ, distal_d1, distal_d2}``.

        Returns ``None`` (gate is a no-op) when self-shadowing is off, when an
        explicit ``occlusion`` override is supplied (it bypasses the LUT), or when
        the body is convex (the LUT short-circuits to all-exposed). Far field uses
        ``paths.k_hat``; near field (``source_pos`` given) uses the per-triangle
        source->point direction.

        With ``self_shadow_directional`` and a far-field query (no ``source_pos``)
        the full octahedral LUT bake is replaced by the source-aware directional
        clearance (a small angular patch around each look direction). ~20x faster
        for the single-source viewer; the full LUT path is kept for near field.
        """
        if not self_shadow or occlusion is not None:
            return None
        from aegis.geometry import visibility as _vis

        centroids = _to_numpy(body.centroids)
        if self_shadow_directional and source_pos is None:
            clr, R_occ, d1, d2 = _vis.directional_clearance(body, _to_numpy(paths.k_hat))
            if not bool((clr < 0).any()):
                return None  # nothing shadowed at these directions -> gate no-op
            return {"clearance": clr, "R_occ": R_occ, "distal_d1": d1, "distal_d2": d2}

        lut = self._get_vis_lut(body, vis_resolution, "erf")
        if bool(lut.exposed_mask.all()):
            return None
        if source_pos is not None:
            src = np.asarray(source_pos, dtype=float)
            k = centroids - src
            k = k / np.linalg.norm(k, axis=1, keepdims=True)
            clr, R_occ, d1, d2 = _vis.query_visibility(lut, k, centroids, source_pos=src)
        else:
            clr, R_occ, d1, d2 = _vis.query_visibility(lut, _to_numpy(paths.k_hat), centroids, source_pos=None)
        return {"clearance": clr, "R_occ": R_occ, "distal_d1": d1, "distal_d2": d2}

    def _build_result(
        self,
        body: BodyMesh,
        paths: PropagationPaths,
        sab: np.ndarray,
        fidelity_level: int,
        *,
        body_mass: float | None = None,
        freq_hz: float | None = None,
        mode: str | None = None,
        corrections: tuple[str, ...] = (),
        Q: np.ndarray | None = None,
        rho: float | None = None,
        eigenvalues: np.ndarray | None = None,
        x_star: np.ndarray | None = None,
        spatial_averaging: bool = True,
        _timings: dict | None = None,
        sinc: np.ndarray | None = None,
    ) -> DosimetryResult:
        """Build a DosimetryResult from raw sab with averaging and derived quantities."""
        if not np.all(np.isfinite(sab)):
            n_nan = int(np.sum(np.isnan(sab)))
            n_inf = int(np.sum(np.isinf(sab)))
            pct = 100 * (n_nan + n_inf) / sab.size
            raise ValueError(
                f"Kernel produced non-finite sab values "
                f"({n_nan} NaN, {n_inf} Inf out of {sab.size} triangles, {pct:.1f}%). "
                f"Level {fidelity_level} kernel. "
                f"Common causes: degenerate mesh triangles with zero area, "
                f"paths with invalid k_hat directions, or extreme tissue parameters."
            )
        p_abs = float(np.sum(sab * body.areas))
        sar_wb = p_abs / body_mass if body_mass is not None else None
        effective_freq_hz = freq_hz if freq_hz is not None else self.freq_hz

        # Per-triangle incident power density.
        # For coherent levels the caller provides the coherent sinc directly;
        # for incoherent levels we compute the standard incoherent sum.
        if sinc is None:
            from aegis.kernels._base import incidence_geometry

            _, mu_plus = incidence_geometry(body.normals, _to_numpy(paths.k_hat))
            sinc = _to_numpy(mu_plus) @ _to_numpy(paths.power)

        sab_averaged = None
        sinc_averaged = None
        sab_1cm2_averaged = None
        avg_timings: dict[str, float] = {}

        if spatial_averaging:
            t0 = time.perf_counter()
            G_4cm2 = self._get_G(body, 4e-4)
            t_build = time.perf_counter() - t0
            t1 = time.perf_counter()
            sab_averaged = _to_numpy(G_4cm2 @ sab)
            sinc_averaged = _to_numpy(G_4cm2 @ sinc)
            t_matvec = time.perf_counter() - t1

            avg_timings = {
                "avg_build_G_4cm2_ms": t_build * 1e3,
                "avg_matvec_4cm2_ms": t_matvec * 1e3,
            }

            if effective_freq_hz is not None and effective_freq_hz > 30e9:
                t2 = time.perf_counter()
                G_1cm2 = self._get_G(body, 1e-4)
                avg_timings["avg_build_G_1cm2_ms"] = (time.perf_counter() - t2) * 1e3
                sab_1cm2_averaged = _to_numpy(G_1cm2 @ sab)

        # Propagate averaging timings to caller's dict if provided
        if _timings is not None:
            _timings.update(avg_timings)
        # Keep module-level dict updated for backward compatibility
        with _timings_lock:
            _last_timings.update(avg_timings)

        return DosimetryResult(
            sab=sab,
            p_abs=p_abs,
            fidelity_level=fidelity_level,
            sab_averaged=sab_averaged,
            sar_wb=sar_wb,
            sinc=sinc,
            sinc_averaged=sinc_averaged,
            sab_1cm2_averaged=sab_1cm2_averaged,
            freq_hz=effective_freq_hz,
            mode=mode,
            corrections=corrections,
            Q=Q,
            rho=rho,
            eigenvalues=eigenvalues,
            x_star=x_star,
        )

    def compute(
        self,
        body: BodyMesh,
        paths: PropagationPaths,
        level: int | None = None,
        body_mass: float | None = None,
        spatial_averaging: bool = True,
        # Level 0/1 precomputed geometry (optional)
        A_ab: float | None = None,
        D_max: float | None = None,
        sh_coeffs: np.ndarray | None = None,
        sh_L: int = 4,
        D_table: np.ndarray | None = None,
        D_dirs: np.ndarray | None = None,
        # Level 4 polarisation / mode-based corrections
        q: np.ndarray | float = 0.0,
        # Level 5/6 curvature
        curvature_H: np.ndarray | None = None,
        # Per-direction visibility (paper eq. 3.1: S_ab(r) = S_inc T_0 ReLU(mu) O(r, k_hat))
        # Shape: (M_tri,) for single path, or (M_tri, N_paths) for multi-path.
        # Values in [0, 1]. None => O = 1 everywhere (assume convex body).
        occlusion: np.ndarray | None = None,
        # Level 7-8 coherent MIMO
        precoder: Precoder | None = None,
        h: np.ndarray | None = None,
        P_abs_max: float = DEFAULT_P_ABS_MAX,
        # Mode-based API
        mode: str | None = None,
        fresnel: bool = True,
        polarisation: bool = False,
        diffraction: bool | None = None,
        diffraction_model: str | None = None,
        inter_body: str = "off",
        curvature: bool = False,
        self_shadow: bool = False,
        self_shadow_directional: bool = False,
        source_pos: np.ndarray | None = None,
        vis_resolution: int = 32,
        freq_hz: float | None = None,
        _timings: dict[str, float] | None = None,
    ) -> DosimetryResult:
        """Compute dosimetry at the specified fidelity level or mode.

        Parameters
        ----------
        body : BodyMesh
        paths : PropagationPaths
        level : fidelity level 0-8 (legacy API, mutually exclusive with mode)
        body_mass : body mass [kg] for SAR computation
        spatial_averaging : compute ICNIRP 4 cm^2 spatial averaging (default True)
        A_ab : absorption area [m^2] (required for levels 0-1)
        D_max : max directivity (required for level 0)
        sh_coeffs : SH coefficients for D(k_hat) (level 1)
        sh_L : SH degree (level 1)
        D_table : directivity LUT (level 1 alternative)
        D_dirs : directions for D_table (level 1 alternative)
        q : TM excess (level 4 or mode='spatial' with polarisation=True)
        curvature_H : (M,) twice mean curvature [1/m] (levels 5-6 or curvature/diffraction flags)
        precoder : Precoder with precoding vector x (required for level 7)
        h : (M_ant,) UE channel vector (required for level 8, optional for 7)
        P_abs_max : maximum absorbed power [W] (level 8)
        mode : one of 'bound', 'aggregate', 'spatial', 'coherent', 'ecbf'
        fresnel : use angle-dependent Fresnel (spatial mode, default True)
        polarisation : enable polarisation correction (spatial mode)
        diffraction : legacy bool. At the engine layer True -> "fock", False ->
            "none". An explicit ``diffraction_model`` always wins. ``None``
            (the default) leaves the model at its "fock" default.
        diffraction_model : shadow-edge gate "none" | "gelu" | "fock". Default
            "fock". Levels 0-5 have no shadow gate and ignore diffraction_model;
            it applies to spatial mode, level 6, and coherent levels 7-8.
        inter_body : "off" (default) or "specular1". "specular1" adds one
            specular recapture bounce (off-by-default, single bounce): each lit
            triangle's Fresnel-reflected ray is cast through the visibility BVH
            and, when it strikes another body triangle, the recaptured power is
            deposited there. Applies to spatial mode and legacy levels >= 2.
        curvature : enable curvature correction (spatial mode)
        self_shadow : enable distal self-shadowing (one body part shadowing
            another) via the baked per-body visibility LUT and the distal Fock
            gate. Default False (opt-in): turning it on changes the dose for
            non-convex bodies and breaks the spatial==level-N composability
            invariant, so it is off until explicitly requested. Convex bodies
            short-circuit to a no-op. Applies to spatial mode, level 6, and
            coherent levels 7-8; ignored when an explicit ``occlusion`` override
            is supplied.
        source_pos : (3,) point-source position for near-field self-shadowing.
            None (default) uses the far-field path directions in ``paths.k_hat``.
        vis_resolution : octahedral LUT resolution for the self-shadow bake.
        _timings : if provided, fine-grained timing data is written into this dict

        Returns
        -------
        DosimetryResult
        """
        if level is not None and mode is not None:
            raise ValueError(_ERR_LEVEL_AND_MODE)

        # Default: neither given -> behave like old level=2
        if level is None and mode is None:
            level = 2

        if body_mass is not None and (not np.isfinite(body_mass) or body_mass <= 0):
            raise ValueError("body_mass must be positive (and finite) when provided")

        active_freq_hz, active_n_tilde, active_T0, active_sigma = self._active_em_params(freq_hz)

        effective_model = self._resolve_diffraction_model(diffraction_model, diffraction)
        self._validate_inter_body(inter_body)

        # Distal self-shadowing gate inputs (spatial + coherent modes; the gate
        # is built inside the kernel from these per-(M, N) arrays). A convex body
        # or an explicit occlusion override returns None (no-op).
        distal = None
        if mode in ("spatial", "coherent", "ecbf"):
            distal = self._distal_inputs(
                body,
                paths,
                self_shadow=self_shadow,
                self_shadow_directional=self_shadow_directional,
                source_pos=source_pos,
                vis_resolution=vis_resolution,
                occlusion=occlusion,
            )

        # Mode-based path
        if mode is not None:
            result = self._compute_mode(
                body,
                paths,
                mode=mode,
                fresnel=fresnel,
                polarisation=polarisation,
                diffraction_model=effective_model,
                curvature=curvature,
                distal=distal,
                q=q,
                curvature_H=curvature_H,
                body_mass=body_mass,
                spatial_averaging=spatial_averaging,
                A_ab=A_ab,
                D_max=D_max,
                sh_coeffs=sh_coeffs,
                sh_L=sh_L,
                D_table=D_table,
                D_dirs=D_dirs,
                precoder=precoder,
                h=h,
                P_abs_max=P_abs_max,
                freq_hz=active_freq_hz,
                n_tilde=active_n_tilde,
                T0=active_T0,
                sigma=active_sigma,
                _timings=_timings,
            )
            if mode == "spatial" and (occlusion is not None or inter_body == "specular1"):
                # Re-build the result with the occlusion-multiplied direct sab
                # plus the optional specular recapture. Occlusion post-multiplies
                # the per-triangle Sab (paper eq. 3.1's O(r, k_hat) factor);
                # specular1 adds a single recapture bounce on top.
                new_sab = result.sab
                if occlusion is not None:
                    occ = np.asarray(occlusion, dtype=result.sab.dtype)
                    if occ.ndim == 2:
                        pw = np.asarray(paths.power, dtype=result.sab.dtype)
                        occ = (occ * pw[None, :]).sum(axis=1) / max(pw.sum(), 1e-30)
                    new_sab = new_sab * occ
                if inter_body == "specular1":
                    from aegis.geometry.inter_body import specular1_sab

                    new_sab = new_sab + specular1_sab(body, paths, active_n_tilde)
                result = self._build_result(
                    body,
                    paths,
                    new_sab,
                    result.fidelity_level,
                    body_mass=body_mass,
                    freq_hz=active_freq_hz,
                    spatial_averaging=spatial_averaging,
                    _timings=_timings,
                )
            return result

        # Legacy level-based path
        if level < 0 or level > 8:
            raise ValueError(f"Fidelity level must be 0-8, got {level}")

        # The distal gate is only wired into level 6 and the coherent levels on
        # the legacy level path (levels 0-5 use dedicated kernels with no shadow
        # gate). Silently dropping self_shadow there would mislead a caller into
        # believing shadowing was applied, so warn instead of no-op'ing quietly.
        # The mode="spatial" path (and explicit occlusion override) is unaffected.
        if self_shadow and (level < 6 or (level == 6 and occlusion is not None)):
            import warnings

            _why = (
                "an explicit occlusion override takes precedence" if level == 6 else f"level {level} has no shadow gate"
            )
            warnings.warn(
                f"self_shadow=True was ignored: {_why}. Distal self-shadowing applies "
                "to mode='spatial', level 6, and coherent levels 7-8. Use mode='spatial' "
                "for the level 2-5 equivalents with self-shadowing.",
                stacklevel=2,
            )

        if level >= 7:
            fock_R, q_F_s, q_F_h = self._fock_params(body, paths.k_hat, effective_model, active_freq_hz, active_n_tilde)
            distal = self._distal_inputs(
                body,
                paths,
                self_shadow=self_shadow,
                self_shadow_directional=self_shadow_directional,
                source_pos=source_pos,
                vis_resolution=vis_resolution,
                occlusion=occlusion,
            )
            return self._compute_coherent(
                body,
                paths,
                level,
                precoder=precoder,
                h=h,
                P_abs_max=P_abs_max,
                body_mass=body_mass,
                spatial_averaging=spatial_averaging,
                freq_hz=active_freq_hz,
                n_tilde=active_n_tilde,
                sigma=active_sigma,
                fock_R=fock_R,
                q_F_s=q_F_s,
                q_F_h=q_F_h,
                distal=distal,
            )

        # Only level 6 consumes the gate on the legacy level path; levels 2-5
        # have no shadow gate, so the Fock radius is computed only when needed.
        fock_R = q_F_s = q_F_h = None
        distal = None
        if level == 6:
            fock_R, q_F_s, q_F_h = self._fock_params(body, paths.k_hat, effective_model, active_freq_hz, active_n_tilde)
            distal = self._distal_inputs(
                body,
                paths,
                self_shadow=self_shadow,
                self_shadow_directional=self_shadow_directional,
                source_pos=source_pos,
                vis_resolution=vis_resolution,
                occlusion=occlusion,
            )

        sab = self._dispatch(
            body,
            paths,
            level,
            A_ab=A_ab,
            D_max=D_max,
            sh_coeffs=sh_coeffs,
            sh_L=sh_L,
            D_table=D_table,
            D_dirs=D_dirs,
            q=q,
            curvature_H=curvature_H,
            freq_hz=active_freq_hz,
            n_tilde=active_n_tilde,
            T0=active_T0,
            diffraction_model=effective_model,
            fock_R=fock_R,
            q_F_s=q_F_s,
            q_F_h=q_F_h,
            **(distal or {}),
        )
        sab = _to_numpy(sab)
        if occlusion is not None and level is not None and level >= 2:
            occ = np.asarray(occlusion, dtype=sab.dtype)
            if occ.ndim == 2:
                # paths combine inside the kernel via @ power, so occ must already
                # be reduced to (M,) before reaching here. Reduce by power-weighted
                # mean if a (M, N) array was passed.
                pw = np.asarray(paths.power, dtype=sab.dtype)
                occ = (occ * pw[None, :]).sum(axis=1) / max(pw.sum(), 1e-30)
            sab = sab * occ
        if inter_body == "specular1" and level is not None and level >= 2:
            from aegis.geometry.inter_body import specular1_sab

            sab = sab + specular1_sab(body, paths, active_n_tilde)
        return self._build_result(
            body,
            paths,
            sab,
            level,
            body_mass=body_mass,
            freq_hz=active_freq_hz,
            spatial_averaging=spatial_averaging,
            _timings=_timings,
        )

    def compute_with_timings(self, *args, **kwargs) -> tuple[DosimetryResult, dict[str, float]]:
        """Like compute(), but returns a (DosimetryResult, timings) tuple.

        The timings dict is local to this call (thread-safe). Keys match those
        in _last_timings: kernel_ms, avg_build_G_4cm2_ms, avg_matvec_4cm2_ms,
        avg_build_G_1cm2_ms.

        Parameters mirror compute() exactly.
        """
        timings: dict[str, float] = {}
        result = self.compute(*args, _timings=timings, **kwargs)
        return result, timings

    def compute_sab(
        self,
        body: BodyMesh,
        paths: PropagationPaths,
        level: int | None = None,
        *,
        precoder_x=None,
        precoder: Precoder | None = None,
        h=None,
        P_abs_max: float = DEFAULT_P_ABS_MAX,
        A_ab: float | None = None,
        D_max: float | None = None,
        sh_coeffs=None,
        sh_L: int = 4,
        D_table=None,
        D_dirs=None,
        q: float = 0.0,
        curvature_H=None,
        # Mode-based API
        mode: str | None = None,
        fresnel: bool = True,
        polarisation: bool = False,
        diffraction: bool | None = None,
        diffraction_model: str | None = None,
        inter_body: str = "off",
        curvature: bool = False,
        freq_hz: float | None = None,
    ):
        """Return per-triangle S_ab as a raw array (JAX or NumPy).

        Unlike compute(), this does not convert to NumPy or wrap in
        DosimetryResult. Use inside jax.grad boundaries for differentiable
        optimization. The diffraction-model resolution mirrors compute(), so
        compute_sab stays numerically consistent with compute().sab (default
        "fock"). The Fock radius is a geometry constant, so threading it does not
        break autodiff w.r.t. the precoder or source. ``inter_body`` mirrors
        compute()'s validation contract but is otherwise ignored here: the
        single specular recapture is a ray-cast pass that is not differentiable,
        so it does not apply to the autodiff path.
        """
        if level is not None and mode is not None:
            raise ValueError(_ERR_LEVEL_AND_MODE)

        # Default: neither given -> behave like old level=2
        if level is None and mode is None:
            level = 2

        active_freq_hz, active_n_tilde, active_T0, active_sigma = self._active_em_params(freq_hz)
        effective_model = self._resolve_diffraction_model(diffraction_model, diffraction)
        # The single specular recapture is a non-differentiable ray-cast pass;
        # validate-and-ignore here to mirror compute()'s error contract.
        self._validate_inter_body(inter_body)

        # Mode-based path for spatial
        if mode is not None:
            if mode == "spatial":
                from aegis.kernels.spatial import spatial_kernel

                fock_R, q_F_s, q_F_h = self._fock_params(
                    body, paths.k_hat, effective_model, active_freq_hz, active_n_tilde
                )
                return spatial_kernel(
                    body.normals,
                    paths.k_hat,
                    paths.power,
                    active_n_tilde,
                    active_T0,
                    active_freq_hz,
                    fresnel=fresnel,
                    polarisation=polarisation,
                    q=q,
                    curvature=curvature,
                    diffraction_model=effective_model,
                    curvature_H=curvature_H,
                    fock_R=fock_R,
                    q_F_s=q_F_s,
                    q_F_h=q_F_h,
                )
            # For non-spatial modes, map to the legacy dispatch
            mode_to_level = {
                "bound": 0,
                "aggregate": 1,
                "coherent": 7,
                "ecbf": 8,
            }
            if mode not in mode_to_level:
                _all = ("spatial",) + tuple(mode_to_level)
                raise ValueError(f"Unknown mode '{mode}', expected one of {_all}")
            level = mode_to_level[mode]

        if level < 0 or level > 8:
            raise ValueError(f"Fidelity level must be 0-8, got {level}")

        if level <= 6:
            fock_R = q_F_s = q_F_h = None
            if level == 6:
                fock_R, q_F_s, q_F_h = self._fock_params(
                    body, paths.k_hat, effective_model, active_freq_hz, active_n_tilde
                )
            return self._dispatch(
                body,
                paths,
                level,
                A_ab=A_ab,
                D_max=D_max,
                sh_coeffs=sh_coeffs,
                sh_L=sh_L,
                D_table=D_table,
                D_dirs=D_dirs,
                q=q,
                curvature_H=curvature_H,
                freq_hz=active_freq_hz,
                n_tilde=active_n_tilde,
                T0=active_T0,
                diffraction_model=effective_model,
                fock_R=fock_R,
                q_F_s=q_F_s,
                q_F_h=q_F_h,
            )

        # Coherent levels 7-8
        x = precoder_x
        if x is None and precoder is not None:
            x = precoder.x

        n_elements = paths.n_elements
        fock_R, q_F_s, q_F_h = self._fock_params(body, paths.k_hat, effective_model, active_freq_hz, active_n_tilde)

        if level == 7:
            if x is None:
                raise ValueError(
                    "Level 7 (coherent MIMO) requires a precoding vector. "
                    "Pass precoder=Precoder(x=...) or precoder_x=np.array(...)."
                )
            from aegis.kernels.level7_coherent import level7_coherent

            sab, _, _, _ = level7_coherent(
                body.normals,
                body.centroids,
                body.areas,
                paths.k_hat,
                paths.psi,
                paths.element_index,
                x,
                active_n_tilde,
                active_sigma,
                active_freq_hz,
                n_elements,
                h=h,
                fock_R=fock_R,
                q_F_s=q_F_s,
                q_F_h=q_F_h,
            )
            return sab

        if level == 8:
            if h is None:
                raise ValueError(
                    "Level 8 (ECBF) requires channel vector h of shape (M_ant,). "
                    "Use level 7 if you only have a precoder without a channel estimate."
                )
            from aegis.kernels.level8_ecbf import level8_ecbf

            P = float(precoder.power) if precoder is not None else 1.0
            sab, _, _, _, _ = level8_ecbf(
                body.normals,
                body.centroids,
                body.areas,
                paths.k_hat,
                paths.psi,
                paths.element_index,
                h,
                active_n_tilde,
                active_sigma,
                active_freq_hz,
                n_elements,
                P=P,
                P_abs_max=P_abs_max,
                fock_R=fock_R,
                q_F_s=q_F_s,
                q_F_h=q_F_h,
            )
            return sab

        raise ValueError(f"Unknown level {level}")

    def _compute_mode(
        self,
        body: BodyMesh,
        paths: PropagationPaths,
        *,
        mode: str,
        fresnel: bool = True,
        polarisation: bool = False,
        diffraction_model: str = "none",
        curvature: bool = False,
        q: np.ndarray | float = 0.0,
        curvature_H: np.ndarray | None = None,
        body_mass: float | None = None,
        spatial_averaging: bool = True,
        A_ab: float | None = None,
        D_max: float | None = None,
        sh_coeffs: np.ndarray | None = None,
        sh_L: int = 4,
        D_table: np.ndarray | None = None,
        D_dirs: np.ndarray | None = None,
        precoder: Precoder | None = None,
        h: np.ndarray | None = None,
        P_abs_max: float = DEFAULT_P_ABS_MAX,
        freq_hz: float | None = None,
        n_tilde: complex | None = None,
        T0: float | None = None,
        sigma: float | None = None,
        distal: dict[str, np.ndarray] | None = None,
        _timings: dict | None = None,
    ) -> DosimetryResult:
        """Dispatch based on mode string with composable correction flags."""
        _valid_modes = ("bound", "aggregate", "spatial", "coherent", "ecbf")
        if mode not in _valid_modes:
            raise ValueError(f"Unknown mode '{mode}', expected one of {_valid_modes}")

        diffraction_active = diffraction_model != "none"

        # Build corrections tuple for result metadata
        corrections: list[str] = []
        if mode == "spatial":
            if fresnel:
                corrections.append("fresnel")
            if polarisation:
                corrections.append("polarisation")
            if curvature:
                corrections.append("curvature")
            if diffraction_active:
                corrections.append("diffraction")

        # Map mode to level for fidelity_level field
        if mode == "spatial":
            fidelity_level = 2  # base spatial (geometric ReLU, no Fresnel)
            if fresnel:
                fidelity_level = 3
            if polarisation:
                fidelity_level = max(fidelity_level, 4)
            if curvature:
                fidelity_level = max(fidelity_level, 5)
            if diffraction_active:
                fidelity_level = max(fidelity_level, 6)
        else:
            mode_to_level = {
                "bound": 0,
                "aggregate": 1,
                "coherent": 7,
                "ecbf": 8,
            }
            fidelity_level = mode_to_level[mode]

        if mode == "spatial":
            from aegis.kernels.spatial import spatial_kernel

            t_kernel = time.perf_counter()
            # Use the physical polarisation from psi only when the paths carry a
            # real one; otherwise fall back to the legacy scalar q knob.
            psi_arg = paths.psi if (polarisation and paths.polarised) else None
            kernel_freq = freq_hz if freq_hz is not None else self.freq_hz
            kernel_n_tilde = n_tilde if n_tilde is not None else self.n_tilde
            fock_R, q_F_s, q_F_h = self._fock_params(body, paths.k_hat, diffraction_model, kernel_freq, kernel_n_tilde)
            sab = spatial_kernel(
                body.normals,
                paths.k_hat,
                paths.power,
                kernel_n_tilde,
                T0 if T0 is not None else self.T0,
                kernel_freq,
                fresnel=fresnel,
                polarisation=polarisation,
                q=q,
                psi=psi_arg,
                curvature=curvature,
                diffraction_model=diffraction_model,
                curvature_H=curvature_H,
                fock_R=fock_R,
                q_F_s=q_F_s,
                q_F_h=q_F_h,
                **(distal or {}),
            )
            sab = _to_numpy(sab)
            kernel_ms = (time.perf_counter() - t_kernel) * 1e3
            if _timings is not None:
                _timings["kernel_ms"] = kernel_ms
            with _timings_lock:
                _last_timings["kernel_ms"] = kernel_ms
        elif mode in ("coherent", "ecbf"):
            coh_freq = freq_hz if freq_hz is not None else self.freq_hz
            coh_n_tilde = n_tilde if n_tilde is not None else self.n_tilde
            fock_R, q_F_s, q_F_h = self._fock_params(body, paths.k_hat, diffraction_model, coh_freq, coh_n_tilde)
            return self._compute_coherent(
                body,
                paths,
                fidelity_level,
                precoder=precoder,
                h=h,
                P_abs_max=P_abs_max,
                body_mass=body_mass,
                spatial_averaging=spatial_averaging,
                freq_hz=freq_hz,
                n_tilde=n_tilde,
                sigma=sigma,
                mode=mode,
                corrections=tuple(corrections),
                fock_R=fock_R,
                q_F_s=q_F_s,
                q_F_h=q_F_h,
                distal=distal,
            )
        else:
            # bound or aggregate: use legacy dispatch
            sab = self._dispatch(
                body,
                paths,
                fidelity_level,
                A_ab=A_ab,
                D_max=D_max,
                sh_coeffs=sh_coeffs,
                sh_L=sh_L,
                D_table=D_table,
                D_dirs=D_dirs,
                q=q,
                curvature_H=curvature_H,
                freq_hz=freq_hz,
                n_tilde=n_tilde,
                T0=T0,
            )
            sab = _to_numpy(sab)

        return self._build_result(
            body,
            paths,
            sab,
            fidelity_level,
            body_mass=body_mass,
            freq_hz=freq_hz,
            mode=mode,
            corrections=tuple(corrections),
            spatial_averaging=spatial_averaging,
            _timings=_timings,
        )

    def _compute_coherent(
        self,
        body: BodyMesh,
        paths: PropagationPaths,
        level: int,
        precoder: Precoder | None = None,
        h: np.ndarray | None = None,
        P_abs_max: float = DEFAULT_P_ABS_MAX,
        body_mass: float | None = None,
        spatial_averaging: bool = True,
        freq_hz: float | None = None,
        n_tilde: complex | None = None,
        sigma: float | None = None,
        mode: str | None = None,
        corrections: tuple[str, ...] = (),
        fock_R: np.ndarray | None = None,
        q_F_s: complex | None = None,
        q_F_h: complex | None = None,
        distal: dict[str, np.ndarray] | None = None,
    ) -> DosimetryResult:
        """Dispatch coherent levels 7-8."""
        n_elements = paths.n_elements
        x_star = None
        active_freq_hz = freq_hz if freq_hz is not None else self.freq_hz
        active_n_tilde = n_tilde if n_tilde is not None else self.n_tilde
        active_sigma = sigma if sigma is not None else self.tissue.sigma
        distal_kw = distal or {}

        if level == 7:
            if precoder is None:
                raise ValueError("Level 7 requires a precoder")
            from aegis.kernels.level7_coherent import level7_coherent

            sab, Q, eigenvalues, rho = level7_coherent(
                body.normals,
                body.centroids,
                body.areas,
                paths.k_hat,
                paths.psi,
                paths.element_index,
                precoder.x,
                active_n_tilde,
                active_sigma,
                active_freq_hz,
                n_elements,
                h=h,
                fock_R=fock_R,
                q_F_s=q_F_s,
                q_F_h=q_F_h,
                **distal_kw,
            )
        elif level == 8:
            if h is None:
                raise ValueError("Level 8 requires UE channel vector h")
            from aegis.kernels.level8_ecbf import level8_ecbf

            P = precoder.power if precoder is not None else 1.0
            sab, Q, eigenvalues, x_star, rho = level8_ecbf(
                body.normals,
                body.centroids,
                body.areas,
                paths.k_hat,
                paths.psi,
                paths.element_index,
                h,
                active_n_tilde,
                active_sigma,
                active_freq_hz,
                n_elements,
                P=P,
                P_abs_max=P_abs_max,
                fock_R=fock_R,
                q_F_s=q_F_s,
                q_F_h=q_F_h,
                **distal_kw,
            )
        else:
            raise ValueError(f"Unknown coherent level {level}")

        sab = _to_numpy(sab)
        Q = _to_numpy(Q)
        eigenvalues = _to_numpy(eigenvalues)
        if x_star is not None:
            x_star = _to_numpy(x_star)

        # Coherent incident power density: use the actual precoder that
        # produced sab, not the incoherent per-path power sum.
        x_for_sinc = _to_numpy(x_star if x_star is not None else precoder.x)
        sinc_coherent = coherent_sinc(
            _to_numpy(body.centroids),
            _to_numpy(paths.k_hat),
            _to_numpy(paths.psi),
            np.asarray(paths.element_index),
            x_for_sinc,
            active_freq_hz,
        )

        return self._build_result(
            body,
            paths,
            sab,
            level,
            body_mass=body_mass,
            freq_hz=active_freq_hz,
            mode=mode,
            corrections=corrections,
            Q=Q,
            rho=rho,
            eigenvalues=eigenvalues,
            x_star=x_star,
            spatial_averaging=spatial_averaging,
            sinc=sinc_coherent,
        )

    def _dispatch(
        self,
        body: BodyMesh,
        paths: PropagationPaths,
        level: int,
        **kwargs,
    ) -> np.ndarray:
        """Dispatch to the appropriate kernel."""
        if level == 0:
            return self._level0(body, paths, **kwargs)
        elif level == 1:
            return self._level1(body, paths, **kwargs)
        elif level == 2:
            return self._level2(body, paths, **kwargs)
        elif level == 3:
            return self._level3(body, paths, **kwargs)
        elif level == 4:
            return self._level4(body, paths, **kwargs)
        elif level == 5:
            return self._level5(body, paths, **kwargs)
        elif level == 6:
            return self._level6(body, paths, **kwargs)
        raise ValueError(f"Unknown level {level}")

    def _level0(self, body: BodyMesh, paths: PropagationPaths, **kwargs) -> np.ndarray:
        from aegis.kernels.level0_bound import level0_bound

        A_ab = kwargs.get("A_ab")
        D_max = kwargs.get("D_max")
        T0 = kwargs.get("T0", self.T0)
        if A_ab is None or D_max is None:
            raise ValueError("Level 0 requires A_ab and D_max")
        sab, _ = level0_bound(
            body.total_area,
            A_ab,
            D_max,
            paths.power,
            T0,
            body.n_triangles,
        )
        return sab

    def _level1(self, body: BodyMesh, paths: PropagationPaths, **kwargs) -> np.ndarray:
        from aegis.kernels.level1_aggregate import level1_aggregate

        A_ab = kwargs.get("A_ab")
        T0 = kwargs.get("T0", self.T0)
        if A_ab is None:
            raise ValueError("Level 1 requires A_ab")
        sab, _ = level1_aggregate(
            body.total_area,
            A_ab,
            paths.k_hat,
            paths.power,
            T0,
            body.n_triangles,
            sh_coeffs=kwargs.get("sh_coeffs"),
            sh_L=kwargs.get("sh_L", 4),
            D_table=kwargs.get("D_table"),
            D_dirs=kwargs.get("D_dirs"),
        )
        return sab

    def _level2(self, body: BodyMesh, paths: PropagationPaths, **kwargs) -> np.ndarray:
        from aegis.kernels.level2_geometric import level2_geometric

        return level2_geometric(body.normals, paths.k_hat, paths.power, kwargs.get("T0", self.T0))

    def _level3(self, body: BodyMesh, paths: PropagationPaths, **kwargs) -> np.ndarray:
        from aegis.kernels.level3_fresnel import level3_fresnel

        return level3_fresnel(body.normals, paths.k_hat, paths.power, kwargs.get("n_tilde", self.n_tilde))

    def _level4(
        self,
        body: BodyMesh,
        paths: PropagationPaths,
        q: np.ndarray | float = 0.0,
        **kwargs,
    ) -> np.ndarray:
        from aegis.kernels.level4_polarisation import level4_polarisation

        return level4_polarisation(
            body.normals,
            paths.k_hat,
            paths.power,
            kwargs.get("n_tilde", self.n_tilde),
            q=q,
            psi=paths.psi if paths.polarised else None,
        )

    def _level5(
        self,
        body: BodyMesh,
        paths: PropagationPaths,
        curvature_H: np.ndarray | None = None,
        **kwargs,
    ) -> np.ndarray:
        from aegis.kernels.level5_curvature import level5_curvature

        if curvature_H is None:
            curvature_H = np.zeros(body.n_triangles)
        return level5_curvature(
            body.normals,
            paths.k_hat,
            paths.power,
            kwargs.get("n_tilde", self.n_tilde),
            kwargs.get("T0", self.T0),
            curvature_H,
            kwargs.get("freq_hz", self.freq_hz),
        )

    def _level6(
        self,
        body: BodyMesh,
        paths: PropagationPaths,
        curvature_H: np.ndarray | None = None,
        **kwargs,
    ) -> np.ndarray:
        from aegis.kernels.level6_diffraction import level6_diffraction

        if curvature_H is None:
            curvature_H = np.zeros(body.n_triangles)
        return level6_diffraction(
            body.normals,
            paths.k_hat,
            paths.power,
            kwargs.get("n_tilde", self.n_tilde),
            kwargs.get("T0", self.T0),
            curvature_H,
            kwargs.get("freq_hz", self.freq_hz),
            diffraction_model=kwargs.get("diffraction_model"),
            fock_R=kwargs.get("fock_R"),
            q_F_s=kwargs.get("q_F_s"),
            q_F_h=kwargs.get("q_F_h"),
            clearance=kwargs.get("clearance"),
            R_occ=kwargs.get("R_occ"),
            distal_d1=kwargs.get("distal_d1"),
            distal_d2=kwargs.get("distal_d2"),
        )

    def sweep_levels(
        self,
        body: BodyMesh,
        paths: PropagationPaths,
        levels: list[int] | None = None,
        *,
        body_mass: float | None = None,
        spatial_averaging: bool = True,
        A_ab: float | None = None,
        D_max: float | None = None,
        q: np.ndarray | float = 0.0,
        curvature_H: np.ndarray | None = None,
        freq_hz: float | None = None,
    ) -> dict[int, DosimetryResult]:
        """Compute dosimetry at multiple fidelity levels for convergence analysis.

        Runs each requested level and returns a dict mapping level -> result.
        Levels that require unavailable parameters are silently skipped.

        Parameters
        ----------
        body : BodyMesh
        paths : PropagationPaths
        levels : list of ints, or None for all feasible incoherent levels
        body_mass : body mass [kg] for SAR (optional)
        A_ab : absorption area for levels 0-1
        D_max : max directivity for level 0
        q : TM excess for level 4
        curvature_H : mean curvature for levels 5-6
        freq_hz : frequency override

        Returns
        -------
        dict mapping int level -> DosimetryResult
        """
        if levels is None:
            levels = list(range(7))  # 0-6, skip coherent

        # Determine which levels are feasible given the available parameters
        _requires = {
            0: ("A_ab", "D_max"),
            1: ("A_ab",),
        }

        params = {"A_ab": A_ab, "D_max": D_max}

        results: dict[int, DosimetryResult] = {}
        for level in levels:
            # Skip levels whose required params are missing
            missing = [p for p in _requires.get(level, ()) if params.get(p) is None]
            if missing:
                continue

            result = self.compute(
                body,
                paths,
                level=level,
                body_mass=body_mass,
                spatial_averaging=spatial_averaging,
                A_ab=A_ab,
                D_max=D_max,
                q=q,
                curvature_H=curvature_H,
                freq_hz=freq_hz,
            )
            results[level] = result

        return results

compute

compute(body: BodyMesh, paths: PropagationPaths, level: int | None = None, body_mass: float | None = None, spatial_averaging: bool = True, A_ab: float | None = None, D_max: float | None = None, sh_coeffs: ndarray | None = None, sh_L: int = 4, D_table: ndarray | None = None, D_dirs: ndarray | None = None, q: ndarray | float = 0.0, curvature_H: ndarray | None = None, occlusion: ndarray | None = None, precoder: Precoder | None = None, h: ndarray | None = None, P_abs_max: float = DEFAULT_P_ABS_MAX, mode: str | None = None, fresnel: bool = True, polarisation: bool = False, diffraction: bool | None = None, diffraction_model: str | None = None, inter_body: str = 'off', curvature: bool = False, self_shadow: bool = False, self_shadow_directional: bool = False, source_pos: ndarray | None = None, vis_resolution: int = 32, freq_hz: float | None = None, _timings: dict[str, float] | None = None) -> DosimetryResult

Compute dosimetry at the specified fidelity level or mode.

Parameters

body : BodyMesh paths : PropagationPaths level : fidelity level 0-8 (legacy API, mutually exclusive with mode) body_mass : body mass [kg] for SAR computation spatial_averaging : compute ICNIRP 4 cm^2 spatial averaging (default True) A_ab : absorption area [m^2] (required for levels 0-1) D_max : max directivity (required for level 0) sh_coeffs : SH coefficients for D(k_hat) (level 1) sh_L : SH degree (level 1) D_table : directivity LUT (level 1 alternative) D_dirs : directions for D_table (level 1 alternative) q : TM excess (level 4 or mode='spatial' with polarisation=True) curvature_H : (M,) twice mean curvature [1/m] (levels 5-6 or curvature/diffraction flags) precoder : Precoder with precoding vector x (required for level 7) h : (M_ant,) UE channel vector (required for level 8, optional for 7) P_abs_max : maximum absorbed power [W] (level 8) mode : one of 'bound', 'aggregate', 'spatial', 'coherent', 'ecbf' fresnel : use angle-dependent Fresnel (spatial mode, default True) polarisation : enable polarisation correction (spatial mode) diffraction : legacy bool. At the engine layer True -> "fock", False -> "none". An explicit diffraction_model always wins. None (the default) leaves the model at its "fock" default. diffraction_model : shadow-edge gate "none" | "gelu" | "fock". Default "fock". Levels 0-5 have no shadow gate and ignore diffraction_model; it applies to spatial mode, level 6, and coherent levels 7-8. inter_body : "off" (default) or "specular1". "specular1" adds one specular recapture bounce (off-by-default, single bounce): each lit triangle's Fresnel-reflected ray is cast through the visibility BVH and, when it strikes another body triangle, the recaptured power is deposited there. Applies to spatial mode and legacy levels >= 2. curvature : enable curvature correction (spatial mode) self_shadow : enable distal self-shadowing (one body part shadowing another) via the baked per-body visibility LUT and the distal Fock gate. Default False (opt-in): turning it on changes the dose for non-convex bodies and breaks the spatial==level-N composability invariant, so it is off until explicitly requested. Convex bodies short-circuit to a no-op. Applies to spatial mode, level 6, and coherent levels 7-8; ignored when an explicit occlusion override is supplied. source_pos : (3,) point-source position for near-field self-shadowing. None (default) uses the far-field path directions in paths.k_hat. vis_resolution : octahedral LUT resolution for the self-shadow bake. _timings : if provided, fine-grained timing data is written into this dict

Returns

DosimetryResult

Source code in src/aegis/engine.py
def compute(
    self,
    body: BodyMesh,
    paths: PropagationPaths,
    level: int | None = None,
    body_mass: float | None = None,
    spatial_averaging: bool = True,
    # Level 0/1 precomputed geometry (optional)
    A_ab: float | None = None,
    D_max: float | None = None,
    sh_coeffs: np.ndarray | None = None,
    sh_L: int = 4,
    D_table: np.ndarray | None = None,
    D_dirs: np.ndarray | None = None,
    # Level 4 polarisation / mode-based corrections
    q: np.ndarray | float = 0.0,
    # Level 5/6 curvature
    curvature_H: np.ndarray | None = None,
    # Per-direction visibility (paper eq. 3.1: S_ab(r) = S_inc T_0 ReLU(mu) O(r, k_hat))
    # Shape: (M_tri,) for single path, or (M_tri, N_paths) for multi-path.
    # Values in [0, 1]. None => O = 1 everywhere (assume convex body).
    occlusion: np.ndarray | None = None,
    # Level 7-8 coherent MIMO
    precoder: Precoder | None = None,
    h: np.ndarray | None = None,
    P_abs_max: float = DEFAULT_P_ABS_MAX,
    # Mode-based API
    mode: str | None = None,
    fresnel: bool = True,
    polarisation: bool = False,
    diffraction: bool | None = None,
    diffraction_model: str | None = None,
    inter_body: str = "off",
    curvature: bool = False,
    self_shadow: bool = False,
    self_shadow_directional: bool = False,
    source_pos: np.ndarray | None = None,
    vis_resolution: int = 32,
    freq_hz: float | None = None,
    _timings: dict[str, float] | None = None,
) -> DosimetryResult:
    """Compute dosimetry at the specified fidelity level or mode.

    Parameters
    ----------
    body : BodyMesh
    paths : PropagationPaths
    level : fidelity level 0-8 (legacy API, mutually exclusive with mode)
    body_mass : body mass [kg] for SAR computation
    spatial_averaging : compute ICNIRP 4 cm^2 spatial averaging (default True)
    A_ab : absorption area [m^2] (required for levels 0-1)
    D_max : max directivity (required for level 0)
    sh_coeffs : SH coefficients for D(k_hat) (level 1)
    sh_L : SH degree (level 1)
    D_table : directivity LUT (level 1 alternative)
    D_dirs : directions for D_table (level 1 alternative)
    q : TM excess (level 4 or mode='spatial' with polarisation=True)
    curvature_H : (M,) twice mean curvature [1/m] (levels 5-6 or curvature/diffraction flags)
    precoder : Precoder with precoding vector x (required for level 7)
    h : (M_ant,) UE channel vector (required for level 8, optional for 7)
    P_abs_max : maximum absorbed power [W] (level 8)
    mode : one of 'bound', 'aggregate', 'spatial', 'coherent', 'ecbf'
    fresnel : use angle-dependent Fresnel (spatial mode, default True)
    polarisation : enable polarisation correction (spatial mode)
    diffraction : legacy bool. At the engine layer True -> "fock", False ->
        "none". An explicit ``diffraction_model`` always wins. ``None``
        (the default) leaves the model at its "fock" default.
    diffraction_model : shadow-edge gate "none" | "gelu" | "fock". Default
        "fock". Levels 0-5 have no shadow gate and ignore diffraction_model;
        it applies to spatial mode, level 6, and coherent levels 7-8.
    inter_body : "off" (default) or "specular1". "specular1" adds one
        specular recapture bounce (off-by-default, single bounce): each lit
        triangle's Fresnel-reflected ray is cast through the visibility BVH
        and, when it strikes another body triangle, the recaptured power is
        deposited there. Applies to spatial mode and legacy levels >= 2.
    curvature : enable curvature correction (spatial mode)
    self_shadow : enable distal self-shadowing (one body part shadowing
        another) via the baked per-body visibility LUT and the distal Fock
        gate. Default False (opt-in): turning it on changes the dose for
        non-convex bodies and breaks the spatial==level-N composability
        invariant, so it is off until explicitly requested. Convex bodies
        short-circuit to a no-op. Applies to spatial mode, level 6, and
        coherent levels 7-8; ignored when an explicit ``occlusion`` override
        is supplied.
    source_pos : (3,) point-source position for near-field self-shadowing.
        None (default) uses the far-field path directions in ``paths.k_hat``.
    vis_resolution : octahedral LUT resolution for the self-shadow bake.
    _timings : if provided, fine-grained timing data is written into this dict

    Returns
    -------
    DosimetryResult
    """
    if level is not None and mode is not None:
        raise ValueError(_ERR_LEVEL_AND_MODE)

    # Default: neither given -> behave like old level=2
    if level is None and mode is None:
        level = 2

    if body_mass is not None and (not np.isfinite(body_mass) or body_mass <= 0):
        raise ValueError("body_mass must be positive (and finite) when provided")

    active_freq_hz, active_n_tilde, active_T0, active_sigma = self._active_em_params(freq_hz)

    effective_model = self._resolve_diffraction_model(diffraction_model, diffraction)
    self._validate_inter_body(inter_body)

    # Distal self-shadowing gate inputs (spatial + coherent modes; the gate
    # is built inside the kernel from these per-(M, N) arrays). A convex body
    # or an explicit occlusion override returns None (no-op).
    distal = None
    if mode in ("spatial", "coherent", "ecbf"):
        distal = self._distal_inputs(
            body,
            paths,
            self_shadow=self_shadow,
            self_shadow_directional=self_shadow_directional,
            source_pos=source_pos,
            vis_resolution=vis_resolution,
            occlusion=occlusion,
        )

    # Mode-based path
    if mode is not None:
        result = self._compute_mode(
            body,
            paths,
            mode=mode,
            fresnel=fresnel,
            polarisation=polarisation,
            diffraction_model=effective_model,
            curvature=curvature,
            distal=distal,
            q=q,
            curvature_H=curvature_H,
            body_mass=body_mass,
            spatial_averaging=spatial_averaging,
            A_ab=A_ab,
            D_max=D_max,
            sh_coeffs=sh_coeffs,
            sh_L=sh_L,
            D_table=D_table,
            D_dirs=D_dirs,
            precoder=precoder,
            h=h,
            P_abs_max=P_abs_max,
            freq_hz=active_freq_hz,
            n_tilde=active_n_tilde,
            T0=active_T0,
            sigma=active_sigma,
            _timings=_timings,
        )
        if mode == "spatial" and (occlusion is not None or inter_body == "specular1"):
            # Re-build the result with the occlusion-multiplied direct sab
            # plus the optional specular recapture. Occlusion post-multiplies
            # the per-triangle Sab (paper eq. 3.1's O(r, k_hat) factor);
            # specular1 adds a single recapture bounce on top.
            new_sab = result.sab
            if occlusion is not None:
                occ = np.asarray(occlusion, dtype=result.sab.dtype)
                if occ.ndim == 2:
                    pw = np.asarray(paths.power, dtype=result.sab.dtype)
                    occ = (occ * pw[None, :]).sum(axis=1) / max(pw.sum(), 1e-30)
                new_sab = new_sab * occ
            if inter_body == "specular1":
                from aegis.geometry.inter_body import specular1_sab

                new_sab = new_sab + specular1_sab(body, paths, active_n_tilde)
            result = self._build_result(
                body,
                paths,
                new_sab,
                result.fidelity_level,
                body_mass=body_mass,
                freq_hz=active_freq_hz,
                spatial_averaging=spatial_averaging,
                _timings=_timings,
            )
        return result

    # Legacy level-based path
    if level < 0 or level > 8:
        raise ValueError(f"Fidelity level must be 0-8, got {level}")

    # The distal gate is only wired into level 6 and the coherent levels on
    # the legacy level path (levels 0-5 use dedicated kernels with no shadow
    # gate). Silently dropping self_shadow there would mislead a caller into
    # believing shadowing was applied, so warn instead of no-op'ing quietly.
    # The mode="spatial" path (and explicit occlusion override) is unaffected.
    if self_shadow and (level < 6 or (level == 6 and occlusion is not None)):
        import warnings

        _why = (
            "an explicit occlusion override takes precedence" if level == 6 else f"level {level} has no shadow gate"
        )
        warnings.warn(
            f"self_shadow=True was ignored: {_why}. Distal self-shadowing applies "
            "to mode='spatial', level 6, and coherent levels 7-8. Use mode='spatial' "
            "for the level 2-5 equivalents with self-shadowing.",
            stacklevel=2,
        )

    if level >= 7:
        fock_R, q_F_s, q_F_h = self._fock_params(body, paths.k_hat, effective_model, active_freq_hz, active_n_tilde)
        distal = self._distal_inputs(
            body,
            paths,
            self_shadow=self_shadow,
            self_shadow_directional=self_shadow_directional,
            source_pos=source_pos,
            vis_resolution=vis_resolution,
            occlusion=occlusion,
        )
        return self._compute_coherent(
            body,
            paths,
            level,
            precoder=precoder,
            h=h,
            P_abs_max=P_abs_max,
            body_mass=body_mass,
            spatial_averaging=spatial_averaging,
            freq_hz=active_freq_hz,
            n_tilde=active_n_tilde,
            sigma=active_sigma,
            fock_R=fock_R,
            q_F_s=q_F_s,
            q_F_h=q_F_h,
            distal=distal,
        )

    # Only level 6 consumes the gate on the legacy level path; levels 2-5
    # have no shadow gate, so the Fock radius is computed only when needed.
    fock_R = q_F_s = q_F_h = None
    distal = None
    if level == 6:
        fock_R, q_F_s, q_F_h = self._fock_params(body, paths.k_hat, effective_model, active_freq_hz, active_n_tilde)
        distal = self._distal_inputs(
            body,
            paths,
            self_shadow=self_shadow,
            self_shadow_directional=self_shadow_directional,
            source_pos=source_pos,
            vis_resolution=vis_resolution,
            occlusion=occlusion,
        )

    sab = self._dispatch(
        body,
        paths,
        level,
        A_ab=A_ab,
        D_max=D_max,
        sh_coeffs=sh_coeffs,
        sh_L=sh_L,
        D_table=D_table,
        D_dirs=D_dirs,
        q=q,
        curvature_H=curvature_H,
        freq_hz=active_freq_hz,
        n_tilde=active_n_tilde,
        T0=active_T0,
        diffraction_model=effective_model,
        fock_R=fock_R,
        q_F_s=q_F_s,
        q_F_h=q_F_h,
        **(distal or {}),
    )
    sab = _to_numpy(sab)
    if occlusion is not None and level is not None and level >= 2:
        occ = np.asarray(occlusion, dtype=sab.dtype)
        if occ.ndim == 2:
            # paths combine inside the kernel via @ power, so occ must already
            # be reduced to (M,) before reaching here. Reduce by power-weighted
            # mean if a (M, N) array was passed.
            pw = np.asarray(paths.power, dtype=sab.dtype)
            occ = (occ * pw[None, :]).sum(axis=1) / max(pw.sum(), 1e-30)
        sab = sab * occ
    if inter_body == "specular1" and level is not None and level >= 2:
        from aegis.geometry.inter_body import specular1_sab

        sab = sab + specular1_sab(body, paths, active_n_tilde)
    return self._build_result(
        body,
        paths,
        sab,
        level,
        body_mass=body_mass,
        freq_hz=active_freq_hz,
        spatial_averaging=spatial_averaging,
        _timings=_timings,
    )

compute_with_timings

compute_with_timings(*args, **kwargs) -> tuple[DosimetryResult, dict[str, float]]

Like compute(), but returns a (DosimetryResult, timings) tuple.

The timings dict is local to this call (thread-safe). Keys match those in _last_timings: kernel_ms, avg_build_G_4cm2_ms, avg_matvec_4cm2_ms, avg_build_G_1cm2_ms.

Parameters mirror compute() exactly.

Source code in src/aegis/engine.py
def compute_with_timings(self, *args, **kwargs) -> tuple[DosimetryResult, dict[str, float]]:
    """Like compute(), but returns a (DosimetryResult, timings) tuple.

    The timings dict is local to this call (thread-safe). Keys match those
    in _last_timings: kernel_ms, avg_build_G_4cm2_ms, avg_matvec_4cm2_ms,
    avg_build_G_1cm2_ms.

    Parameters mirror compute() exactly.
    """
    timings: dict[str, float] = {}
    result = self.compute(*args, _timings=timings, **kwargs)
    return result, timings

compute_sab

compute_sab(body: BodyMesh, paths: PropagationPaths, level: int | None = None, *, precoder_x=None, precoder: Precoder | None = None, h=None, P_abs_max: float = DEFAULT_P_ABS_MAX, A_ab: float | None = None, D_max: float | None = None, sh_coeffs=None, sh_L: int = 4, D_table=None, D_dirs=None, q: float = 0.0, curvature_H=None, mode: str | None = None, fresnel: bool = True, polarisation: bool = False, diffraction: bool | None = None, diffraction_model: str | None = None, inter_body: str = 'off', curvature: bool = False, freq_hz: float | None = None)

Return per-triangle S_ab as a raw array (JAX or NumPy).

Unlike compute(), this does not convert to NumPy or wrap in DosimetryResult. Use inside jax.grad boundaries for differentiable optimization. The diffraction-model resolution mirrors compute(), so compute_sab stays numerically consistent with compute().sab (default "fock"). The Fock radius is a geometry constant, so threading it does not break autodiff w.r.t. the precoder or source. inter_body mirrors compute()'s validation contract but is otherwise ignored here: the single specular recapture is a ray-cast pass that is not differentiable, so it does not apply to the autodiff path.

Source code in src/aegis/engine.py
def compute_sab(
    self,
    body: BodyMesh,
    paths: PropagationPaths,
    level: int | None = None,
    *,
    precoder_x=None,
    precoder: Precoder | None = None,
    h=None,
    P_abs_max: float = DEFAULT_P_ABS_MAX,
    A_ab: float | None = None,
    D_max: float | None = None,
    sh_coeffs=None,
    sh_L: int = 4,
    D_table=None,
    D_dirs=None,
    q: float = 0.0,
    curvature_H=None,
    # Mode-based API
    mode: str | None = None,
    fresnel: bool = True,
    polarisation: bool = False,
    diffraction: bool | None = None,
    diffraction_model: str | None = None,
    inter_body: str = "off",
    curvature: bool = False,
    freq_hz: float | None = None,
):
    """Return per-triangle S_ab as a raw array (JAX or NumPy).

    Unlike compute(), this does not convert to NumPy or wrap in
    DosimetryResult. Use inside jax.grad boundaries for differentiable
    optimization. The diffraction-model resolution mirrors compute(), so
    compute_sab stays numerically consistent with compute().sab (default
    "fock"). The Fock radius is a geometry constant, so threading it does not
    break autodiff w.r.t. the precoder or source. ``inter_body`` mirrors
    compute()'s validation contract but is otherwise ignored here: the
    single specular recapture is a ray-cast pass that is not differentiable,
    so it does not apply to the autodiff path.
    """
    if level is not None and mode is not None:
        raise ValueError(_ERR_LEVEL_AND_MODE)

    # Default: neither given -> behave like old level=2
    if level is None and mode is None:
        level = 2

    active_freq_hz, active_n_tilde, active_T0, active_sigma = self._active_em_params(freq_hz)
    effective_model = self._resolve_diffraction_model(diffraction_model, diffraction)
    # The single specular recapture is a non-differentiable ray-cast pass;
    # validate-and-ignore here to mirror compute()'s error contract.
    self._validate_inter_body(inter_body)

    # Mode-based path for spatial
    if mode is not None:
        if mode == "spatial":
            from aegis.kernels.spatial import spatial_kernel

            fock_R, q_F_s, q_F_h = self._fock_params(
                body, paths.k_hat, effective_model, active_freq_hz, active_n_tilde
            )
            return spatial_kernel(
                body.normals,
                paths.k_hat,
                paths.power,
                active_n_tilde,
                active_T0,
                active_freq_hz,
                fresnel=fresnel,
                polarisation=polarisation,
                q=q,
                curvature=curvature,
                diffraction_model=effective_model,
                curvature_H=curvature_H,
                fock_R=fock_R,
                q_F_s=q_F_s,
                q_F_h=q_F_h,
            )
        # For non-spatial modes, map to the legacy dispatch
        mode_to_level = {
            "bound": 0,
            "aggregate": 1,
            "coherent": 7,
            "ecbf": 8,
        }
        if mode not in mode_to_level:
            _all = ("spatial",) + tuple(mode_to_level)
            raise ValueError(f"Unknown mode '{mode}', expected one of {_all}")
        level = mode_to_level[mode]

    if level < 0 or level > 8:
        raise ValueError(f"Fidelity level must be 0-8, got {level}")

    if level <= 6:
        fock_R = q_F_s = q_F_h = None
        if level == 6:
            fock_R, q_F_s, q_F_h = self._fock_params(
                body, paths.k_hat, effective_model, active_freq_hz, active_n_tilde
            )
        return self._dispatch(
            body,
            paths,
            level,
            A_ab=A_ab,
            D_max=D_max,
            sh_coeffs=sh_coeffs,
            sh_L=sh_L,
            D_table=D_table,
            D_dirs=D_dirs,
            q=q,
            curvature_H=curvature_H,
            freq_hz=active_freq_hz,
            n_tilde=active_n_tilde,
            T0=active_T0,
            diffraction_model=effective_model,
            fock_R=fock_R,
            q_F_s=q_F_s,
            q_F_h=q_F_h,
        )

    # Coherent levels 7-8
    x = precoder_x
    if x is None and precoder is not None:
        x = precoder.x

    n_elements = paths.n_elements
    fock_R, q_F_s, q_F_h = self._fock_params(body, paths.k_hat, effective_model, active_freq_hz, active_n_tilde)

    if level == 7:
        if x is None:
            raise ValueError(
                "Level 7 (coherent MIMO) requires a precoding vector. "
                "Pass precoder=Precoder(x=...) or precoder_x=np.array(...)."
            )
        from aegis.kernels.level7_coherent import level7_coherent

        sab, _, _, _ = level7_coherent(
            body.normals,
            body.centroids,
            body.areas,
            paths.k_hat,
            paths.psi,
            paths.element_index,
            x,
            active_n_tilde,
            active_sigma,
            active_freq_hz,
            n_elements,
            h=h,
            fock_R=fock_R,
            q_F_s=q_F_s,
            q_F_h=q_F_h,
        )
        return sab

    if level == 8:
        if h is None:
            raise ValueError(
                "Level 8 (ECBF) requires channel vector h of shape (M_ant,). "
                "Use level 7 if you only have a precoder without a channel estimate."
            )
        from aegis.kernels.level8_ecbf import level8_ecbf

        P = float(precoder.power) if precoder is not None else 1.0
        sab, _, _, _, _ = level8_ecbf(
            body.normals,
            body.centroids,
            body.areas,
            paths.k_hat,
            paths.psi,
            paths.element_index,
            h,
            active_n_tilde,
            active_sigma,
            active_freq_hz,
            n_elements,
            P=P,
            P_abs_max=P_abs_max,
            fock_R=fock_R,
            q_F_s=q_F_s,
            q_F_h=q_F_h,
        )
        return sab

    raise ValueError(f"Unknown level {level}")

sweep_levels

sweep_levels(body: BodyMesh, paths: PropagationPaths, levels: list[int] | None = None, *, body_mass: float | None = None, spatial_averaging: bool = True, A_ab: float | None = None, D_max: float | None = None, q: ndarray | float = 0.0, curvature_H: ndarray | None = None, freq_hz: float | None = None) -> dict[int, DosimetryResult]

Compute dosimetry at multiple fidelity levels for convergence analysis.

Runs each requested level and returns a dict mapping level -> result. Levels that require unavailable parameters are silently skipped.

Parameters

body : BodyMesh paths : PropagationPaths levels : list of ints, or None for all feasible incoherent levels body_mass : body mass [kg] for SAR (optional) A_ab : absorption area for levels 0-1 D_max : max directivity for level 0 q : TM excess for level 4 curvature_H : mean curvature for levels 5-6 freq_hz : frequency override

Returns

dict mapping int level -> DosimetryResult

Source code in src/aegis/engine.py
def sweep_levels(
    self,
    body: BodyMesh,
    paths: PropagationPaths,
    levels: list[int] | None = None,
    *,
    body_mass: float | None = None,
    spatial_averaging: bool = True,
    A_ab: float | None = None,
    D_max: float | None = None,
    q: np.ndarray | float = 0.0,
    curvature_H: np.ndarray | None = None,
    freq_hz: float | None = None,
) -> dict[int, DosimetryResult]:
    """Compute dosimetry at multiple fidelity levels for convergence analysis.

    Runs each requested level and returns a dict mapping level -> result.
    Levels that require unavailable parameters are silently skipped.

    Parameters
    ----------
    body : BodyMesh
    paths : PropagationPaths
    levels : list of ints, or None for all feasible incoherent levels
    body_mass : body mass [kg] for SAR (optional)
    A_ab : absorption area for levels 0-1
    D_max : max directivity for level 0
    q : TM excess for level 4
    curvature_H : mean curvature for levels 5-6
    freq_hz : frequency override

    Returns
    -------
    dict mapping int level -> DosimetryResult
    """
    if levels is None:
        levels = list(range(7))  # 0-6, skip coherent

    # Determine which levels are feasible given the available parameters
    _requires = {
        0: ("A_ab", "D_max"),
        1: ("A_ab",),
    }

    params = {"A_ab": A_ab, "D_max": D_max}

    results: dict[int, DosimetryResult] = {}
    for level in levels:
        # Skip levels whose required params are missing
        missing = [p for p in _requires.get(level, ()) if params.get(p) is None]
        if missing:
            continue

        result = self.compute(
            body,
            paths,
            level=level,
            body_mass=body_mass,
            spatial_averaging=spatial_averaging,
            A_ab=A_ab,
            D_max=D_max,
            q=q,
            curvature_H=curvature_H,
            freq_hz=freq_hz,
        )
        results[level] = result

    return results

DosimetryResult

aegis.result.DosimetryResult dataclass

Output of a dosimetry computation.

Attributes

sab : (M,) per-triangle absorbed power density [W/m^2] sab_averaged : (M,) or None, spatially averaged (ICNIRP 4 cm^2) [W/m^2] p_abs : total absorbed power [W] sar_wb : whole-body SAR [W/kg], or None if body mass not provided fidelity_level : which kernel level produced this result (0-8)

Source code in src/aegis/result.py
@dataclass(frozen=True)
class DosimetryResult:
    """Output of a dosimetry computation.

    Attributes
    ----------
    sab : (M,) per-triangle absorbed power density [W/m^2]
    sab_averaged : (M,) or None, spatially averaged (ICNIRP 4 cm^2) [W/m^2]
    p_abs : total absorbed power [W]
    sar_wb : whole-body SAR [W/kg], or None if body mass not provided
    fidelity_level : which kernel level produced this result (0-8)
    """

    sab: np.ndarray = field(repr=False)
    p_abs: float
    fidelity_level: int
    sab_averaged: np.ndarray | None = field(default=None, repr=False)
    sar_wb: float | None = None

    # Mode-based API (None when using legacy level= API)
    mode: str | None = None
    corrections: tuple[str, ...] = ()

    # Coherent-specific (None for incoherent levels 0-6)
    Q: np.ndarray | None = field(default=None, repr=False)
    rho: float | None = None
    eigenvalues: np.ndarray | None = field(default=None, repr=False)
    x_star: np.ndarray | None = field(default=None, repr=False)

    # Incident and averaged fields
    sinc: np.ndarray | None = field(default=None, repr=False)
    sinc_averaged: np.ndarray | None = field(default=None, repr=False)
    sab_1cm2_averaged: np.ndarray | None = field(default=None, repr=False)
    freq_hz: float | None = None

    def to_dict(self) -> dict:
        """Serialize fields to a JSON-friendly dict. Omits None values.

        Complex arrays (Q, eigenvalues, x_star) are serialized as
        {"real": [...], "imag": [...]}.
        """
        out: dict = {}
        for f in fields(self):
            val = getattr(self, f.name)
            if val is None:
                continue
            # Use hasattr check so both NumPy and JAX arrays are handled
            if hasattr(val, "tolist") and hasattr(val, "dtype"):
                if np.iscomplexobj(val):
                    out[f.name] = {"real": np.asarray(val.real).tolist(), "imag": np.asarray(val.imag).tolist()}
                else:
                    out[f.name] = np.asarray(val).tolist()
            elif isinstance(val, np.generic):
                out[f.name] = val.item()
            else:
                out[f.name] = val
        return out

    def to_json(self, indent: int | None = 2) -> str:
        return json.dumps(self.to_dict(), indent=indent)

    @classmethod
    def from_dict(cls, d: dict) -> DosimetryResult:
        """Reconstruct a DosimetryResult from a dict (inverse of to_dict).

        Complex arrays serialized as {"real": [...], "imag": [...]} are
        reconstructed as complex numpy arrays. Real lists become float64
        arrays. Scalar fields are passed through.
        """
        # Fields that are numpy arrays
        _array_fields = {
            "sab",
            "sab_averaged",
            "Q",
            "eigenvalues",
            "x_star",
            "sinc",
            "sinc_averaged",
            "sab_1cm2_averaged",
        }
        _field_names = {f.name for f in fields(cls)}

        kwargs: dict = {}
        for key, val in d.items():
            if key not in _field_names:
                continue
            if key in _array_fields:
                if val is None:
                    kwargs[key] = None
                elif isinstance(val, dict) and "real" in val and "imag" in val:
                    kwargs[key] = np.array(val["real"]) + 1j * np.array(val["imag"])
                elif isinstance(val, list):
                    kwargs[key] = np.array(val, dtype=np.float64)
                else:
                    kwargs[key] = val
            elif key == "corrections":
                kwargs[key] = tuple(val) if isinstance(val, list) else val
            else:
                kwargs[key] = val

        return cls(**kwargs)

    @classmethod
    def from_json(cls, s: str) -> DosimetryResult:
        """Reconstruct from a JSON string (inverse of to_json)."""
        return cls.from_dict(json.loads(s))

    @property
    def peak_sab(self) -> float:
        """Peak per-triangle S_ab [W/m^2]."""
        if self.sab.size == 0:
            raise ValueError("peak_sab is undefined for empty sab")
        return float(np.max(self.sab))

    @property
    def peak_triangle_index(self) -> int:
        """Triangle index with maximum S_ab."""
        if self.sab.size == 0:
            raise ValueError("peak_triangle_index is undefined for empty sab")
        return int(np.argmax(self.sab))

    @property
    def mean_sab(self) -> float:
        """Mean per-triangle S_ab [W/m^2]."""
        return float(np.mean(self.sab))

    @property
    def peak_sab_averaged(self) -> float | None:
        """Peak spatially averaged S_ab [W/m^2], or None if not computed."""
        if self.sab_averaged is None:
            return None
        if self.sab_averaged.size == 0:
            raise ValueError("peak_sab_averaged is undefined for empty sab_averaged")
        return float(np.max(self.sab_averaged))

    @property
    def compliant_sab(self) -> bool | None:
        """ICNIRP compliance: peak spatially averaged S_ab <= limit.

        Returns None if freq_hz is not set or outside the ICNIRP 2020
        range (>6 GHz to 300 GHz).
        """
        peak = self.peak_sab_averaged
        if peak is None or self.freq_hz is None:
            return None
        from aegis.compliance import ExposureScenario, icnirp_limits

        try:
            lim = icnirp_limits(ExposureScenario.GENERAL_PUBLIC, self.freq_hz)
        except ValueError:
            return None
        if lim.sab_4cm2 is None:
            return None
        return peak <= lim.sab_4cm2

    @property
    def compliant_sar(self) -> bool | None:
        """ICNIRP compliance for whole-body SAR: <= limit.

        Returns None if SAR was not computed.
        """
        if self.sar_wb is None:
            return None
        from aegis.compliance import ICNIRP_2020

        return self.sar_wb <= ICNIRP_2020.sar_wb

    def scale(self, factor: float) -> DosimetryResult:
        """Return a new result with all power quantities scaled by ``factor``.

        S_ab is linear in transmit power for all fidelity levels (0-8).
        This enables parameter sweeps: compute once at a reference power,
        then scale to explore the compliance boundary.

        Coherent-specific fields (Q, eigenvalues) scale with ``factor`` too,
        since Q ~ P and eigenvalues are eigenvalues of Q. The precoder
        x_star is not scaled (it encodes direction, not magnitude).

        Parameters
        ----------
        factor : float
            Multiplicative scaling factor. Must be non-negative.
        """
        if not np.isfinite(factor):
            raise ValueError("scale factor must be finite (no NaN or inf)")
        if factor < 0:
            raise ValueError("scale factor must be non-negative")
        return DosimetryResult(
            sab=self.sab * factor,
            p_abs=self.p_abs * factor,
            fidelity_level=self.fidelity_level,
            sab_averaged=self.sab_averaged * factor if self.sab_averaged is not None else None,
            sar_wb=self.sar_wb * factor if self.sar_wb is not None else None,
            mode=self.mode,
            corrections=self.corrections,
            Q=self.Q * factor if self.Q is not None else None,
            rho=self.rho,
            eigenvalues=self.eigenvalues * factor if self.eigenvalues is not None else None,
            x_star=self.x_star,
            sinc=self.sinc * factor if self.sinc is not None else None,
            sinc_averaged=self.sinc_averaged * factor if self.sinc_averaged is not None else None,
            sab_1cm2_averaged=self.sab_1cm2_averaged * factor if self.sab_1cm2_averaged is not None else None,
            freq_hz=self.freq_hz,
        )

    def compliance_kwargs(self, *, body=None) -> dict:
        """Extract all compliance quantities as kwargs for evaluate_compliance.

        Parameters
        ----------
        body : BodyMesh or None
            If provided, computes sinc_whole_body (area-weighted mean S_inc).
        """
        peak_4 = self.peak_sab_averaged
        if peak_4 is None and self.sab.size > 0:
            peak_4 = self.peak_sab

        peak_1 = (
            float(np.max(self.sab_1cm2_averaged))
            if self.sab_1cm2_averaged is not None and self.sab_1cm2_averaged.size > 0
            else None
        )

        sinc_peak = (
            float(np.max(self.sinc_averaged))
            if self.sinc_averaged is not None and self.sinc_averaged.size > 0
            else None
        )
        if sinc_peak is None and self.sinc is not None and self.sinc.size > 0:
            sinc_peak = float(np.max(self.sinc))

        sinc_wb = None
        if body is not None and self.sinc is not None and self.sinc.size > 0:
            area_sum = np.sum(body.areas)
            if area_sum > 0:
                sinc_wb = float(np.sum(self.sinc * body.areas) / area_sum)

        return {
            "sab_4cm2": peak_4,
            "sab_1cm2": peak_1,
            "sar_wb": self.sar_wb,
            "sinc_local": sinc_peak,
            "sinc_whole_body": sinc_wb,
        }

    def evaluate_compliance(self, scenario=None):
        """Run a full ICNIRP 2020 compliance evaluation on this result."""
        from aegis.compliance import ExposureScenario as _ES
        from aegis.compliance import evaluate_compliance as _eval

        if self.freq_hz is None:
            raise ValueError("freq_hz must be set on DosimetryResult for compliance evaluation")

        if scenario is None:
            scenario = _ES.GENERAL_PUBLIC

        return _eval(freq_hz=self.freq_hz, scenario=scenario, **self.compliance_kwargs())

    @staticmethod
    def compare(results: dict[str, DosimetryResult]) -> dict:
        """Compare multiple dosimetry results (e.g. across fidelity levels).

        Produces a summary dict with per-result metrics and pairwise relative
        errors. Useful for fidelity level convergence studies.

        Parameters
        ----------
        results : dict mapping label -> DosimetryResult
            At least two results required. Labels can be anything (e.g.
            "level2", "level3", "spatial+fresnel+pol").

        Returns
        -------
        dict with keys:
            labels : list of result labels
            peak_sab : dict of label -> peak S_ab [W/m^2]
            p_abs : dict of label -> total absorbed power [W]
            peak_sab_averaged : dict of label -> peak averaged S_ab or None
            relative_error : dict of (label_i, label_j) -> relative error in peak S_ab
            rmse : dict of (label_i, label_j) -> RMSE of per-triangle S_ab
            max_abs_error : dict of (label_i, label_j) -> max absolute error
        """
        if len(results) < 2:
            raise ValueError("compare() requires at least 2 results")

        labels = list(results.keys())
        peak_sab = {}
        p_abs = {}
        peak_sab_avg = {}

        for label, r in results.items():
            peak_sab[label] = r.peak_sab if r.sab.size > 0 else 0.0
            p_abs[label] = r.p_abs
            peak_sab_avg[label] = r.peak_sab_averaged

        rel_err = {}
        rmse = {}
        max_abs = {}
        for i, li in enumerate(labels):
            for j, lj in enumerate(labels):
                if j <= i:
                    continue
                ri = results[li]
                rj = results[lj]
                if ri.sab.shape != rj.sab.shape:
                    continue
                diff = ri.sab - rj.sab
                pair = (li, lj)
                ref = max(peak_sab[li], peak_sab[lj], NUMERICAL_FLOOR)
                rel_err[pair] = float(np.abs(peak_sab[li] - peak_sab[lj]) / ref)
                rmse[pair] = float(np.sqrt(np.mean(diff**2)))
                max_abs[pair] = float(np.max(np.abs(diff)))

        return {
            "labels": labels,
            "peak_sab": peak_sab,
            "p_abs": p_abs,
            "peak_sab_averaged": peak_sab_avg,
            "relative_error": rel_err,
            "rmse": rmse,
            "max_abs_error": max_abs,
        }

    def show(
        self,
        body: BodyMesh,
        *,
        averaged: bool = False,
        **kwargs,
    ) -> Any:
        """Render S_ab as a heatmap on the body mesh.

        Convenience wrapper around ``aegis.viz.plot_heatmap``. Requires
        ``aegis[viz]`` (matplotlib or plotly).

        Parameters
        ----------
        body : BodyMesh
            The body mesh used to compute this result.
        averaged : bool
            If True, plot spatially averaged S_ab instead of raw per-triangle values.
        **kwargs
            Forwarded to ``plot_heatmap`` (title, cmap, backend, show, out_path, etc.).
        """
        from aegis.viz import plot_heatmap

        sab = self.sab_averaged if (averaged and self.sab_averaged is not None) else self.sab
        return plot_heatmap(body.vertices, sab, **kwargs)

    def __repr__(self) -> str:
        parts = [
            f"DosimetryResult(level={self.fidelity_level}",
            f"p_abs={self.p_abs:.4g} W",
        ]
        if self.sab.size > 0:
            parts.append(f"peak_sab={self.peak_sab:.4g} W/m^2")
        else:
            parts.append("peak_sab=N/A (empty)")
        if self.sar_wb is not None:
            parts.append(f"sar_wb={self.sar_wb:.4g} W/kg")
        return ", ".join(parts) + ")"

peak_sab property

peak_sab: float

Peak per-triangle S_ab [W/m^2].

peak_triangle_index property

peak_triangle_index: int

Triangle index with maximum S_ab.

mean_sab property

mean_sab: float

Mean per-triangle S_ab [W/m^2].

peak_sab_averaged property

peak_sab_averaged: float | None

Peak spatially averaged S_ab [W/m^2], or None if not computed.

compliant_sab property

compliant_sab: bool | None

ICNIRP compliance: peak spatially averaged S_ab <= limit.

Returns None if freq_hz is not set or outside the ICNIRP 2020 range (>6 GHz to 300 GHz).

compliant_sar property

compliant_sar: bool | None

ICNIRP compliance for whole-body SAR: <= limit.

Returns None if SAR was not computed.

to_dict

to_dict() -> dict

Serialize fields to a JSON-friendly dict. Omits None values.

Complex arrays (Q, eigenvalues, x_star) are serialized as {"real": [...], "imag": [...]}.

Source code in src/aegis/result.py
def to_dict(self) -> dict:
    """Serialize fields to a JSON-friendly dict. Omits None values.

    Complex arrays (Q, eigenvalues, x_star) are serialized as
    {"real": [...], "imag": [...]}.
    """
    out: dict = {}
    for f in fields(self):
        val = getattr(self, f.name)
        if val is None:
            continue
        # Use hasattr check so both NumPy and JAX arrays are handled
        if hasattr(val, "tolist") and hasattr(val, "dtype"):
            if np.iscomplexobj(val):
                out[f.name] = {"real": np.asarray(val.real).tolist(), "imag": np.asarray(val.imag).tolist()}
            else:
                out[f.name] = np.asarray(val).tolist()
        elif isinstance(val, np.generic):
            out[f.name] = val.item()
        else:
            out[f.name] = val
    return out

from_dict classmethod

from_dict(d: dict) -> DosimetryResult

Reconstruct a DosimetryResult from a dict (inverse of to_dict).

Complex arrays serialized as {"real": [...], "imag": [...]} are reconstructed as complex numpy arrays. Real lists become float64 arrays. Scalar fields are passed through.

Source code in src/aegis/result.py
@classmethod
def from_dict(cls, d: dict) -> DosimetryResult:
    """Reconstruct a DosimetryResult from a dict (inverse of to_dict).

    Complex arrays serialized as {"real": [...], "imag": [...]} are
    reconstructed as complex numpy arrays. Real lists become float64
    arrays. Scalar fields are passed through.
    """
    # Fields that are numpy arrays
    _array_fields = {
        "sab",
        "sab_averaged",
        "Q",
        "eigenvalues",
        "x_star",
        "sinc",
        "sinc_averaged",
        "sab_1cm2_averaged",
    }
    _field_names = {f.name for f in fields(cls)}

    kwargs: dict = {}
    for key, val in d.items():
        if key not in _field_names:
            continue
        if key in _array_fields:
            if val is None:
                kwargs[key] = None
            elif isinstance(val, dict) and "real" in val and "imag" in val:
                kwargs[key] = np.array(val["real"]) + 1j * np.array(val["imag"])
            elif isinstance(val, list):
                kwargs[key] = np.array(val, dtype=np.float64)
            else:
                kwargs[key] = val
        elif key == "corrections":
            kwargs[key] = tuple(val) if isinstance(val, list) else val
        else:
            kwargs[key] = val

    return cls(**kwargs)

from_json classmethod

from_json(s: str) -> DosimetryResult

Reconstruct from a JSON string (inverse of to_json).

Source code in src/aegis/result.py
@classmethod
def from_json(cls, s: str) -> DosimetryResult:
    """Reconstruct from a JSON string (inverse of to_json)."""
    return cls.from_dict(json.loads(s))

scale

scale(factor: float) -> DosimetryResult

Return a new result with all power quantities scaled by factor.

S_ab is linear in transmit power for all fidelity levels (0-8). This enables parameter sweeps: compute once at a reference power, then scale to explore the compliance boundary.

Coherent-specific fields (Q, eigenvalues) scale with factor too, since Q ~ P and eigenvalues are eigenvalues of Q. The precoder x_star is not scaled (it encodes direction, not magnitude).

Parameters

factor : float Multiplicative scaling factor. Must be non-negative.

Source code in src/aegis/result.py
def scale(self, factor: float) -> DosimetryResult:
    """Return a new result with all power quantities scaled by ``factor``.

    S_ab is linear in transmit power for all fidelity levels (0-8).
    This enables parameter sweeps: compute once at a reference power,
    then scale to explore the compliance boundary.

    Coherent-specific fields (Q, eigenvalues) scale with ``factor`` too,
    since Q ~ P and eigenvalues are eigenvalues of Q. The precoder
    x_star is not scaled (it encodes direction, not magnitude).

    Parameters
    ----------
    factor : float
        Multiplicative scaling factor. Must be non-negative.
    """
    if not np.isfinite(factor):
        raise ValueError("scale factor must be finite (no NaN or inf)")
    if factor < 0:
        raise ValueError("scale factor must be non-negative")
    return DosimetryResult(
        sab=self.sab * factor,
        p_abs=self.p_abs * factor,
        fidelity_level=self.fidelity_level,
        sab_averaged=self.sab_averaged * factor if self.sab_averaged is not None else None,
        sar_wb=self.sar_wb * factor if self.sar_wb is not None else None,
        mode=self.mode,
        corrections=self.corrections,
        Q=self.Q * factor if self.Q is not None else None,
        rho=self.rho,
        eigenvalues=self.eigenvalues * factor if self.eigenvalues is not None else None,
        x_star=self.x_star,
        sinc=self.sinc * factor if self.sinc is not None else None,
        sinc_averaged=self.sinc_averaged * factor if self.sinc_averaged is not None else None,
        sab_1cm2_averaged=self.sab_1cm2_averaged * factor if self.sab_1cm2_averaged is not None else None,
        freq_hz=self.freq_hz,
    )

compliance_kwargs

compliance_kwargs(*, body=None) -> dict

Extract all compliance quantities as kwargs for evaluate_compliance.

Parameters

body : BodyMesh or None If provided, computes sinc_whole_body (area-weighted mean S_inc).

Source code in src/aegis/result.py
def compliance_kwargs(self, *, body=None) -> dict:
    """Extract all compliance quantities as kwargs for evaluate_compliance.

    Parameters
    ----------
    body : BodyMesh or None
        If provided, computes sinc_whole_body (area-weighted mean S_inc).
    """
    peak_4 = self.peak_sab_averaged
    if peak_4 is None and self.sab.size > 0:
        peak_4 = self.peak_sab

    peak_1 = (
        float(np.max(self.sab_1cm2_averaged))
        if self.sab_1cm2_averaged is not None and self.sab_1cm2_averaged.size > 0
        else None
    )

    sinc_peak = (
        float(np.max(self.sinc_averaged))
        if self.sinc_averaged is not None and self.sinc_averaged.size > 0
        else None
    )
    if sinc_peak is None and self.sinc is not None and self.sinc.size > 0:
        sinc_peak = float(np.max(self.sinc))

    sinc_wb = None
    if body is not None and self.sinc is not None and self.sinc.size > 0:
        area_sum = np.sum(body.areas)
        if area_sum > 0:
            sinc_wb = float(np.sum(self.sinc * body.areas) / area_sum)

    return {
        "sab_4cm2": peak_4,
        "sab_1cm2": peak_1,
        "sar_wb": self.sar_wb,
        "sinc_local": sinc_peak,
        "sinc_whole_body": sinc_wb,
    }

evaluate_compliance

evaluate_compliance(scenario=None)

Run a full ICNIRP 2020 compliance evaluation on this result.

Source code in src/aegis/result.py
def evaluate_compliance(self, scenario=None):
    """Run a full ICNIRP 2020 compliance evaluation on this result."""
    from aegis.compliance import ExposureScenario as _ES
    from aegis.compliance import evaluate_compliance as _eval

    if self.freq_hz is None:
        raise ValueError("freq_hz must be set on DosimetryResult for compliance evaluation")

    if scenario is None:
        scenario = _ES.GENERAL_PUBLIC

    return _eval(freq_hz=self.freq_hz, scenario=scenario, **self.compliance_kwargs())

compare staticmethod

compare(results: dict[str, DosimetryResult]) -> dict

Compare multiple dosimetry results (e.g. across fidelity levels).

Produces a summary dict with per-result metrics and pairwise relative errors. Useful for fidelity level convergence studies.

Parameters

results : dict mapping label -> DosimetryResult At least two results required. Labels can be anything (e.g. "level2", "level3", "spatial+fresnel+pol").

Returns

dict with keys: labels : list of result labels peak_sab : dict of label -> peak S_ab [W/m^2] p_abs : dict of label -> total absorbed power [W] peak_sab_averaged : dict of label -> peak averaged S_ab or None relative_error : dict of (label_i, label_j) -> relative error in peak S_ab rmse : dict of (label_i, label_j) -> RMSE of per-triangle S_ab max_abs_error : dict of (label_i, label_j) -> max absolute error

Source code in src/aegis/result.py
@staticmethod
def compare(results: dict[str, DosimetryResult]) -> dict:
    """Compare multiple dosimetry results (e.g. across fidelity levels).

    Produces a summary dict with per-result metrics and pairwise relative
    errors. Useful for fidelity level convergence studies.

    Parameters
    ----------
    results : dict mapping label -> DosimetryResult
        At least two results required. Labels can be anything (e.g.
        "level2", "level3", "spatial+fresnel+pol").

    Returns
    -------
    dict with keys:
        labels : list of result labels
        peak_sab : dict of label -> peak S_ab [W/m^2]
        p_abs : dict of label -> total absorbed power [W]
        peak_sab_averaged : dict of label -> peak averaged S_ab or None
        relative_error : dict of (label_i, label_j) -> relative error in peak S_ab
        rmse : dict of (label_i, label_j) -> RMSE of per-triangle S_ab
        max_abs_error : dict of (label_i, label_j) -> max absolute error
    """
    if len(results) < 2:
        raise ValueError("compare() requires at least 2 results")

    labels = list(results.keys())
    peak_sab = {}
    p_abs = {}
    peak_sab_avg = {}

    for label, r in results.items():
        peak_sab[label] = r.peak_sab if r.sab.size > 0 else 0.0
        p_abs[label] = r.p_abs
        peak_sab_avg[label] = r.peak_sab_averaged

    rel_err = {}
    rmse = {}
    max_abs = {}
    for i, li in enumerate(labels):
        for j, lj in enumerate(labels):
            if j <= i:
                continue
            ri = results[li]
            rj = results[lj]
            if ri.sab.shape != rj.sab.shape:
                continue
            diff = ri.sab - rj.sab
            pair = (li, lj)
            ref = max(peak_sab[li], peak_sab[lj], NUMERICAL_FLOOR)
            rel_err[pair] = float(np.abs(peak_sab[li] - peak_sab[lj]) / ref)
            rmse[pair] = float(np.sqrt(np.mean(diff**2)))
            max_abs[pair] = float(np.max(np.abs(diff)))

    return {
        "labels": labels,
        "peak_sab": peak_sab,
        "p_abs": p_abs,
        "peak_sab_averaged": peak_sab_avg,
        "relative_error": rel_err,
        "rmse": rmse,
        "max_abs_error": max_abs,
    }

show

show(body: BodyMesh, *, averaged: bool = False, **kwargs) -> Any

Render S_ab as a heatmap on the body mesh.

Convenience wrapper around aegis.viz.plot_heatmap. Requires aegis[viz] (matplotlib or plotly).

Parameters

body : BodyMesh The body mesh used to compute this result. averaged : bool If True, plot spatially averaged S_ab instead of raw per-triangle values. **kwargs Forwarded to plot_heatmap (title, cmap, backend, show, out_path, etc.).

Source code in src/aegis/result.py
def show(
    self,
    body: BodyMesh,
    *,
    averaged: bool = False,
    **kwargs,
) -> Any:
    """Render S_ab as a heatmap on the body mesh.

    Convenience wrapper around ``aegis.viz.plot_heatmap``. Requires
    ``aegis[viz]`` (matplotlib or plotly).

    Parameters
    ----------
    body : BodyMesh
        The body mesh used to compute this result.
    averaged : bool
        If True, plot spatially averaged S_ab instead of raw per-triangle values.
    **kwargs
        Forwarded to ``plot_heatmap`` (title, cmap, backend, show, out_path, etc.).
    """
    from aegis.viz import plot_heatmap

    sab = self.sab_averaged if (averaged and self.sab_averaged is not None) else self.sab
    return plot_heatmap(body.vertices, sab, **kwargs)

PropagationPaths

aegis.paths.PropagationPaths dataclass

Batch of N propagation paths arriving at the body.

Attributes

k_hat : (N, 3) unit directions of arrival psi : (N, 3) complex polarisation-amplitude vectors (V/m / sqrt(W)) element_index : (N,) originating antenna element index delay : (N,) propagation delay in seconds (optional metadata) is_los : (N,) line-of-sight flag (optional metadata) k_hat_tx : optional (N, 3) unit departure directions at the source. For a bounced path this differs from k_hat: array steering and element patterns act on the departure direction, body-side physics on arrival. None when the producer cannot supply it (consumers fall back to k_hat, exact for LOS).

Source code in src/aegis/paths.py
@dataclass(frozen=True)
class PropagationPaths:
    """Batch of N propagation paths arriving at the body.

    Attributes
    ----------
    k_hat : (N, 3) unit directions of arrival
    psi : (N, 3) complex polarisation-amplitude vectors (V/m / sqrt(W))
    element_index : (N,) originating antenna element index
    delay : (N,) propagation delay in seconds (optional metadata)
    is_los : (N,) line-of-sight flag (optional metadata)
    k_hat_tx : optional (N, 3) unit departure directions at the source. For a
        bounced path this differs from ``k_hat``: array steering and element
        patterns act on the departure direction, body-side physics on arrival.
        ``None`` when the producer cannot supply it (consumers fall back to
        ``k_hat``, exact for LOS).
    """

    k_hat: np.ndarray = field(repr=False)
    psi: np.ndarray = field(repr=False)
    element_index: np.ndarray = field(repr=False)
    delay: np.ndarray = field(repr=False)
    is_los: np.ndarray = field(repr=False)
    # True when ``psi`` carries a physically meaningful incident polarisation
    # (ray tracer, coherent channel, or an explicit polarisation in from_powers).
    # False when the polarisation was fabricated (from_powers default), in which
    # case incoherent dosimetry must treat the field as unpolarised.
    polarised: bool = field(default=False, repr=False)
    k_hat_tx: np.ndarray | None = field(default=None, repr=False)

    def __post_init__(self) -> None:
        n = self.k_hat.shape[0]
        if self.k_hat.shape != (n, 3):
            raise ValueError(f"k_hat must be (N, 3), got {self.k_hat.shape}")
        if self.psi.shape != (n, 3):
            raise ValueError(f"psi must be (N, 3), got {self.psi.shape}")
        if self.element_index.shape != (n,):
            raise ValueError(f"element_index must be (N,), got {self.element_index.shape}")
        if self.delay.shape != (n,):
            raise ValueError(f"delay must be (N,), got {self.delay.shape}")
        if self.is_los.shape != (n,):
            raise ValueError(f"is_los must be (N,), got {self.is_los.shape}")
        if self.k_hat_tx is not None and self.k_hat_tx.shape != (n, 3):
            raise ValueError(f"k_hat_tx must be (N, 3) or None, got {self.k_hat_tx.shape}")
        if n > 0 and np.any(self.element_index < 0):
            raise ValueError("element_index must be non-negative")
        if n > 0:
            norms = np.linalg.norm(self.k_hat, axis=1)
            if not np.allclose(norms, 1.0, atol=1e-5):
                worst = float(np.max(np.abs(norms - 1.0)))
                raise ValueError(f"k_hat rows must be unit vectors (max norm deviation: {worst:.2e})")

    @property
    def n_paths(self) -> int:
        return self.k_hat.shape[0]

    def __len__(self) -> int:
        return self.n_paths

    @property
    def total_power(self) -> float:
        return float(np.sum(self.power))

    def subset(self, indices: np.ndarray | Sequence[int]) -> PropagationPaths:
        """Return paths restricted to the given index array (e.g. LOS-only)."""
        idx = np.asarray(indices, dtype=np.intp)
        return PropagationPaths(
            k_hat=self.k_hat[idx],
            psi=self.psi[idx],
            element_index=self.element_index[idx],
            delay=self.delay[idx],
            is_los=self.is_los[idx],
            polarised=self.polarised,
            k_hat_tx=self.k_hat_tx[idx] if self.k_hat_tx is not None else None,
        )

    @property
    def los_paths(self) -> PropagationPaths:
        """Return only line-of-sight paths."""
        return self.subset(np.where(self.is_los)[0])

    @property
    def nlos_paths(self) -> PropagationPaths:
        """Return only non-line-of-sight paths."""
        return self.subset(np.where(~self.is_los)[0])

    @property
    def n_elements(self) -> int:
        return int(np.max(self.element_index)) + 1 if self.n_paths > 0 else 0

    @property
    def power(self) -> np.ndarray:
        """Per-path incident power density S_i = |psi_i|^2 / (2 * Z_0) [W/m^2].

        The factor 1/(2*Z_0) converts from |E|^2 to power density for a plane wave.
        """
        return np.sum(np.abs(self.psi) ** 2, axis=1) / (2 * Z_0)

    @classmethod
    def from_powers(
        cls,
        k_hat: np.ndarray,
        power: np.ndarray,
        polarisation: np.ndarray | None = None,
    ) -> PropagationPaths:
        """Construct from directions and scalar powers (incoherent use).

        Parameters
        ----------
        k_hat : (N, 3) incident directions (will be normalised)
        power : (N,) incident power density per path [W/m^2]
        polarisation : optional (3,) or (N, 3) incident E-field direction.
            When given, the field is projected onto the plane transverse to
            each ``k_hat`` and the result is flagged ``polarised=True`` so
            polarisation-aware kernels use it. Real or complex (elliptical).
            When ``None`` (default) an arbitrary perpendicular polarisation is
            fabricated and the result is flagged ``polarised=False`` so
            incoherent dosimetry treats the field as unpolarised.
        """
        k_hat = np.asarray(k_hat, dtype=np.float64)
        power = np.asarray(power, dtype=np.float64)

        if k_hat.ndim == 1:
            k_hat = k_hat[np.newaxis, :]
        if power.ndim == 0:
            power = power[np.newaxis]

        n = k_hat.shape[0]
        if power.shape != (n,):
            raise ValueError(f"power shape {power.shape} doesn't match k_hat ({n},)")

        if n > 0 and not np.all(np.isfinite(power)):
            raise ValueError("power must be finite (no NaN or inf)")
        if n > 0 and not np.all(np.isfinite(k_hat)):
            raise ValueError("k_hat must be finite (no NaN or inf)")

        # Normalise directions
        norms = np.linalg.norm(k_hat, axis=1, keepdims=True)
        if n > 0 and np.any(norms[:, 0] <= 0):
            raise ValueError("k_hat rows must have positive norm (non-zero direction)")
        k_hat = k_hat / norms

        amplitude = np.sqrt(2 * Z_0 * np.maximum(power, 0.0))

        if polarisation is None:
            # Fabricate an arbitrary perpendicular polarisation: physically
            # meaningless, so the path is flagged unpolarised.
            ref = np.zeros_like(k_hat)
            abs_k = np.abs(k_hat)
            min_axis = np.argmin(abs_k, axis=1)
            ref[np.arange(n), min_axis] = 1.0
            e_dir = np.cross(k_hat, ref)
            e_dir = e_dir / np.where(
                np.linalg.norm(e_dir, axis=1, keepdims=True) > 0,
                np.linalg.norm(e_dir, axis=1, keepdims=True),
                1.0,
            )
            psi = (amplitude[:, np.newaxis] * e_dir).astype(complex)
            polarised = False
        else:
            pol = np.asarray(polarisation, dtype=complex)
            if pol.ndim == 1:
                pol = np.broadcast_to(pol, (n, 3))
            if pol.shape != (n, 3):
                raise ValueError(f"polarisation shape {pol.shape} doesn't match k_hat ({n}, 3)")
            # Remove any longitudinal component so the field is transverse to k.
            k_dot_p = np.einsum("nj,nj->n", k_hat.astype(complex), pol)
            pol_t = pol - k_dot_p[:, np.newaxis] * k_hat
            t_norm = np.sqrt(np.sum(np.abs(pol_t) ** 2, axis=1, keepdims=True))
            if n > 0 and np.any(t_norm[:, 0] <= 0):
                raise ValueError("polarisation must have a component transverse to k_hat")
            pol_hat = pol_t / t_norm
            psi = (amplitude[:, np.newaxis] * pol_hat).astype(complex)
            polarised = True

        return cls(
            k_hat=k_hat,
            psi=psi,
            element_index=np.arange(n, dtype=np.intp),
            delay=np.zeros(n, dtype=np.float64),
            is_los=np.ones(n, dtype=bool),
            polarised=polarised,
        )

    @classmethod
    def from_spherical(
        cls,
        theta: np.ndarray,
        phi: np.ndarray,
        power: np.ndarray,
    ) -> PropagationPaths:
        """Construct from spherical arrival angles and scalar powers.

        Convenient for analytical scenarios (uniform illumination, sector
        beams, stochastic channel models) where paths are specified as
        (theta, phi) rather than Cartesian k_hat.

        Parameters
        ----------
        theta : (N,) zenith angle of arrival in radians (0 = +z)
        phi : (N,) azimuth angle of arrival in radians
        power : (N,) incident power density per path [W/m^2]
        """
        theta = np.asarray(theta, dtype=np.float64)
        phi = np.asarray(phi, dtype=np.float64)

        if theta.ndim == 0:
            theta = theta[np.newaxis]
        if phi.ndim == 0:
            phi = phi[np.newaxis]

        k_hat = np.column_stack(
            [
                np.sin(theta) * np.cos(phi),
                np.sin(theta) * np.sin(phi),
                np.cos(theta),
            ]
        )
        return cls.from_powers(k_hat=k_hat, power=np.asarray(power))

    @classmethod
    def uniform_sphere(
        cls,
        n_paths: int,
        total_power: float = 1.0,
        seed: int | None = None,
    ) -> PropagationPaths:
        """Generate paths uniformly distributed over the sphere.

        Useful for worst-case analysis, Monte Carlo integration of the
        exposure integral, and testing. Each path carries equal power
        such that the total incident power density sums to ``total_power``.

        Parameters
        ----------
        n_paths : int
            Number of paths to generate.
        total_power : float
            Total incident power density [W/m^2], distributed equally.
        seed : int or None
            Random seed for reproducibility.
        """
        if n_paths < 0:
            raise ValueError(f"n_paths must be non-negative, got {n_paths}")
        if n_paths == 0:
            return cls.from_powers(
                k_hat=np.empty((0, 3), dtype=np.float64),
                power=np.empty(0, dtype=np.float64),
            )

        rng = np.random.default_rng(seed)
        # Uniform on sphere via Gaussian normalization
        raw = rng.standard_normal((n_paths, 3))
        norms = np.linalg.norm(raw, axis=1, keepdims=True)
        norms = np.where(norms > 0, norms, 1.0)
        k_hat = raw / norms
        power = np.full(n_paths, total_power / n_paths)
        return cls.from_powers(k_hat=k_hat, power=power)

    @classmethod
    def concatenate(
        cls,
        paths_list: Sequence[PropagationPaths],
        *,
        reindex_elements: bool = True,
    ) -> PropagationPaths:
        """Concatenate multiple PropagationPaths into one.

        Parameters
        ----------
        paths_list : sequence of PropagationPaths
            Paths to concatenate. Empty entries are skipped.
        reindex_elements : bool
            If True (default), shift element_index so that each input's
            elements are disjoint. If False, keep element indices as-is
            (useful when paths already share a common antenna indexing).

        Returns
        -------
        PropagationPaths
            Combined paths with N = sum(N_i) total paths.
        """
        paths_list = [p for p in paths_list if p.n_paths > 0]
        if len(paths_list) == 0:
            empty = np.empty((0, 3), dtype=np.float64)
            empty_1d = np.empty(0, dtype=np.float64)
            return cls(
                k_hat=empty,
                psi=empty.astype(complex),
                element_index=np.empty(0, dtype=np.intp),
                delay=empty_1d,
                is_los=np.empty(0, dtype=bool),
            )
        if len(paths_list) == 1:
            return paths_list[0]

        k_hats = [p.k_hat for p in paths_list]
        psis = [p.psi for p in paths_list]
        delays = [p.delay for p in paths_list]
        is_loss = [p.is_los for p in paths_list]

        if reindex_elements:
            elem_indices = []
            offset = 0
            for p in paths_list:
                elem_indices.append(p.element_index + offset)
                offset += p.n_elements
        else:
            elem_indices = [p.element_index for p in paths_list]

        return cls(
            k_hat=np.concatenate(k_hats, axis=0),
            psi=np.concatenate(psis, axis=0),
            element_index=np.concatenate(elem_indices, axis=0),
            delay=np.concatenate(delays, axis=0),
            is_los=np.concatenate(is_loss, axis=0),
            # Only treat the result as polarised if every input was; a mix would
            # leave fabricated polarisations alongside real ones.
            polarised=all(p.polarised for p in paths_list),
            # Departure directions survive only if every input carries them.
            k_hat_tx=(
                np.concatenate([p.k_hat_tx for p in paths_list], axis=0)
                if all(p.k_hat_tx is not None for p in paths_list)
                else None
            ),
        )

    def to_dict(self) -> dict:
        """Serialize to a JSON-friendly dict.

        Complex arrays (psi) are stored as {"real": [...], "imag": [...]}.
        """
        d = {
            "k_hat": self.k_hat.tolist(),
            "psi": {
                "real": self.psi.real.tolist(),
                "imag": self.psi.imag.tolist(),
            },
            "element_index": self.element_index.tolist(),
            "delay": self.delay.tolist(),
            "is_los": self.is_los.tolist(),
            "polarised": bool(self.polarised),
        }
        if self.k_hat_tx is not None:
            d["k_hat_tx"] = self.k_hat_tx.tolist()
        return d

    @classmethod
    def from_dict(cls, d: dict) -> PropagationPaths:
        """Reconstruct from a dict (inverse of to_dict)."""
        psi_raw = d["psi"]
        if isinstance(psi_raw, dict) and "real" in psi_raw:
            psi = np.array(psi_raw["real"]) + 1j * np.array(psi_raw["imag"])
        else:
            psi = np.array(psi_raw, dtype=complex)
        k_hat = np.array(d["k_hat"], dtype=np.float64)
        # np.array([]) gives shape (0,) for empty lists; restore the (N, 3) shape.
        if k_hat.ndim == 1 and k_hat.shape[0] == 0:
            k_hat = k_hat.reshape(0, 3)
        if psi.ndim == 1 and psi.shape[0] == 0:
            psi = psi.reshape(0, 3)
        k_hat_tx = d.get("k_hat_tx")
        return cls(
            k_hat=k_hat,
            psi=psi,
            element_index=np.array(d["element_index"], dtype=np.intp),
            delay=np.array(d["delay"], dtype=np.float64),
            is_los=np.array(d["is_los"], dtype=bool),
            polarised=bool(d.get("polarised", False)),
            k_hat_tx=np.array(k_hat_tx, dtype=np.float64) if k_hat_tx is not None else None,
        )

    def __repr__(self) -> str:
        return f"PropagationPaths(n_paths={self.n_paths}, n_elements={self.n_elements})"

los_paths property

los_paths: PropagationPaths

Return only line-of-sight paths.

nlos_paths property

nlos_paths: PropagationPaths

Return only non-line-of-sight paths.

power property

power: ndarray

Per-path incident power density S_i = |psi_i|^2 / (2 * Z_0) [W/m^2].

The factor 1/(2*Z_0) converts from |E|^2 to power density for a plane wave.

subset

subset(indices: ndarray | Sequence[int]) -> PropagationPaths

Return paths restricted to the given index array (e.g. LOS-only).

Source code in src/aegis/paths.py
def subset(self, indices: np.ndarray | Sequence[int]) -> PropagationPaths:
    """Return paths restricted to the given index array (e.g. LOS-only)."""
    idx = np.asarray(indices, dtype=np.intp)
    return PropagationPaths(
        k_hat=self.k_hat[idx],
        psi=self.psi[idx],
        element_index=self.element_index[idx],
        delay=self.delay[idx],
        is_los=self.is_los[idx],
        polarised=self.polarised,
        k_hat_tx=self.k_hat_tx[idx] if self.k_hat_tx is not None else None,
    )

from_powers classmethod

from_powers(k_hat: ndarray, power: ndarray, polarisation: ndarray | None = None) -> PropagationPaths

Construct from directions and scalar powers (incoherent use).

Parameters

k_hat : (N, 3) incident directions (will be normalised) power : (N,) incident power density per path [W/m^2] polarisation : optional (3,) or (N, 3) incident E-field direction. When given, the field is projected onto the plane transverse to each k_hat and the result is flagged polarised=True so polarisation-aware kernels use it. Real or complex (elliptical). When None (default) an arbitrary perpendicular polarisation is fabricated and the result is flagged polarised=False so incoherent dosimetry treats the field as unpolarised.

Source code in src/aegis/paths.py
@classmethod
def from_powers(
    cls,
    k_hat: np.ndarray,
    power: np.ndarray,
    polarisation: np.ndarray | None = None,
) -> PropagationPaths:
    """Construct from directions and scalar powers (incoherent use).

    Parameters
    ----------
    k_hat : (N, 3) incident directions (will be normalised)
    power : (N,) incident power density per path [W/m^2]
    polarisation : optional (3,) or (N, 3) incident E-field direction.
        When given, the field is projected onto the plane transverse to
        each ``k_hat`` and the result is flagged ``polarised=True`` so
        polarisation-aware kernels use it. Real or complex (elliptical).
        When ``None`` (default) an arbitrary perpendicular polarisation is
        fabricated and the result is flagged ``polarised=False`` so
        incoherent dosimetry treats the field as unpolarised.
    """
    k_hat = np.asarray(k_hat, dtype=np.float64)
    power = np.asarray(power, dtype=np.float64)

    if k_hat.ndim == 1:
        k_hat = k_hat[np.newaxis, :]
    if power.ndim == 0:
        power = power[np.newaxis]

    n = k_hat.shape[0]
    if power.shape != (n,):
        raise ValueError(f"power shape {power.shape} doesn't match k_hat ({n},)")

    if n > 0 and not np.all(np.isfinite(power)):
        raise ValueError("power must be finite (no NaN or inf)")
    if n > 0 and not np.all(np.isfinite(k_hat)):
        raise ValueError("k_hat must be finite (no NaN or inf)")

    # Normalise directions
    norms = np.linalg.norm(k_hat, axis=1, keepdims=True)
    if n > 0 and np.any(norms[:, 0] <= 0):
        raise ValueError("k_hat rows must have positive norm (non-zero direction)")
    k_hat = k_hat / norms

    amplitude = np.sqrt(2 * Z_0 * np.maximum(power, 0.0))

    if polarisation is None:
        # Fabricate an arbitrary perpendicular polarisation: physically
        # meaningless, so the path is flagged unpolarised.
        ref = np.zeros_like(k_hat)
        abs_k = np.abs(k_hat)
        min_axis = np.argmin(abs_k, axis=1)
        ref[np.arange(n), min_axis] = 1.0
        e_dir = np.cross(k_hat, ref)
        e_dir = e_dir / np.where(
            np.linalg.norm(e_dir, axis=1, keepdims=True) > 0,
            np.linalg.norm(e_dir, axis=1, keepdims=True),
            1.0,
        )
        psi = (amplitude[:, np.newaxis] * e_dir).astype(complex)
        polarised = False
    else:
        pol = np.asarray(polarisation, dtype=complex)
        if pol.ndim == 1:
            pol = np.broadcast_to(pol, (n, 3))
        if pol.shape != (n, 3):
            raise ValueError(f"polarisation shape {pol.shape} doesn't match k_hat ({n}, 3)")
        # Remove any longitudinal component so the field is transverse to k.
        k_dot_p = np.einsum("nj,nj->n", k_hat.astype(complex), pol)
        pol_t = pol - k_dot_p[:, np.newaxis] * k_hat
        t_norm = np.sqrt(np.sum(np.abs(pol_t) ** 2, axis=1, keepdims=True))
        if n > 0 and np.any(t_norm[:, 0] <= 0):
            raise ValueError("polarisation must have a component transverse to k_hat")
        pol_hat = pol_t / t_norm
        psi = (amplitude[:, np.newaxis] * pol_hat).astype(complex)
        polarised = True

    return cls(
        k_hat=k_hat,
        psi=psi,
        element_index=np.arange(n, dtype=np.intp),
        delay=np.zeros(n, dtype=np.float64),
        is_los=np.ones(n, dtype=bool),
        polarised=polarised,
    )

from_spherical classmethod

from_spherical(theta: ndarray, phi: ndarray, power: ndarray) -> PropagationPaths

Construct from spherical arrival angles and scalar powers.

Convenient for analytical scenarios (uniform illumination, sector beams, stochastic channel models) where paths are specified as (theta, phi) rather than Cartesian k_hat.

Parameters

theta : (N,) zenith angle of arrival in radians (0 = +z) phi : (N,) azimuth angle of arrival in radians power : (N,) incident power density per path [W/m^2]

Source code in src/aegis/paths.py
@classmethod
def from_spherical(
    cls,
    theta: np.ndarray,
    phi: np.ndarray,
    power: np.ndarray,
) -> PropagationPaths:
    """Construct from spherical arrival angles and scalar powers.

    Convenient for analytical scenarios (uniform illumination, sector
    beams, stochastic channel models) where paths are specified as
    (theta, phi) rather than Cartesian k_hat.

    Parameters
    ----------
    theta : (N,) zenith angle of arrival in radians (0 = +z)
    phi : (N,) azimuth angle of arrival in radians
    power : (N,) incident power density per path [W/m^2]
    """
    theta = np.asarray(theta, dtype=np.float64)
    phi = np.asarray(phi, dtype=np.float64)

    if theta.ndim == 0:
        theta = theta[np.newaxis]
    if phi.ndim == 0:
        phi = phi[np.newaxis]

    k_hat = np.column_stack(
        [
            np.sin(theta) * np.cos(phi),
            np.sin(theta) * np.sin(phi),
            np.cos(theta),
        ]
    )
    return cls.from_powers(k_hat=k_hat, power=np.asarray(power))

uniform_sphere classmethod

uniform_sphere(n_paths: int, total_power: float = 1.0, seed: int | None = None) -> PropagationPaths

Generate paths uniformly distributed over the sphere.

Useful for worst-case analysis, Monte Carlo integration of the exposure integral, and testing. Each path carries equal power such that the total incident power density sums to total_power.

Parameters

n_paths : int Number of paths to generate. total_power : float Total incident power density [W/m^2], distributed equally. seed : int or None Random seed for reproducibility.

Source code in src/aegis/paths.py
@classmethod
def uniform_sphere(
    cls,
    n_paths: int,
    total_power: float = 1.0,
    seed: int | None = None,
) -> PropagationPaths:
    """Generate paths uniformly distributed over the sphere.

    Useful for worst-case analysis, Monte Carlo integration of the
    exposure integral, and testing. Each path carries equal power
    such that the total incident power density sums to ``total_power``.

    Parameters
    ----------
    n_paths : int
        Number of paths to generate.
    total_power : float
        Total incident power density [W/m^2], distributed equally.
    seed : int or None
        Random seed for reproducibility.
    """
    if n_paths < 0:
        raise ValueError(f"n_paths must be non-negative, got {n_paths}")
    if n_paths == 0:
        return cls.from_powers(
            k_hat=np.empty((0, 3), dtype=np.float64),
            power=np.empty(0, dtype=np.float64),
        )

    rng = np.random.default_rng(seed)
    # Uniform on sphere via Gaussian normalization
    raw = rng.standard_normal((n_paths, 3))
    norms = np.linalg.norm(raw, axis=1, keepdims=True)
    norms = np.where(norms > 0, norms, 1.0)
    k_hat = raw / norms
    power = np.full(n_paths, total_power / n_paths)
    return cls.from_powers(k_hat=k_hat, power=power)

concatenate classmethod

concatenate(paths_list: Sequence[PropagationPaths], *, reindex_elements: bool = True) -> PropagationPaths

Concatenate multiple PropagationPaths into one.

Parameters

paths_list : sequence of PropagationPaths Paths to concatenate. Empty entries are skipped. reindex_elements : bool If True (default), shift element_index so that each input's elements are disjoint. If False, keep element indices as-is (useful when paths already share a common antenna indexing).

Returns

PropagationPaths Combined paths with N = sum(N_i) total paths.

Source code in src/aegis/paths.py
@classmethod
def concatenate(
    cls,
    paths_list: Sequence[PropagationPaths],
    *,
    reindex_elements: bool = True,
) -> PropagationPaths:
    """Concatenate multiple PropagationPaths into one.

    Parameters
    ----------
    paths_list : sequence of PropagationPaths
        Paths to concatenate. Empty entries are skipped.
    reindex_elements : bool
        If True (default), shift element_index so that each input's
        elements are disjoint. If False, keep element indices as-is
        (useful when paths already share a common antenna indexing).

    Returns
    -------
    PropagationPaths
        Combined paths with N = sum(N_i) total paths.
    """
    paths_list = [p for p in paths_list if p.n_paths > 0]
    if len(paths_list) == 0:
        empty = np.empty((0, 3), dtype=np.float64)
        empty_1d = np.empty(0, dtype=np.float64)
        return cls(
            k_hat=empty,
            psi=empty.astype(complex),
            element_index=np.empty(0, dtype=np.intp),
            delay=empty_1d,
            is_los=np.empty(0, dtype=bool),
        )
    if len(paths_list) == 1:
        return paths_list[0]

    k_hats = [p.k_hat for p in paths_list]
    psis = [p.psi for p in paths_list]
    delays = [p.delay for p in paths_list]
    is_loss = [p.is_los for p in paths_list]

    if reindex_elements:
        elem_indices = []
        offset = 0
        for p in paths_list:
            elem_indices.append(p.element_index + offset)
            offset += p.n_elements
    else:
        elem_indices = [p.element_index for p in paths_list]

    return cls(
        k_hat=np.concatenate(k_hats, axis=0),
        psi=np.concatenate(psis, axis=0),
        element_index=np.concatenate(elem_indices, axis=0),
        delay=np.concatenate(delays, axis=0),
        is_los=np.concatenate(is_loss, axis=0),
        # Only treat the result as polarised if every input was; a mix would
        # leave fabricated polarisations alongside real ones.
        polarised=all(p.polarised for p in paths_list),
        # Departure directions survive only if every input carries them.
        k_hat_tx=(
            np.concatenate([p.k_hat_tx for p in paths_list], axis=0)
            if all(p.k_hat_tx is not None for p in paths_list)
            else None
        ),
    )

to_dict

to_dict() -> dict

Serialize to a JSON-friendly dict.

Complex arrays (psi) are stored as {"real": [...], "imag": [...]}.

Source code in src/aegis/paths.py
def to_dict(self) -> dict:
    """Serialize to a JSON-friendly dict.

    Complex arrays (psi) are stored as {"real": [...], "imag": [...]}.
    """
    d = {
        "k_hat": self.k_hat.tolist(),
        "psi": {
            "real": self.psi.real.tolist(),
            "imag": self.psi.imag.tolist(),
        },
        "element_index": self.element_index.tolist(),
        "delay": self.delay.tolist(),
        "is_los": self.is_los.tolist(),
        "polarised": bool(self.polarised),
    }
    if self.k_hat_tx is not None:
        d["k_hat_tx"] = self.k_hat_tx.tolist()
    return d

from_dict classmethod

from_dict(d: dict) -> PropagationPaths

Reconstruct from a dict (inverse of to_dict).

Source code in src/aegis/paths.py
@classmethod
def from_dict(cls, d: dict) -> PropagationPaths:
    """Reconstruct from a dict (inverse of to_dict)."""
    psi_raw = d["psi"]
    if isinstance(psi_raw, dict) and "real" in psi_raw:
        psi = np.array(psi_raw["real"]) + 1j * np.array(psi_raw["imag"])
    else:
        psi = np.array(psi_raw, dtype=complex)
    k_hat = np.array(d["k_hat"], dtype=np.float64)
    # np.array([]) gives shape (0,) for empty lists; restore the (N, 3) shape.
    if k_hat.ndim == 1 and k_hat.shape[0] == 0:
        k_hat = k_hat.reshape(0, 3)
    if psi.ndim == 1 and psi.shape[0] == 0:
        psi = psi.reshape(0, 3)
    k_hat_tx = d.get("k_hat_tx")
    return cls(
        k_hat=k_hat,
        psi=psi,
        element_index=np.array(d["element_index"], dtype=np.intp),
        delay=np.array(d["delay"], dtype=np.float64),
        is_los=np.array(d["is_los"], dtype=bool),
        polarised=bool(d.get("polarised", False)),
        k_hat_tx=np.array(k_hat_tx, dtype=np.float64) if k_hat_tx is not None else None,
    )

Precoder

aegis.precoder.Precoder dataclass

Precoding vector for coherent MIMO transmission.

Attributes

x : (M_ant,) complex precoding vector. ||x||^2 = total transmit power P.

Source code in src/aegis/precoder.py
@dataclass(frozen=True)
class Precoder:
    """Precoding vector for coherent MIMO transmission.

    Attributes
    ----------
    x : (M_ant,) complex precoding vector. ||x||^2 = total transmit power P.
    """

    x: np.ndarray = field(repr=False)

    def __post_init__(self) -> None:
        if self.x.ndim != 1:
            raise ValueError(f"x must be 1D, got shape {self.x.shape}")

    @property
    def n_elements(self) -> int:
        return self.x.shape[0]

    @property
    def power(self) -> float:
        """Total transmit power ||x||^2 [W]."""
        return float(np.real(np.vdot(self.x, self.x)))

    @classmethod
    def mrt(cls, h: np.ndarray, P: float = 1.0) -> Precoder:
        """Maximum ratio transmission: x = sqrt(P) * h* / ||h||.

        Parameters
        ----------
        h : (M_ant,)
            UE channel vector.
        P : float
            Total transmit power [W].
        """
        h = np.asarray(h, dtype=complex)
        h_conj = h.conj()
        norm = np.sqrt(float(np.real(np.vdot(h_conj, h_conj))))
        if norm < NUMERICAL_FLOOR:
            x = np.zeros(h.shape, dtype=h.dtype)
            x[0] = np.sqrt(P)
            return cls(x=x)
        return cls(x=np.sqrt(P) * h_conj / norm)

    @classmethod
    def ecbf(
        cls,
        h: np.ndarray,
        Q: np.ndarray,
        P_abs_max: float,
        P: float = 1.0,
    ) -> Precoder:
        """Exposure-constrained beamforming via QCQP.

        Parameters
        ----------
        h : (M_ant,)
            UE channel vector.
        Q : (M_ant, M_ant)
            Exposure operator.
        P_abs_max : float
            Maximum allowed absorbed power [W].
        P : float
            Total transmit power [W].
        """
        from aegis.coherent.ecbf import solve_ecbf

        x = solve_ecbf(h, Q, P_abs_max, P)
        return cls(x=x)

    def __repr__(self) -> str:
        return f"Precoder(M={self.n_elements}, P={self.power:.4g} W)"

power property

power: float

Total transmit power ||x||^2 [W].

mrt classmethod

mrt(h: ndarray, P: float = 1.0) -> Precoder

Maximum ratio transmission: x = sqrt(P) * h* / ||h||.

Parameters

h : (M_ant,) UE channel vector. P : float Total transmit power [W].

Source code in src/aegis/precoder.py
@classmethod
def mrt(cls, h: np.ndarray, P: float = 1.0) -> Precoder:
    """Maximum ratio transmission: x = sqrt(P) * h* / ||h||.

    Parameters
    ----------
    h : (M_ant,)
        UE channel vector.
    P : float
        Total transmit power [W].
    """
    h = np.asarray(h, dtype=complex)
    h_conj = h.conj()
    norm = np.sqrt(float(np.real(np.vdot(h_conj, h_conj))))
    if norm < NUMERICAL_FLOOR:
        x = np.zeros(h.shape, dtype=h.dtype)
        x[0] = np.sqrt(P)
        return cls(x=x)
    return cls(x=np.sqrt(P) * h_conj / norm)

ecbf classmethod

ecbf(h: ndarray, Q: ndarray, P_abs_max: float, P: float = 1.0) -> Precoder

Exposure-constrained beamforming via QCQP.

Parameters

h : (M_ant,) UE channel vector. Q : (M_ant, M_ant) Exposure operator. P_abs_max : float Maximum allowed absorbed power [W]. P : float Total transmit power [W].

Source code in src/aegis/precoder.py
@classmethod
def ecbf(
    cls,
    h: np.ndarray,
    Q: np.ndarray,
    P_abs_max: float,
    P: float = 1.0,
) -> Precoder:
    """Exposure-constrained beamforming via QCQP.

    Parameters
    ----------
    h : (M_ant,)
        UE channel vector.
    Q : (M_ant, M_ant)
        Exposure operator.
    P_abs_max : float
        Maximum allowed absorbed power [W].
    P : float
        Total transmit power [W].
    """
    from aegis.coherent.ecbf import solve_ecbf

    x = solve_ecbf(h, Q, P_abs_max, P)
    return cls(x=x)

Tissue

TissueModel

aegis.tissue.dielectric.TissueModel dataclass

Tissue electromagnetic properties at a specific frequency.

Parameters

name Human-readable label (e.g. "Skin 28 GHz"). eps_r Relative permittivity. sigma Conductivity (S/m). freq_hz Frequency (Hz).

Source code in src/aegis/tissue/dielectric.py
@dataclass(frozen=True)
class TissueModel:
    """Tissue electromagnetic properties at a specific frequency.

    Parameters
    ----------
    name
        Human-readable label (e.g. "Skin 28 GHz").
    eps_r
        Relative permittivity.
    sigma
        Conductivity (S/m).
    freq_hz
        Frequency (Hz).
    """

    name: str
    eps_r: float
    sigma: float
    freq_hz: float

    def __post_init__(self) -> None:
        if self.freq_hz <= 0:
            raise ValueError(f"Frequency must be positive, got {self.freq_hz} Hz")

    @property
    def n_complex(self) -> complex:
        """Complex refractive index."""
        return _n_complex(self.eps_r, self.sigma, self.freq_hz)

    @property
    def T0(self) -> float:
        """Normal-incidence power transmission coefficient."""
        return _T0_from_n(self.n_complex)

    @classmethod
    def from_params(cls, name: str, eps_r: float, sigma: float, freq_hz: float) -> TissueModel:
        """Construct from explicit electromagnetic parameters."""
        return cls(name=name, eps_r=eps_r, sigma=sigma, freq_hz=freq_hz)

    def plot_spectrum(
        self,
        freq_min_hz: float = 1e9,
        freq_max_hz: float = 100e9,
        *,
        n_points: int = 200,
    ):
        """Plot permittivity and conductivity vs frequency for this tissue.

        Uses the IT'IS Cole-Cole database. The tissue name (first word of
        ``self.name``) is used as the database lookup key.

        Requires ``aegis[viz]`` (matplotlib).

        Parameters
        ----------
        freq_min_hz : float
            Lower frequency bound [Hz].
        freq_max_hz : float
            Upper frequency bound [Hz].
        n_points : int
            Number of frequency samples.
        """
        from aegis.viz import plot_tissue_spectrum

        tissue_name = self.name.split()[0]
        return plot_tissue_spectrum(tissue_name, freq_min_hz, freq_max_hz, n_points=n_points)

    @classmethod
    def from_database(cls, tissue_name: str, freq_hz: float, db_path: Path | None = None) -> TissueModel:
        """Construct from the IT'IS v5.0 database using the Cole-Cole model.

        Parameters
        ----------
        tissue_name
            Tissue name in the database (e.g. "Skin", "Muscle").
        freq_hz
            Frequency (Hz).
        db_path
            Explicit path to itis_v5.db. Auto-detected if None.
        """
        from aegis.tissue.database import get_tissue_properties

        key = (tissue_name, float(freq_hz))
        if key not in _tissue_db_props_cache:
            _tissue_db_props_cache[key] = get_tissue_properties(tissue_name, freq_hz, db_path=db_path)
        props = _tissue_db_props_cache[key]
        return cls(
            name=f"{tissue_name} {freq_hz / 1e9:.0f} GHz",
            eps_r=props["eps_r"],
            sigma=props["sigma"],
            freq_hz=freq_hz,
        )

n_complex property

n_complex: complex

Complex refractive index.

T0 property

T0: float

Normal-incidence power transmission coefficient.

from_params classmethod

from_params(name: str, eps_r: float, sigma: float, freq_hz: float) -> TissueModel

Construct from explicit electromagnetic parameters.

Source code in src/aegis/tissue/dielectric.py
@classmethod
def from_params(cls, name: str, eps_r: float, sigma: float, freq_hz: float) -> TissueModel:
    """Construct from explicit electromagnetic parameters."""
    return cls(name=name, eps_r=eps_r, sigma=sigma, freq_hz=freq_hz)

plot_spectrum

plot_spectrum(freq_min_hz: float = 1000000000.0, freq_max_hz: float = 100000000000.0, *, n_points: int = 200)

Plot permittivity and conductivity vs frequency for this tissue.

Uses the IT'IS Cole-Cole database. The tissue name (first word of self.name) is used as the database lookup key.

Requires aegis[viz] (matplotlib).

Parameters

freq_min_hz : float Lower frequency bound [Hz]. freq_max_hz : float Upper frequency bound [Hz]. n_points : int Number of frequency samples.

Source code in src/aegis/tissue/dielectric.py
def plot_spectrum(
    self,
    freq_min_hz: float = 1e9,
    freq_max_hz: float = 100e9,
    *,
    n_points: int = 200,
):
    """Plot permittivity and conductivity vs frequency for this tissue.

    Uses the IT'IS Cole-Cole database. The tissue name (first word of
    ``self.name``) is used as the database lookup key.

    Requires ``aegis[viz]`` (matplotlib).

    Parameters
    ----------
    freq_min_hz : float
        Lower frequency bound [Hz].
    freq_max_hz : float
        Upper frequency bound [Hz].
    n_points : int
        Number of frequency samples.
    """
    from aegis.viz import plot_tissue_spectrum

    tissue_name = self.name.split()[0]
    return plot_tissue_spectrum(tissue_name, freq_min_hz, freq_max_hz, n_points=n_points)

from_database classmethod

from_database(tissue_name: str, freq_hz: float, db_path: Path | None = None) -> TissueModel

Construct from the IT'IS v5.0 database using the Cole-Cole model.

Parameters

tissue_name Tissue name in the database (e.g. "Skin", "Muscle"). freq_hz Frequency (Hz). db_path Explicit path to itis_v5.db. Auto-detected if None.

Source code in src/aegis/tissue/dielectric.py
@classmethod
def from_database(cls, tissue_name: str, freq_hz: float, db_path: Path | None = None) -> TissueModel:
    """Construct from the IT'IS v5.0 database using the Cole-Cole model.

    Parameters
    ----------
    tissue_name
        Tissue name in the database (e.g. "Skin", "Muscle").
    freq_hz
        Frequency (Hz).
    db_path
        Explicit path to itis_v5.db. Auto-detected if None.
    """
    from aegis.tissue.database import get_tissue_properties

    key = (tissue_name, float(freq_hz))
    if key not in _tissue_db_props_cache:
        _tissue_db_props_cache[key] = get_tissue_properties(tissue_name, freq_hz, db_path=db_path)
    props = _tissue_db_props_cache[key]
    return cls(
        name=f"{tissue_name} {freq_hz / 1e9:.0f} GHz",
        eps_r=props["eps_r"],
        sigma=props["sigma"],
        freq_hz=freq_hz,
    )

Fresnel coefficients

aegis.tissue.fresnel

Fresnel power transmission coefficients for lossy dielectric half-spaces.

Numerically stable implementation using energy conservation: T = 1 - |r|^2. The square-root branch for xi is selected so Re(xi) >= 0.

n_complex

n_complex(eps_r: float, sigma: float, freq_hz: float) -> complex

Complex refractive index. Scalar-only, not JIT-traced.

Source code in src/aegis/tissue/fresnel.py
def n_complex(eps_r: float, sigma: float, freq_hz: float) -> complex:
    """Complex refractive index. Scalar-only, not JIT-traced."""
    omega = 2 * np.pi * freq_hz
    eps_complex = eps_r - 1j * sigma / (omega * EPS_0)
    n_tilde = np.sqrt(eps_complex)
    if np.real(n_tilde) < 0:
        n_tilde = -n_tilde
    return n_tilde

fresnel_transmission

fresnel_transmission(mu, n_tilde)

Fresnel power transmission for TE and TM polarizations.

Convenience wrapper with scalar support. NOT called from JIT boundaries. JIT'd kernels use _fresnel_core via _base.py::fresnel_weights instead.

Source code in src/aegis/tissue/fresnel.py
def fresnel_transmission(mu, n_tilde):
    """Fresnel power transmission for TE and TM polarizations.

    Convenience wrapper with scalar support. NOT called from JIT boundaries.
    JIT'd kernels use _fresnel_core via _base.py::fresnel_weights instead.
    """
    mu = np.asarray(mu, dtype=complex)
    scalar_input = mu.ndim == 0
    mu = np.atleast_1d(mu)

    _, _, T_s, T_p, _, _ = _fresnel_core(xp.asarray(mu), n_tilde)

    T_s = np.asarray(T_s)
    T_p = np.asarray(T_p)

    if scalar_input:
        return float(T_s[0]), float(T_p[0])
    return T_s, T_p

fresnel_reflection

fresnel_reflection(mu, n_tilde)

Fresnel amplitude reflection coefficients.

Convenience wrapper with scalar support. NOT called from JIT boundaries.

Source code in src/aegis/tissue/fresnel.py
def fresnel_reflection(mu, n_tilde):
    """Fresnel amplitude reflection coefficients.

    Convenience wrapper with scalar support. NOT called from JIT boundaries.
    """
    mu = np.asarray(mu, dtype=complex)
    scalar_input = mu.ndim == 0
    mu = np.atleast_1d(mu)

    r_s, r_p, _, _, _, _ = _fresnel_core(xp.asarray(mu), n_tilde)

    r_s = np.asarray(r_s)
    r_p = np.asarray(r_p)

    if scalar_input:
        return complex(r_s[0]), complex(r_p[0])
    return r_s, r_p

fresnel_amplitude

fresnel_amplitude(mu, n_tilde)

Fresnel amplitude transmission coefficients.

Convenience wrapper with scalar support. NOT called from JIT boundaries. JIT'd coherent kernels call _fresnel_core or use the fresnel_operator module.

Source code in src/aegis/tissue/fresnel.py
def fresnel_amplitude(mu, n_tilde):
    """Fresnel amplitude transmission coefficients.

    Convenience wrapper with scalar support. NOT called from JIT boundaries.
    JIT'd coherent kernels call _fresnel_core or use the fresnel_operator module.
    """
    mu = np.asarray(mu, dtype=complex)
    scalar_input = mu.ndim == 0
    mu = np.atleast_1d(mu)

    _, _, _, _, t_s, t_p = _fresnel_core(xp.asarray(mu), n_tilde)

    t_s = np.asarray(t_s)
    t_p = np.asarray(t_p)

    if scalar_input:
        return complex(t_s[0]), complex(t_p[0])
    return t_s, t_p

xi_from_mu

xi_from_mu(mu, n_tilde)

Normal wave-vector component in tissue. Uses xp, JIT-safe.

Source code in src/aegis/tissue/fresnel.py
def xi_from_mu(mu, n_tilde):
    """Normal wave-vector component in tissue. Uses xp, JIT-safe."""
    mu = xp.asarray(mu, dtype=complex)
    n2 = n_tilde**2
    xi = xp.sqrt(n2 - 1 + mu**2)
    xi = xp.where(xp.real(xi) < 0, -xi, xi)
    return xi

T0

T0(n_tilde: complex) -> float

Normal-incidence power transmission.

Source code in src/aegis/tissue/fresnel.py
def T0(n_tilde: complex) -> float:
    """Normal-incidence power transmission."""
    denom = abs(1 + n_tilde) ** 2
    if denom == 0:
        raise ValueError(f"Cannot compute T0: |1 + n_tilde|^2 = 0 for n_tilde={n_tilde}")
    return float(4 * np.real(n_tilde) / denom)

Cole-Cole model

aegis.tissue.cole_cole

4-pole Cole-Cole model for complex permittivity of biological tissues.

Implements the Gabriel (1996) parametric model used in the IT'IS v5.0 database. Each tissue is described by 14 parameters: ef (high-frequency permittivity), 4 x (delta, tau, alpha) dispersion poles, and static conductivity sig.

cole_cole_permittivity

cole_cole_permittivity(freq_hz: float | ndarray, params: dict) -> complex

Complex permittivity from the 4-pole Cole-Cole model.

Parameters

freq_hz Frequency in Hz. Scalar or array. params Gabriel model parameters with keys: ef, del1..del4, tau1..tau4, alf1..alf4, sig.

Returns

Complex permittivity (eps_r - j*sigma/(omega*eps_0) combined).

Source code in src/aegis/tissue/cole_cole.py
def cole_cole_permittivity(freq_hz: float | np.ndarray, params: dict) -> complex:
    """Complex permittivity from the 4-pole Cole-Cole model.

    Parameters
    ----------
    freq_hz
        Frequency in Hz. Scalar or array.
    params
        Gabriel model parameters with keys:
        ef, del1..del4, tau1..tau4, alf1..alf4, sig.

    Returns
    -------
    Complex permittivity (eps_r - j*sigma/(omega*eps_0) combined).
    """
    freq_hz = np.asarray(freq_hz)
    scalar = freq_hz.ndim == 0
    freq_hz = np.atleast_1d(freq_hz)
    omega = 2 * np.pi * freq_hz

    eps = np.full_like(omega, params["ef"], dtype=complex)

    for i in range(4):
        delta = params[f"del{i + 1}"]
        tau = params[f"tau{i + 1}"] * _TAU_UNITS[i]
        alpha = params[f"alf{i + 1}"]

        if delta != 0 and tau != 0:
            denom = 1 + (1j * omega * tau) ** (1 - alpha)
            eps += delta / denom

    if params["sig"] != 0:
        safe_omega = np.where(omega != 0, omega, 1.0)
        sig_term = 1j * params["sig"] / (safe_omega * EPS_0)
        eps -= np.where(omega != 0, sig_term, 0.0)

    if scalar:
        return complex(eps[0])
    return eps

debye_permittivity

debye_permittivity(freq_hz: float | ndarray, eps_inf: float, eps_static: float, sigma: float, tau_s: float) -> complex | np.ndarray

Single-pole Debye permittivity model.

Parameters

freq_hz : frequency in Hz (scalar or array) eps_inf : high-frequency permittivity limit eps_static : static (DC) permittivity sigma : static conductivity in S/m tau_s : relaxation time in seconds

Source code in src/aegis/tissue/cole_cole.py
def debye_permittivity(
    freq_hz: float | np.ndarray,
    eps_inf: float,
    eps_static: float,
    sigma: float,
    tau_s: float,
) -> complex | np.ndarray:
    """Single-pole Debye permittivity model.

    Parameters
    ----------
    freq_hz : frequency in Hz (scalar or array)
    eps_inf : high-frequency permittivity limit
    eps_static : static (DC) permittivity
    sigma : static conductivity in S/m
    tau_s : relaxation time in seconds
    """
    omega = 2 * np.pi * np.asarray(freq_hz, dtype=np.float64)
    eps = eps_inf + (eps_static - eps_inf) / (1 + 1j * omega * tau_s)
    if sigma != 0:
        safe_omega = np.where(omega != 0, omega, 1.0)
        sig_term = 1j * sigma / (safe_omega * EPS_0)
        eps = eps - np.where(omega != 0, sig_term, 0.0)
    return eps

IT'IS database

aegis.tissue.database

IT'IS v5.0 tissue database loader.

Reads Gabriel model parameters from the SQLite database and computes frequency-dependent tissue properties via the 4-pole Cole-Cole model.

find_database

find_database() -> Path

Locate the IT'IS v5.0 SQLite database.

Search order: 1. AEGIS_DATA_DIR environment variable 2. Repository data/ directory (aegis/data/)

Source code in src/aegis/tissue/database.py
def find_database() -> Path:
    """Locate the IT'IS v5.0 SQLite database.

    Search order:
    1. AEGIS_DATA_DIR environment variable
    2. Repository data/ directory (aegis/data/)
    """
    candidates: list[Path] = []

    env_path = os.environ.get("AEGIS_DATA_DIR")
    if env_path:
        candidates.append(Path(env_path) / "itis_v5.db")

    # Default: aegis/data/ (repo-local)
    # __file__ = src/aegis/tissue/database.py -> 4 parents to repo root
    repo_root = Path(__file__).resolve().parent.parent.parent.parent
    candidates.append(repo_root / "data" / "itis_v5.db")

    for path in candidates:
        if path.exists():
            return path

    raise FileNotFoundError("Could not find itis_v5.db. Set AEGIS_DATA_DIR or place it in data/.")

get_gabriel_params

get_gabriel_params(tissue_name: str, db_path: Path | None = None) -> dict | None

Extract Gabriel model parameters (14 doubles) from the database.

Parameters

tissue_name Tissue name as stored in the database (e.g. "Skin", "Muscle"). db_path Explicit path to itis_v5.db. Auto-detected if None.

Returns

Dict with keys: ef, del1..del4, tau1..tau4, alf1..alf4, sig. None if tissue not found.

Source code in src/aegis/tissue/database.py
def get_gabriel_params(tissue_name: str, db_path: Path | None = None) -> dict | None:
    """Extract Gabriel model parameters (14 doubles) from the database.

    Parameters
    ----------
    tissue_name
        Tissue name as stored in the database (e.g. "Skin", "Muscle").
    db_path
        Explicit path to itis_v5.db. Auto-detected if None.

    Returns
    -------
    Dict with keys: ef, del1..del4, tau1..tau4, alf1..alf4, sig.
    None if tissue not found.
    """
    if db_path is None:
        db_path = find_database()

    conn = sqlite3.connect(str(db_path))
    try:
        c = conn.cursor()

        c.execute("SELECT prop_id FROM properties WHERE name = ?", ("Gabriel Parameters",))
        row = c.fetchone()
        if row is None:
            return None
        prop_id = row[0]

        c.execute(
            """SELECT v.vals
               FROM materials m
               JOIN vectors v ON m.mat_id = v.mat_id
               WHERE m.name = ? AND v.prop_id = ?
               LIMIT 1""",
            (tissue_name, prop_id),
        )
        row = c.fetchone()
        if row is None:
            return None

        blob = row[0]
        if len(blob) < 14 * 8:
            return None

        values = struct.unpack("d" * 14, blob[: 14 * 8])
        return {
            "ef": values[0],
            "del1": values[1],
            "tau1": values[2],
            "alf1": values[3],
            "del2": values[4],
            "tau2": values[5],
            "alf2": values[6],
            "del3": values[7],
            "tau3": values[8],
            "alf3": values[9],
            "del4": values[10],
            "tau4": values[11],
            "alf4": values[12],
            "sig": values[13],
        }
    finally:
        conn.close()

get_tissue_properties

get_tissue_properties(tissue_name: str, freq_hz: float, db_path: Path | None = None) -> dict

Full tissue EM properties at a given frequency from the IT'IS database.

Parameters

tissue_name Tissue name (e.g. "Skin"). freq_hz Frequency in Hz. db_path Explicit path to itis_v5.db. Auto-detected if None.

Returns

Dict with keys: freq_hz, eps_r, sigma, m (complex refractive index), n, kappa, abs_m, T0.

Source code in src/aegis/tissue/database.py
def get_tissue_properties(tissue_name: str, freq_hz: float, db_path: Path | None = None) -> dict:
    """Full tissue EM properties at a given frequency from the IT'IS database.

    Parameters
    ----------
    tissue_name
        Tissue name (e.g. "Skin").
    freq_hz
        Frequency in Hz.
    db_path
        Explicit path to itis_v5.db. Auto-detected if None.

    Returns
    -------
    Dict with keys: freq_hz, eps_r, sigma, m (complex refractive index),
    n, kappa, abs_m, T0.
    """
    params = get_gabriel_params(tissue_name, db_path=db_path)
    if params is None:
        raise ValueError(f"Could not load Gabriel parameters for '{tissue_name}'")

    eps_complex = cole_cole_permittivity(freq_hz, params)

    # Complex refractive index
    m = cmath.sqrt(eps_complex)
    if m.real < 0:
        m = -m

    n = m.real
    kappa = -m.imag  # positive for absorption

    # Normal-incidence transmission
    T0 = 4 * n / ((1 + n) ** 2 + kappa**2)

    # Recover real eps_r and effective conductivity
    omega = 2 * np.pi * freq_hz
    eps_r = eps_complex.real
    sigma = -eps_complex.imag * omega * EPS_0

    return {
        "freq_hz": freq_hz,
        "eps_r": eps_r,
        "sigma": sigma,
        "m": m,
        "n": n,
        "kappa": kappa,
        "abs_m": abs(m),
        "T0": T0,
    }

get_tissue_spectrum

get_tissue_spectrum(tissue_name: str, freqs_hz: ndarray, db_path: Path | None = None) -> dict

Vectorized tissue properties across a frequency array.

Single database lookup, then vectorized Cole-Cole evaluation. Much faster than calling get_tissue_properties in a loop.

Parameters

tissue_name Tissue name (e.g. "Skin"). freqs_hz 1D array of frequencies in Hz. db_path Explicit path to itis_v5.db. Auto-detected if None.

Returns

Dict with keys: freqs_hz, eps_r, sigma, n, kappa, T0 (all 1D arrays).

Source code in src/aegis/tissue/database.py
def get_tissue_spectrum(
    tissue_name: str,
    freqs_hz: np.ndarray,
    db_path: Path | None = None,
) -> dict:
    """Vectorized tissue properties across a frequency array.

    Single database lookup, then vectorized Cole-Cole evaluation. Much faster
    than calling ``get_tissue_properties`` in a loop.

    Parameters
    ----------
    tissue_name
        Tissue name (e.g. "Skin").
    freqs_hz
        1D array of frequencies in Hz.
    db_path
        Explicit path to itis_v5.db. Auto-detected if None.

    Returns
    -------
    Dict with keys: freqs_hz, eps_r, sigma, n, kappa, T0 (all 1D arrays).
    """
    params = get_gabriel_params(tissue_name, db_path=db_path)
    if params is None:
        raise ValueError(f"Could not load Gabriel parameters for '{tissue_name}'")

    freqs_hz = np.asarray(freqs_hz, dtype=np.float64)
    eps_complex = cole_cole_permittivity(freqs_hz, params)

    m = np.sqrt(eps_complex)
    m = np.where(np.real(m) < 0, -m, m)

    n = np.real(m)
    kappa = -np.imag(m)

    T0 = 4 * n / ((1 + n) ** 2 + kappa**2)

    omega = 2 * np.pi * freqs_hz
    eps_r = np.real(eps_complex)
    sigma = -np.imag(eps_complex) * omega * EPS_0

    return {
        "freqs_hz": freqs_hz,
        "eps_r": eps_r,
        "sigma": sigma,
        "n": n,
        "kappa": kappa,
        "T0": T0,
    }

Geometry

BodyMesh

aegis.geometry.mesh.BodyMesh dataclass

Triangulated body surface mesh.

All arrays are read-only views after construction.

Attributes

vertices : (N, 3, 3) triangle vertices normals : (N, 3) unit outward normals centroids : (N, 3) triangle centroids areas : (N,) triangle areas in mesh units squared

Source code in src/aegis/geometry/mesh.py
@dataclass(frozen=True)
class BodyMesh:
    """Triangulated body surface mesh.

    All arrays are read-only views after construction.

    Attributes
    ----------
    vertices : (N, 3, 3) triangle vertices
    normals : (N, 3) unit outward normals
    centroids : (N, 3) triangle centroids
    areas : (N,) triangle areas in mesh units squared
    """

    vertices: np.ndarray = field(repr=False)
    normals: np.ndarray = field(repr=False)
    centroids: np.ndarray = field(repr=False)
    areas: np.ndarray = field(repr=False)
    name: str = ""
    _geometry_hash: int = field(default=0, repr=False, compare=False)
    _bbox_cache: tuple[np.ndarray, np.ndarray] | None = field(
        default=None,
        repr=False,
        compare=False,
        hash=False,
    )

    def __post_init__(self) -> None:
        normals = np.asarray(self.normals, dtype=np.float64)
        if normals.shape != (self.vertices.shape[0], 3):
            raise ValueError(f"normals must be ({self.vertices.shape[0]}, 3), got {normals.shape}")
        norms = np.linalg.norm(normals, axis=1, keepdims=True)
        # Degenerate triangles (zero-area) produce zero normals from cross
        # products. Assign a fallback direction so downstream code always sees
        # unit normals. These triangles have zero area and contribute nothing
        # to integrated quantities, so the direction is irrelevant.
        zero = norms[:, 0] <= 0
        if normals.shape[0] > 0 and np.any(zero):
            normals = normals.copy()
            normals[zero] = [0.0, 0.0, 1.0]
            norms[zero] = 1.0
        normalized = normals / norms
        object.__setattr__(self, "normals", normalized)
        if self._geometry_hash == 0:
            object.__setattr__(self, "_geometry_hash", self._compute_geometry_hash())

    def _compute_geometry_hash(self) -> int:
        """Rigid-transform-invariant content hash of the mesh for cache keying.

        Uses sorted per-triangle edge lengths plus areas. Both are stable
        under translation and rotation up to float32 precision, so moved or
        yawed copies of the same mesh hit caches (e.g. the spatial averaging
        matrix G) instead of triggering expensive rebuilds. Subtracting the
        mean centroid before hashing is not robust: near-zero residuals
        collect enough float64 noise that tiny FP differences cross float32
        quantization boundaries, producing spurious cache misses.
        """
        edge_vecs = np.roll(self.vertices, -1, axis=1) - self.vertices
        edge_lengths = np.sort(np.linalg.norm(edge_vecs, axis=2), axis=1)
        h = hashlib.sha256(self.areas.astype(np.float32).tobytes())
        h.update(edge_lengths.astype(np.float32).tobytes())
        digest = h.digest()[:8]
        return hash((int.from_bytes(digest, "little"), self.n_triangles))

    @property
    def geometry_hash(self) -> int:
        return self._geometry_hash

    @property
    def vertex_hash(self) -> int:
        """Pose-dependent content hash of the raw vertices.

        Distinct from ``geometry_hash`` (rigid-invariant). Visibility is
        direction-dependent, so a yawed body must miss the cache. Quantized to
        float32 so float64 round-off does not spuriously change the key.
        """
        h = hashlib.sha256(np.ascontiguousarray(self.vertices, dtype=np.float32).tobytes())
        return hash((int.from_bytes(h.digest()[:8], "little"), self.n_triangles))

    @classmethod
    def from_arrays(
        cls,
        vertices: np.ndarray,
        normals: np.ndarray | None = None,
        name: str = "synthetic",
    ) -> BodyMesh:
        """Create a BodyMesh from raw vertex arrays.

        Centroids and areas are computed automatically. If normals are not
        provided, they are computed from the vertex cross product.

        Parameters
        ----------
        vertices : (N, 3, 3) triangle vertices
        normals : (N, 3) unit outward normals, or None to compute from vertices
        name : mesh name
        """
        vertices = np.asarray(vertices, dtype=np.float64)
        if vertices.ndim != 3 or vertices.shape[1:] != (3, 3):
            raise ValueError(f"vertices must be (N, 3, 3), got {vertices.shape}")

        centroids = np.mean(vertices, axis=1)
        areas = triangle_areas(vertices)

        if normals is None:
            v0, v1, v2 = vertices[:, 0], vertices[:, 1], vertices[:, 2]
            cross = np.cross(v1 - v0, v2 - v0)
            norms = np.linalg.norm(cross, axis=1, keepdims=True)
            normals = cross / np.where(norms > 0, norms, 1.0)
        else:
            normals = np.asarray(normals, dtype=np.float64)
            if normals.shape != (vertices.shape[0], 3):
                raise ValueError(f"normals must be ({vertices.shape[0]}, 3), got {normals.shape}")

        return cls(vertices=vertices, normals=normals, centroids=centroids, areas=areas, name=name)

    @classmethod
    def sphere(cls, radius: float = 1.0, n_subdivisions: int = 2) -> BodyMesh:
        """Create a sphere mesh via icosphere subdivision.

        Parameters
        ----------
        radius : float
            Sphere radius. Must be positive.
        n_subdivisions : int
            Number of subdivision iterations. 0 gives a bare icosahedron (20
            triangles). Each iteration multiplies the triangle count by 4.
        """
        if radius <= 0.0:
            raise ValueError("radius must be positive")
        if n_subdivisions < 0:
            raise ValueError("n_subdivisions must be non-negative")

        # Regular icosahedron vertices on unit sphere
        phi = (1.0 + np.sqrt(5.0)) / 2.0
        raw = np.array(
            [
                [-1, phi, 0],
                [1, phi, 0],
                [-1, -phi, 0],
                [1, -phi, 0],
                [0, -1, phi],
                [0, 1, phi],
                [0, -1, -phi],
                [0, 1, -phi],
                [phi, 0, -1],
                [phi, 0, 1],
                [-phi, 0, -1],
                [-phi, 0, 1],
            ],
            dtype=np.float64,
        )
        verts = raw / np.linalg.norm(raw, axis=1, keepdims=True)

        # 20 icosahedron faces (CCW outward winding)
        faces = np.array(
            [
                [0, 11, 5],
                [0, 5, 1],
                [0, 1, 7],
                [0, 7, 10],
                [0, 10, 11],
                [1, 5, 9],
                [5, 11, 4],
                [11, 10, 2],
                [10, 7, 6],
                [7, 1, 8],
                [3, 9, 4],
                [3, 4, 2],
                [3, 2, 6],
                [3, 6, 8],
                [3, 8, 9],
                [4, 9, 5],
                [2, 4, 11],
                [6, 2, 10],
                [8, 6, 7],
                [9, 8, 1],
            ],
            dtype=np.int64,
        )

        # Subdivide
        for _ in range(n_subdivisions):
            new_faces = []
            midpoint_cache: dict[tuple[int, int], int] = {}
            vert_list = list(verts)

            def _get_midpoint(
                a: int,
                b: int,
                cache: dict = midpoint_cache,
                vl: list = vert_list,
            ) -> int:
                key = (min(a, b), max(a, b))
                if key in cache:
                    return cache[key]
                mid = (np.array(vl[a]) + np.array(vl[b])) / 2.0
                mid = mid / np.linalg.norm(mid)
                idx = len(vl)
                vl.append(mid)
                cache[key] = idx
                return idx

            for f in faces:
                a, b, c = int(f[0]), int(f[1]), int(f[2])
                ab = _get_midpoint(a, b)
                bc = _get_midpoint(b, c)
                ca = _get_midpoint(c, a)
                new_faces.extend([[a, ab, ca], [b, bc, ab], [c, ca, bc], [ab, bc, ca]])
            faces = np.array(new_faces, dtype=np.int64)
            verts = np.array(vert_list, dtype=np.float64)

        # Scale and build (N, 3, 3) vertex array
        verts = verts * radius
        vertices = verts[faces]  # (N, 3, 3)
        return cls.from_arrays(vertices, name="sphere")

    @classmethod
    def cylinder(
        cls,
        radius: float = 1.0,
        height: float = 1.0,
        n_segments: int = 32,
    ) -> BodyMesh:
        """Create a capped cylinder mesh aligned along the z-axis.

        The cylinder spans from z = -height/2 to z = +height/2.

        Parameters
        ----------
        radius : float
            Cylinder radius. Must be positive.
        height : float
            Cylinder height. Must be positive.
        n_segments : int
            Number of azimuthal divisions. Must be >= 3.
        """
        if radius <= 0.0:
            raise ValueError("radius must be positive")
        if height <= 0.0:
            raise ValueError("height must be positive")
        if n_segments < 3:
            raise ValueError("n_segments must be at least 3")

        angles = np.linspace(0.0, 2.0 * np.pi, n_segments, endpoint=False)
        cos_a = np.cos(angles)
        sin_a = np.sin(angles)

        z_top = height / 2.0
        z_bot = -height / 2.0

        # Ring vertices: top and bottom circles
        top_ring = np.stack([radius * cos_a, radius * sin_a, np.full(n_segments, z_top)], axis=1)
        bot_ring = np.stack([radius * cos_a, radius * sin_a, np.full(n_segments, z_bot)], axis=1)

        n = n_segments
        side_tris = np.empty((2 * n, 3, 3), dtype=np.float64)
        for i in range(n):
            j = (i + 1) % n
            # Two triangles per quad, CCW outward winding
            side_tris[2 * i] = [top_ring[i], bot_ring[i], bot_ring[j]]
            side_tris[2 * i + 1] = [top_ring[i], bot_ring[j], top_ring[j]]

        # Top cap: fan from center (0, 0, z_top), CCW when viewed from +z
        top_center = np.array([0.0, 0.0, z_top])
        top_tris = np.empty((n, 3, 3), dtype=np.float64)
        for i in range(n):
            j = (i + 1) % n
            top_tris[i] = [top_center, top_ring[i], top_ring[j]]

        # Bottom cap: fan from center (0, 0, z_bot), CCW when viewed from -z
        bot_center = np.array([0.0, 0.0, z_bot])
        bot_tris = np.empty((n, 3, 3), dtype=np.float64)
        for i in range(n):
            j = (i + 1) % n
            bot_tris[i] = [bot_center, bot_ring[j], bot_ring[i]]

        vertices = np.concatenate([side_tris, top_tris, bot_tris], axis=0)
        return cls.from_arrays(vertices, name="cylinder")

    @staticmethod
    def load(path: str | Path, name: str | None = None) -> BodyMesh:
        """Load a binary STL file and return a BodyMesh."""
        path = Path(path)
        vertices, normals, centroids = load_stl_binary(path)
        areas = triangle_areas(vertices)
        if name is None:
            name = path.stem
        return BodyMesh(
            vertices=vertices,
            normals=normals,
            centroids=centroids,
            areas=areas,
            name=name,
        )

    @property
    def n_triangles(self) -> int:
        return self.vertices.shape[0]

    @property
    def total_area(self) -> float:
        return float(np.sum(self.areas))

    @property
    def bounding_box(self) -> tuple[np.ndarray, np.ndarray]:
        """Return (bmin, bmax) of the mesh, cached after first access."""
        if self._bbox_cache is None:
            flat = self.vertices.reshape(-1, 3)
            object.__setattr__(self, "_bbox_cache", (np.min(flat, axis=0), np.max(flat, axis=0)))
        return self._bbox_cache

    @property
    def center(self) -> np.ndarray:
        bmin, bmax = self.bounding_box
        return (bmin + bmax) / 2.0

    @property
    def height(self) -> float:
        bmin, bmax = self.bounding_box
        return float(bmax[2] - bmin[2])

    @property
    def scale(self) -> float:
        """Bounding box diagonal length."""
        bmin, bmax = self.bounding_box
        return float(np.linalg.norm(bmax - bmin))

    def save_binary_stl(self, path: str | Path) -> None:
        """Write a binary STL (little-endian float32) for this mesh.

        Uses vectorized numpy writes for speed on large meshes.
        """
        path = Path(path)
        path.parent.mkdir(parents=True, exist_ok=True)
        n = self.n_triangles
        header = b"AEGIS BodyMesh" + b"\0" * (80 - 14)

        dt = np.dtype(
            [
                ("normal", "<f4", (3,)),
                ("v0", "<f4", (3,)),
                ("v1", "<f4", (3,)),
                ("v2", "<f4", (3,)),
                ("attr", "<u2"),
            ]
        )
        records = np.zeros(n, dtype=dt)
        records["normal"] = self.normals.astype(np.float32)
        records["v0"] = self.vertices[:, 0].astype(np.float32)
        records["v1"] = self.vertices[:, 1].astype(np.float32)
        records["v2"] = self.vertices[:, 2].astype(np.float32)

        with path.open("wb") as f:
            f.write(header)
            f.write(struct.pack("<I", n))
            f.write(records.tobytes())

    def __repr__(self) -> str:
        return f"BodyMesh(name={self.name!r}, n_triangles={self.n_triangles}, total_area={self.total_area:.6g})"

vertex_hash property

vertex_hash: int

Pose-dependent content hash of the raw vertices.

Distinct from geometry_hash (rigid-invariant). Visibility is direction-dependent, so a yawed body must miss the cache. Quantized to float32 so float64 round-off does not spuriously change the key.

bounding_box property

bounding_box: tuple[ndarray, ndarray]

Return (bmin, bmax) of the mesh, cached after first access.

scale property

scale: float

Bounding box diagonal length.

from_arrays classmethod

from_arrays(vertices: ndarray, normals: ndarray | None = None, name: str = 'synthetic') -> BodyMesh

Create a BodyMesh from raw vertex arrays.

Centroids and areas are computed automatically. If normals are not provided, they are computed from the vertex cross product.

Parameters

vertices : (N, 3, 3) triangle vertices normals : (N, 3) unit outward normals, or None to compute from vertices name : mesh name

Source code in src/aegis/geometry/mesh.py
@classmethod
def from_arrays(
    cls,
    vertices: np.ndarray,
    normals: np.ndarray | None = None,
    name: str = "synthetic",
) -> BodyMesh:
    """Create a BodyMesh from raw vertex arrays.

    Centroids and areas are computed automatically. If normals are not
    provided, they are computed from the vertex cross product.

    Parameters
    ----------
    vertices : (N, 3, 3) triangle vertices
    normals : (N, 3) unit outward normals, or None to compute from vertices
    name : mesh name
    """
    vertices = np.asarray(vertices, dtype=np.float64)
    if vertices.ndim != 3 or vertices.shape[1:] != (3, 3):
        raise ValueError(f"vertices must be (N, 3, 3), got {vertices.shape}")

    centroids = np.mean(vertices, axis=1)
    areas = triangle_areas(vertices)

    if normals is None:
        v0, v1, v2 = vertices[:, 0], vertices[:, 1], vertices[:, 2]
        cross = np.cross(v1 - v0, v2 - v0)
        norms = np.linalg.norm(cross, axis=1, keepdims=True)
        normals = cross / np.where(norms > 0, norms, 1.0)
    else:
        normals = np.asarray(normals, dtype=np.float64)
        if normals.shape != (vertices.shape[0], 3):
            raise ValueError(f"normals must be ({vertices.shape[0]}, 3), got {normals.shape}")

    return cls(vertices=vertices, normals=normals, centroids=centroids, areas=areas, name=name)

sphere classmethod

sphere(radius: float = 1.0, n_subdivisions: int = 2) -> BodyMesh

Create a sphere mesh via icosphere subdivision.

Parameters

radius : float Sphere radius. Must be positive. n_subdivisions : int Number of subdivision iterations. 0 gives a bare icosahedron (20 triangles). Each iteration multiplies the triangle count by 4.

Source code in src/aegis/geometry/mesh.py
@classmethod
def sphere(cls, radius: float = 1.0, n_subdivisions: int = 2) -> BodyMesh:
    """Create a sphere mesh via icosphere subdivision.

    Parameters
    ----------
    radius : float
        Sphere radius. Must be positive.
    n_subdivisions : int
        Number of subdivision iterations. 0 gives a bare icosahedron (20
        triangles). Each iteration multiplies the triangle count by 4.
    """
    if radius <= 0.0:
        raise ValueError("radius must be positive")
    if n_subdivisions < 0:
        raise ValueError("n_subdivisions must be non-negative")

    # Regular icosahedron vertices on unit sphere
    phi = (1.0 + np.sqrt(5.0)) / 2.0
    raw = np.array(
        [
            [-1, phi, 0],
            [1, phi, 0],
            [-1, -phi, 0],
            [1, -phi, 0],
            [0, -1, phi],
            [0, 1, phi],
            [0, -1, -phi],
            [0, 1, -phi],
            [phi, 0, -1],
            [phi, 0, 1],
            [-phi, 0, -1],
            [-phi, 0, 1],
        ],
        dtype=np.float64,
    )
    verts = raw / np.linalg.norm(raw, axis=1, keepdims=True)

    # 20 icosahedron faces (CCW outward winding)
    faces = np.array(
        [
            [0, 11, 5],
            [0, 5, 1],
            [0, 1, 7],
            [0, 7, 10],
            [0, 10, 11],
            [1, 5, 9],
            [5, 11, 4],
            [11, 10, 2],
            [10, 7, 6],
            [7, 1, 8],
            [3, 9, 4],
            [3, 4, 2],
            [3, 2, 6],
            [3, 6, 8],
            [3, 8, 9],
            [4, 9, 5],
            [2, 4, 11],
            [6, 2, 10],
            [8, 6, 7],
            [9, 8, 1],
        ],
        dtype=np.int64,
    )

    # Subdivide
    for _ in range(n_subdivisions):
        new_faces = []
        midpoint_cache: dict[tuple[int, int], int] = {}
        vert_list = list(verts)

        def _get_midpoint(
            a: int,
            b: int,
            cache: dict = midpoint_cache,
            vl: list = vert_list,
        ) -> int:
            key = (min(a, b), max(a, b))
            if key in cache:
                return cache[key]
            mid = (np.array(vl[a]) + np.array(vl[b])) / 2.0
            mid = mid / np.linalg.norm(mid)
            idx = len(vl)
            vl.append(mid)
            cache[key] = idx
            return idx

        for f in faces:
            a, b, c = int(f[0]), int(f[1]), int(f[2])
            ab = _get_midpoint(a, b)
            bc = _get_midpoint(b, c)
            ca = _get_midpoint(c, a)
            new_faces.extend([[a, ab, ca], [b, bc, ab], [c, ca, bc], [ab, bc, ca]])
        faces = np.array(new_faces, dtype=np.int64)
        verts = np.array(vert_list, dtype=np.float64)

    # Scale and build (N, 3, 3) vertex array
    verts = verts * radius
    vertices = verts[faces]  # (N, 3, 3)
    return cls.from_arrays(vertices, name="sphere")

cylinder classmethod

cylinder(radius: float = 1.0, height: float = 1.0, n_segments: int = 32) -> BodyMesh

Create a capped cylinder mesh aligned along the z-axis.

The cylinder spans from z = -height/2 to z = +height/2.

Parameters

radius : float Cylinder radius. Must be positive. height : float Cylinder height. Must be positive. n_segments : int Number of azimuthal divisions. Must be >= 3.

Source code in src/aegis/geometry/mesh.py
@classmethod
def cylinder(
    cls,
    radius: float = 1.0,
    height: float = 1.0,
    n_segments: int = 32,
) -> BodyMesh:
    """Create a capped cylinder mesh aligned along the z-axis.

    The cylinder spans from z = -height/2 to z = +height/2.

    Parameters
    ----------
    radius : float
        Cylinder radius. Must be positive.
    height : float
        Cylinder height. Must be positive.
    n_segments : int
        Number of azimuthal divisions. Must be >= 3.
    """
    if radius <= 0.0:
        raise ValueError("radius must be positive")
    if height <= 0.0:
        raise ValueError("height must be positive")
    if n_segments < 3:
        raise ValueError("n_segments must be at least 3")

    angles = np.linspace(0.0, 2.0 * np.pi, n_segments, endpoint=False)
    cos_a = np.cos(angles)
    sin_a = np.sin(angles)

    z_top = height / 2.0
    z_bot = -height / 2.0

    # Ring vertices: top and bottom circles
    top_ring = np.stack([radius * cos_a, radius * sin_a, np.full(n_segments, z_top)], axis=1)
    bot_ring = np.stack([radius * cos_a, radius * sin_a, np.full(n_segments, z_bot)], axis=1)

    n = n_segments
    side_tris = np.empty((2 * n, 3, 3), dtype=np.float64)
    for i in range(n):
        j = (i + 1) % n
        # Two triangles per quad, CCW outward winding
        side_tris[2 * i] = [top_ring[i], bot_ring[i], bot_ring[j]]
        side_tris[2 * i + 1] = [top_ring[i], bot_ring[j], top_ring[j]]

    # Top cap: fan from center (0, 0, z_top), CCW when viewed from +z
    top_center = np.array([0.0, 0.0, z_top])
    top_tris = np.empty((n, 3, 3), dtype=np.float64)
    for i in range(n):
        j = (i + 1) % n
        top_tris[i] = [top_center, top_ring[i], top_ring[j]]

    # Bottom cap: fan from center (0, 0, z_bot), CCW when viewed from -z
    bot_center = np.array([0.0, 0.0, z_bot])
    bot_tris = np.empty((n, 3, 3), dtype=np.float64)
    for i in range(n):
        j = (i + 1) % n
        bot_tris[i] = [bot_center, bot_ring[j], bot_ring[i]]

    vertices = np.concatenate([side_tris, top_tris, bot_tris], axis=0)
    return cls.from_arrays(vertices, name="cylinder")

load staticmethod

load(path: str | Path, name: str | None = None) -> BodyMesh

Load a binary STL file and return a BodyMesh.

Source code in src/aegis/geometry/mesh.py
@staticmethod
def load(path: str | Path, name: str | None = None) -> BodyMesh:
    """Load a binary STL file and return a BodyMesh."""
    path = Path(path)
    vertices, normals, centroids = load_stl_binary(path)
    areas = triangle_areas(vertices)
    if name is None:
        name = path.stem
    return BodyMesh(
        vertices=vertices,
        normals=normals,
        centroids=centroids,
        areas=areas,
        name=name,
    )

save_binary_stl

save_binary_stl(path: str | Path) -> None

Write a binary STL (little-endian float32) for this mesh.

Uses vectorized numpy writes for speed on large meshes.

Source code in src/aegis/geometry/mesh.py
def save_binary_stl(self, path: str | Path) -> None:
    """Write a binary STL (little-endian float32) for this mesh.

    Uses vectorized numpy writes for speed on large meshes.
    """
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    n = self.n_triangles
    header = b"AEGIS BodyMesh" + b"\0" * (80 - 14)

    dt = np.dtype(
        [
            ("normal", "<f4", (3,)),
            ("v0", "<f4", (3,)),
            ("v1", "<f4", (3,)),
            ("v2", "<f4", (3,)),
            ("attr", "<u2"),
        ]
    )
    records = np.zeros(n, dtype=dt)
    records["normal"] = self.normals.astype(np.float32)
    records["v0"] = self.vertices[:, 0].astype(np.float32)
    records["v1"] = self.vertices[:, 1].astype(np.float32)
    records["v2"] = self.vertices[:, 2].astype(np.float32)

    with path.open("wb") as f:
        f.write(header)
        f.write(struct.pack("<I", n))
        f.write(records.tobytes())

Mesh utilities

aegis.geometry.mesh.load_stl_binary

load_stl_binary(path: str | Path) -> tuple[np.ndarray, np.ndarray, np.ndarray]

Load a binary STL file.

Uses vectorized numpy reads instead of per-triangle struct.unpack, giving ~50-100x speedup on large meshes (100k+ triangles).

Returns

vertices : (N, 3, 3) Triangle vertices. normals : (N, 3) Unit triangle normals. centroids : (N, 3) Triangle centroids.

Source code in src/aegis/geometry/mesh.py
def load_stl_binary(path: str | Path) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """
    Load a binary STL file.

    Uses vectorized numpy reads instead of per-triangle struct.unpack,
    giving ~50-100x speedup on large meshes (100k+ triangles).

    Returns
    -------
    vertices : (N, 3, 3)
        Triangle vertices.
    normals : (N, 3)
        Unit triangle normals.
    centroids : (N, 3)
        Triangle centroids.
    """
    path = Path(path)
    with path.open("rb") as f:
        f.read(80)  # header
        num_triangles = struct.unpack("<I", f.read(4))[0]
        data = f.read()

    # Binary STL: each triangle is 50 bytes
    # 12 bytes normal (3x float32) + 36 bytes vertices (9x float32) + 2 bytes attr
    record_bytes = 50
    expected = num_triangles * record_bytes
    if len(data) < expected:
        raise ValueError(
            f"STL file truncated: expected {expected} bytes for {num_triangles} triangles, got {len(data)}"
        )

    # Build a structured dtype matching the STL record layout
    dt = np.dtype(
        [
            ("normal", "<f4", (3,)),
            ("v0", "<f4", (3,)),
            ("v1", "<f4", (3,)),
            ("v2", "<f4", (3,)),
            ("attr", "<u2"),
        ]
    )
    records = np.frombuffer(data[:expected], dtype=dt)

    normals = records["normal"].astype(np.float64)
    vertices = np.stack([records["v0"], records["v1"], records["v2"]], axis=1).astype(np.float64)

    centroids = np.mean(vertices, axis=1)

    # Normalize normals. Recompute from vertices if STL normal is zero.
    n_norm = np.linalg.norm(normals, axis=1, keepdims=True)
    bad = n_norm[:, 0] <= 0
    if np.any(bad):
        v0 = vertices[bad, 0]
        v1 = vertices[bad, 1]
        v2 = vertices[bad, 2]
        nn = np.cross(v1 - v0, v2 - v0)
        nn_norm = np.linalg.norm(nn, axis=1, keepdims=True)
        nn = nn / np.where(nn_norm > 0, nn_norm, 1.0)
        normals[bad] = nn
        # Only recompute norms for the fixed subset
        n_norm[bad] = np.linalg.norm(normals[bad], axis=1, keepdims=True)

    normals = normals / np.where(n_norm > 0, n_norm, 1.0)

    return vertices, normals, centroids

aegis.geometry.mesh.triangle_areas

triangle_areas(vertices: ndarray) -> np.ndarray

Compute area of each triangle from a (N, 3, 3) vertex array.

Source code in src/aegis/geometry/mesh.py
def triangle_areas(vertices: np.ndarray) -> np.ndarray:
    """Compute area of each triangle from a (N, 3, 3) vertex array."""
    v0, v1, v2 = vertices[:, 0], vertices[:, 1], vertices[:, 2]
    cross = np.cross(v1 - v0, v2 - v0)
    return 0.5 * np.linalg.norm(cross, axis=1)

Projected area

aegis.geometry.projected_area

Projected area A_perp(k_hat) lookup table.

Extracted from scripts/compute_projected_area_table.py.

fibonacci_sphere

fibonacci_sphere(n: int) -> np.ndarray

Deterministic near-uniform sampling on S^2 via golden spiral.

Returns k_hat of shape (n, 3).

Source code in src/aegis/geometry/projected_area.py
def fibonacci_sphere(n: int) -> np.ndarray:
    """Deterministic near-uniform sampling on S^2 via golden spiral.

    Returns k_hat of shape (n, 3).
    """
    if n <= 0:
        raise ValueError("n must be positive")

    cached = _fibonacci_sphere_cache.get(n)
    if cached is not None:
        return cached

    i = np.arange(n, dtype=np.float64)
    golden_ratio = (1.0 + np.sqrt(5.0)) / 2.0

    z = 1.0 - 2.0 * (i + 0.5) / n
    r = np.sqrt(np.maximum(0.0, 1.0 - z * z))
    phi = 2.0 * np.pi * i / golden_ratio

    x = r * np.cos(phi)
    y = r * np.sin(phi)
    k_hat = np.stack([x, y, z], axis=1)
    k_hat /= np.linalg.norm(k_hat, axis=1, keepdims=True)
    out = np.array(k_hat, copy=True)
    out.flags.writeable = False
    while len(_fibonacci_sphere_cache) >= _FIBONACCI_CACHE_MAX:
        _fibonacci_sphere_cache.pop(next(iter(_fibonacci_sphere_cache)))
    _fibonacci_sphere_cache[n] = out
    return out

compute_projected_area

compute_projected_area(normals: ndarray, areas: ndarray, k_hat: ndarray, chunk_dirs: int = 128) -> np.ndarray

Compute A_perp(k_hat) = sum_j a_j * [n_j . (-k_hat)]_+ for each direction.

Parameters

normals : (M, 3) unit triangle normals areas : (M,) triangle areas k_hat : (N, 3) incident directions (unit vectors) chunk_dirs : chunk size for memory-bounded BLAS

Returns

A_perp : (N,) projected areas

Source code in src/aegis/geometry/projected_area.py
def compute_projected_area(
    normals: np.ndarray,
    areas: np.ndarray,
    k_hat: np.ndarray,
    chunk_dirs: int = 128,
) -> np.ndarray:
    """Compute A_perp(k_hat) = sum_j a_j * [n_j . (-k_hat)]_+ for each direction.

    Parameters
    ----------
    normals : (M, 3) unit triangle normals
    areas : (M,) triangle areas
    k_hat : (N, 3) incident directions (unit vectors)
    chunk_dirs : chunk size for memory-bounded BLAS

    Returns
    -------
    A_perp : (N,) projected areas
    """
    normals = np.asarray(normals, dtype=np.float64)
    areas = np.asarray(areas, dtype=np.float64)
    k_hat = np.asarray(k_hat, dtype=np.float64)

    n_dirs = k_hat.shape[0]
    A_perp = np.zeros(n_dirs, dtype=np.float64)
    minus_k = -k_hat

    for start in range(0, n_dirs, chunk_dirs):
        end = min(start + chunk_dirs, n_dirs)
        k_chunk = minus_k[start:end]  # (C, 3)
        mu = normals @ k_chunk.T  # (M, C)
        mu_pos = np.maximum(0.0, mu)
        A_perp[start:end] = areas @ mu_pos  # (C,)

    return A_perp

Directivity and spherical harmonics

aegis.geometry.directivity

Body absorption directivity D(k_hat) and spherical harmonic compression.

Extracted from scripts/compute_body_directivity.py.

spherical_angles_from_k_hat

spherical_angles_from_k_hat(k_hat: ndarray) -> tuple[np.ndarray, np.ndarray]

Convert unit vectors to (theta, phi).

theta: polar angle in [0, pi], measured from +z. phi: azimuth in [-pi, pi], measured from +x toward +y.

Source code in src/aegis/geometry/directivity.py
def spherical_angles_from_k_hat(k_hat: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Convert unit vectors to (theta, phi).

    theta: polar angle in [0, pi], measured from +z.
    phi: azimuth in [-pi, pi], measured from +x toward +y.
    """
    k_hat = np.asarray(k_hat, dtype=float)
    if k_hat.ndim != 2 or k_hat.shape[1] != 3:
        raise ValueError(f"Expected k_hat shape (N,3), got {k_hat.shape}")

    norms = np.linalg.norm(k_hat, axis=1)
    if not np.all(norms > 0):
        raise ValueError("k_hat contains zero-length vectors")

    k = k_hat / norms[:, None]
    z = np.clip(k[:, 2], -1.0, 1.0)
    theta = np.arccos(z)
    phi = np.arctan2(k[:, 1], k[:, 0])
    return theta, phi

compute_directivity

compute_directivity(A_perp: ndarray) -> np.ndarray

Compute directivity D = A_perp / mean(A_perp).

D has mean 1 by construction.

Source code in src/aegis/geometry/directivity.py
def compute_directivity(A_perp: np.ndarray) -> np.ndarray:
    """Compute directivity D = A_perp / mean(A_perp).

    D has mean 1 by construction.
    """
    A_perp = np.asarray(A_perp, dtype=np.float64)
    mean = float(np.mean(A_perp))
    if mean <= 0:
        raise ValueError(f"mean(A_perp) must be > 0, got {mean}")
    return A_perp / mean

fit_sh

fit_sh(D: ndarray, theta: ndarray, phi: ndarray, L: int) -> np.ndarray

Fit complex SH coefficients via least squares.

Parameters

D : (N,) directivity samples theta, phi : (N,) spherical angles L : maximum SH degree

Returns

c : ((L+1)^2,) complex coefficients

Source code in src/aegis/geometry/directivity.py
def fit_sh(
    D: np.ndarray,
    theta: np.ndarray,
    phi: np.ndarray,
    L: int,
) -> np.ndarray:
    """Fit complex SH coefficients via least squares.

    Parameters
    ----------
    D : (N,) directivity samples
    theta, phi : (N,) spherical angles
    L : maximum SH degree

    Returns
    -------
    c : ((L+1)^2,) complex coefficients
    """
    if L < 0:
        raise ValueError("L must be >= 0")

    D = np.asarray(D, dtype=float)
    theta = np.asarray(theta, dtype=float)
    phi_02pi = np.mod(np.asarray(phi, dtype=float), 2 * np.pi)

    cols = []
    for ell in range(L + 1):
        for m in range(-ell, ell + 1):
            cols.append(_sph_harm(m, ell, phi_02pi, theta))

    Y = np.stack(cols, axis=1)
    c, *_ = np.linalg.lstsq(Y, D.astype(complex), rcond=None)
    return c

eval_sh

eval_sh(c: ndarray, theta: ndarray, phi: ndarray, L: int) -> np.ndarray

Evaluate SH expansion at given angles.

Returns real-valued reconstruction.

Source code in src/aegis/geometry/directivity.py
def eval_sh(
    c: np.ndarray,
    theta: np.ndarray,
    phi: np.ndarray,
    L: int,
) -> np.ndarray:
    """Evaluate SH expansion at given angles.

    Returns real-valued reconstruction.
    """
    phi_02pi = np.mod(np.asarray(phi, dtype=float), 2 * np.pi)
    theta = np.asarray(theta, dtype=float)

    cols = []
    idx = 0
    for ell in range(L + 1):
        for m in range(-ell, ell + 1):
            cols.append(_sph_harm(m, ell, phi_02pi, theta) * c[idx])
            idx += 1
    return np.real(np.sum(np.stack(cols, axis=1), axis=1))

sh_reconstruction_error

sh_reconstruction_error(D: ndarray, theta: ndarray, phi: ndarray, L: int) -> dict

Fit SH at degree L and return error metrics.

Returns dict with keys: L, n_coeff, rms, max_abs, p99_abs, coefficients.

Source code in src/aegis/geometry/directivity.py
def sh_reconstruction_error(
    D: np.ndarray,
    theta: np.ndarray,
    phi: np.ndarray,
    L: int,
) -> dict:
    """Fit SH at degree L and return error metrics.

    Returns dict with keys: L, n_coeff, rms, max_abs, p99_abs, coefficients.
    """
    c = fit_sh(D, theta, phi, L)
    D_hat = eval_sh(c, theta, phi, L)
    err = D_hat - D
    abs_err = np.abs(err)
    return {
        "L": L,
        "n_coeff": (L + 1) ** 2,
        "rms": float(np.sqrt(np.mean(err**2))),
        "max_abs": float(np.max(abs_err)),
        "p99_abs": float(np.percentile(abs_err, 99.0)),
        "coefficients": c,
    }

Ambient occlusion

aegis.geometry.occlusion

Cosine-weighted ambient occlusion (exposure fraction eta).

Extracted from scripts/compute_exposure_fraction_eta.py. Uses Numba JIT compilation for the BVH traversal and ray intersection hot path when available, giving ~50-100x speedup on large meshes.

cosine_weighted_hemisphere_samples

cosine_weighted_hemisphere_samples(n: int, rng: Generator) -> np.ndarray

Sample n directions on the +Z hemisphere with cosine-weighted distribution.

Returns (n, 3) array with z >= 0.

Source code in src/aegis/geometry/occlusion.py
def cosine_weighted_hemisphere_samples(n: int, rng: np.random.Generator) -> np.ndarray:
    """Sample n directions on the +Z hemisphere with cosine-weighted distribution.

    Returns (n, 3) array with z >= 0.
    """
    u1 = rng.random(n)
    u2 = rng.random(n)
    r = np.sqrt(u1)
    phi = 2.0 * np.pi * u2
    x = r * np.cos(phi)
    y = r * np.sin(phi)
    z = np.sqrt(np.maximum(0.0, 1.0 - u1))
    return np.stack([x, y, z], axis=1)

make_tangent_frame

make_tangent_frame(n: ndarray) -> tuple[np.ndarray, np.ndarray]

Build orthonormal (t, b) for unit normal n so that t x b = n.

Source code in src/aegis/geometry/occlusion.py
def make_tangent_frame(n: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Build orthonormal (t, b) for unit normal n so that t x b = n."""
    n = _normalize(n)
    a = np.array([0.0, 0.0, 1.0]) if abs(n[2]) < 0.999 else np.array([1.0, 0.0, 0.0])
    t = _normalize(np.cross(a, n))
    b = np.cross(n, t)
    return t, b

build_bvh

build_bvh(tri_bmin: ndarray, tri_bmax: ndarray, tri_centroids: ndarray, max_leaf: int = 8) -> tuple[dict[str, np.ndarray], np.ndarray]

Build a median-split BVH over triangle AABBs.

Returns (bvh_dict, tri_indices) where bvh_dict has keys bmin, bmax, left, right, start, count.

Source code in src/aegis/geometry/occlusion.py
def build_bvh(
    tri_bmin: np.ndarray,
    tri_bmax: np.ndarray,
    tri_centroids: np.ndarray,
    max_leaf: int = 8,
) -> tuple[dict[str, np.ndarray], np.ndarray]:
    """Build a median-split BVH over triangle AABBs.

    Returns (bvh_dict, tri_indices) where bvh_dict has keys
    bmin, bmax, left, right, start, count.
    """
    n_tris = tri_centroids.shape[0]
    tri_indices = np.arange(n_tris, dtype=np.int32)
    bmin_list: list[np.ndarray] = []
    bmax_list: list[np.ndarray] = []
    left_list: list[int] = []
    right_list: list[int] = []
    start_list: list[int] = []
    count_list: list[int] = []

    def build_node(start: int, end: int) -> int:
        bmin, bmax = _segment_bbox(tri_indices, start, end, tri_bmin, tri_bmax)
        node_idx = len(bmin_list)
        bmin_list.append(bmin)
        bmax_list.append(bmax)
        left_list.append(-1)
        right_list.append(-1)
        start_list.append(int(start))
        count_list.append(int(end - start))

        count = end - start
        if count <= max_leaf:
            return node_idx

        extent = bmax - bmin
        axis = int(np.argmax(extent))
        mid = start + count // 2

        seg = tri_indices[start:end]
        keys = tri_centroids[seg, axis]
        order = np.argpartition(keys, mid - start)
        tri_indices[start:end] = seg[order]

        left = build_node(start, mid)
        right = build_node(mid, end)
        left_list[node_idx] = int(left)
        right_list[node_idx] = int(right)
        return node_idx

    build_node(0, n_tris)
    bvh = {
        "bmin": np.asarray(bmin_list, dtype=np.float64),
        "bmax": np.asarray(bmax_list, dtype=np.float64),
        "left": np.asarray(left_list, dtype=np.int32),
        "right": np.asarray(right_list, dtype=np.int32),
        "start": np.asarray(start_list, dtype=np.int32),
        "count": np.asarray(count_list, dtype=np.int32),
    }
    return bvh, tri_indices

ray_mesh_any_hit

ray_mesh_any_hit(ox: float, oy: float, oz: float, dx: float, dy: float, dz: float, bvh: dict[str, ndarray], tri_indices: ndarray, tri_v0x: ndarray, tri_v0y: ndarray, tri_v0z: ndarray, tri_e1x: ndarray, tri_e1y: ndarray, tri_e1z: ndarray, tri_e2x: ndarray, tri_e2y: ndarray, tri_e2z: ndarray, ignore_tri: int | None, t_min: float) -> bool

BVH traversal for any-hit ray-mesh intersection.

Public API wrapper that extracts flat arrays from the BVH dict and delegates to the Numba-JIT inner function.

Source code in src/aegis/geometry/occlusion.py
def ray_mesh_any_hit(
    ox: float,
    oy: float,
    oz: float,
    dx: float,
    dy: float,
    dz: float,
    bvh: dict[str, np.ndarray],
    tri_indices: np.ndarray,
    tri_v0x: np.ndarray,
    tri_v0y: np.ndarray,
    tri_v0z: np.ndarray,
    tri_e1x: np.ndarray,
    tri_e1y: np.ndarray,
    tri_e1z: np.ndarray,
    tri_e2x: np.ndarray,
    tri_e2y: np.ndarray,
    tri_e2z: np.ndarray,
    ignore_tri: int | None,
    t_min: float,
) -> bool:
    """BVH traversal for any-hit ray-mesh intersection.

    Public API wrapper that extracts flat arrays from the BVH dict
    and delegates to the Numba-JIT inner function.
    """
    return _ray_mesh_any_hit_numba(
        ox,
        oy,
        oz,
        dx,
        dy,
        dz,
        bvh["bmin"],
        bvh["bmax"],
        bvh["left"],
        bvh["right"],
        bvh["start"],
        bvh["count"],
        tri_indices,
        tri_v0x,
        tri_v0y,
        tri_v0z,
        tri_e1x,
        tri_e1y,
        tri_e1z,
        tri_e2x,
        tri_e2y,
        tri_e2z,
        ignore_tri if ignore_tri is not None else -1,
        t_min,
    )

batch_closest_hits

batch_closest_hits(origins: ndarray, dirs: ndarray, ignore: ndarray, bvh: dict[str, ndarray], tri_order: ndarray, tri_data: dict[str, ndarray], t_min: float) -> np.ndarray

Nearest hit triangle index for each ray, -1 on miss.

Parameters

origins : (R, 3) ray origins dirs : (R, 3) ray directions (need not be normalised) ignore : (R,) triangle index to skip per ray (e.g. the originating triangle) bvh, tri_order, tri_data : structures from build_bvh / _precompute_triangle_data t_min : minimum ray parameter to count as a hit (self-intersection guard)

Returns

(R,) int array of hit triangle indices, -1 where the ray escapes.

Source code in src/aegis/geometry/occlusion.py
def batch_closest_hits(
    origins: np.ndarray,
    dirs: np.ndarray,
    ignore: np.ndarray,
    bvh: dict[str, np.ndarray],
    tri_order: np.ndarray,
    tri_data: dict[str, np.ndarray],
    t_min: float,
) -> np.ndarray:
    """Nearest hit triangle index for each ray, -1 on miss.

    Parameters
    ----------
    origins : (R, 3) ray origins
    dirs : (R, 3) ray directions (need not be normalised)
    ignore : (R,) triangle index to skip per ray (e.g. the originating triangle)
    bvh, tri_order, tri_data : structures from build_bvh / _precompute_triangle_data
    t_min : minimum ray parameter to count as a hit (self-intersection guard)

    Returns
    -------
    (R,) int array of hit triangle indices, -1 where the ray escapes.
    """
    origins = np.ascontiguousarray(origins, dtype=np.float64)
    dirs = np.ascontiguousarray(dirs, dtype=np.float64)
    ignore = np.ascontiguousarray(ignore, dtype=np.int32)
    n_rays = origins.shape[0]
    if n_rays == 0:
        return np.empty(0, dtype=np.int32)
    return _batch_closest_hits_numba(
        origins[:, 0],
        origins[:, 1],
        origins[:, 2],
        dirs,
        ignore,
        n_rays,
        t_min,
        bvh["bmin"],
        bvh["bmax"],
        bvh["left"],
        bvh["right"],
        bvh["start"],
        bvh["count"],
        tri_order,
        tri_data["tri_v0x"],
        tri_data["tri_v0y"],
        tri_data["tri_v0z"],
        tri_data["tri_e1x"],
        tri_data["tri_e1y"],
        tri_data["tri_e1z"],
        tri_data["tri_e2x"],
        tri_data["tri_e2y"],
        tri_data["tri_e2z"],
    )

compute_ambient_occlusion

compute_ambient_occlusion(mesh: BodyMesh, n_rays: int = 64, seed: int = 0, max_leaf: int = 8) -> np.ndarray

Compute cosine-weighted ambient occlusion (exposure fraction eta).

For each triangle, eta is the fraction of cosine-weighted hemisphere directions that are not occluded by other triangles.

Parameters

mesh : BodyMesh n_rays : number of hemisphere samples per triangle seed : RNG seed for reproducibility max_leaf : BVH leaf size

Returns

eta : (N,) array in [0, 1]

Source code in src/aegis/geometry/occlusion.py
def compute_ambient_occlusion(
    mesh: BodyMesh,
    n_rays: int = 64,
    seed: int = 0,
    max_leaf: int = 8,
) -> np.ndarray:
    """Compute cosine-weighted ambient occlusion (exposure fraction eta).

    For each triangle, eta is the fraction of cosine-weighted hemisphere
    directions that are not occluded by other triangles.

    Parameters
    ----------
    mesh : BodyMesh
    n_rays : number of hemisphere samples per triangle
    seed : RNG seed for reproducibility
    max_leaf : BVH leaf size

    Returns
    -------
    eta : (N,) array in [0, 1]
    """
    normals = mesh.normals
    centroids = mesh.centroids
    n_tri = mesh.n_triangles

    origin_eps = 1e-6 * mesh.scale
    t_min = 10.0 * origin_eps

    tri_data = _precompute_triangle_data(mesh.vertices)
    bvh, tri_order = build_bvh(tri_data["tri_bmin"], tri_data["tri_bmax"], centroids, max_leaf=max_leaf)

    rng = np.random.default_rng(seed)
    base_dirs = cosine_weighted_hemisphere_samples(n_rays, rng=rng)

    # Vectorized tangent frame + direction rotation for all triangles
    t_frames, b_frames = _make_tangent_frames_batch(normals)
    origins = centroids + origin_eps * normals

    # Pre-rotate base_dirs into world space for each triangle: (n_tri, n_rays, 3)
    # dirs[i] = base_dirs[:, 0:1] * t[i] + base_dirs[:, 1:2] * b[i] + base_dirs[:, 2:3] * n[i]
    all_dirs = (
        np.einsum("r,id->ird", base_dirs[:, 0], t_frames)
        + np.einsum("r,id->ird", base_dirs[:, 1], b_frames)
        + np.einsum("r,id->ird", base_dirs[:, 2], normals)
    )

    # Extract BVH arrays once
    bvh_bmin = bvh["bmin"]
    bvh_bmax = bvh["bmax"]
    bvh_left = bvh["left"]
    bvh_right = bvh["right"]
    bvh_start = bvh["start"]
    bvh_count = bvh["count"]
    tv0x, tv0y, tv0z = tri_data["tri_v0x"], tri_data["tri_v0y"], tri_data["tri_v0z"]
    te1x, te1y, te1z = tri_data["tri_e1x"], tri_data["tri_e1y"], tri_data["tri_e1z"]
    te2x, te2y, te2z = tri_data["tri_e2x"], tri_data["tri_e2y"], tri_data["tri_e2z"]

    def _process(i):
        return _fire_rays_numba(
            float(origins[i, 0]),
            float(origins[i, 1]),
            float(origins[i, 2]),
            all_dirs[i],
            n_rays,
            i,
            t_min,
            bvh_bmin,
            bvh_bmax,
            bvh_left,
            bvh_right,
            bvh_start,
            bvh_count,
            tri_order,
            tv0x,
            tv0y,
            tv0z,
            te1x,
            te1y,
            te1z,
            te2x,
            te2y,
            te2z,
        ) / float(n_rays)

    # Use ThreadPoolExecutor for parallelism (Numba releases the GIL)
    if NUMBA_AVAILABLE and n_tri > 100:
        import os
        from concurrent.futures import ThreadPoolExecutor

        n_workers = min(os.cpu_count() or 1, 8)
        eta = np.zeros(n_tri, dtype=np.float64)
        with ThreadPoolExecutor(max_workers=n_workers) as pool:
            results = pool.map(_process, range(n_tri))
            for i, val in enumerate(results):
                eta[i] = val
    else:
        eta = np.array([_process(i) for i in range(n_tri)], dtype=np.float64)

    return np.clip(eta, 0.0, 1.0)

Spatial averaging

aegis.geometry.averaging

ICNIRP 4 cm^2 spatial averaging for absorbed power density.

Extracted from scripts/sab_demo.py.

apply_spatial_averaging

apply_spatial_averaging(sab: ndarray, centroids: ndarray, areas: ndarray, target_area_m2: float = 0.0004) -> np.ndarray

Apply ICNIRP 4 cm^2 spatial averaging to per-triangle S_ab.

For each triangle, find the minimal set of neighbours (sorted by distance) whose cumulative area reaches target_area. The averaged S_ab is the area-weighted mean over that patch.

Parameters

sab : (M,) per-triangle absorbed power density centroids : (M, 3) triangle centroids areas : (M,) triangle areas target_area_m2 : target averaging area (default 4e-4 = 4 cm^2)

Returns

sab_avg : (M,) spatially averaged S_ab

Source code in src/aegis/geometry/averaging.py
def apply_spatial_averaging(
    sab: np.ndarray,
    centroids: np.ndarray,
    areas: np.ndarray,
    target_area_m2: float = 4e-4,
) -> np.ndarray:
    """Apply ICNIRP 4 cm^2 spatial averaging to per-triangle S_ab.

    For each triangle, find the minimal set of neighbours (sorted by
    distance) whose cumulative area reaches target_area. The averaged
    S_ab is the area-weighted mean over that patch.

    Parameters
    ----------
    sab : (M,) per-triangle absorbed power density
    centroids : (M, 3) triangle centroids
    areas : (M,) triangle areas
    target_area_m2 : target averaging area (default 4e-4 = 4 cm^2)

    Returns
    -------
    sab_avg : (M,) spatially averaged S_ab
    """
    G = precompute_averaging_matrix(centroids, areas, target_area_m2)
    return G @ sab

precompute_averaging_matrix

precompute_averaging_matrix(centroids: ndarray, areas: ndarray, target_area_m2: float = 0.0004) -> sparse.csr_array

Build a sparse area-weighted averaging matrix G.

G is row-stochastic: each row sums to 1, so G @ sab gives the spatially averaged absorbed power density. Precomputing G lets us reuse the same geometry for many fields and, when converted to a dense JAX array, enables automatic differentiation through the averaging step.

Uses Numba JIT compilation when available for ~15-30x speedup over the pure-Python loop. Falls back to NumPy otherwise.

The query radius is sized just above the largest patch that fills target_area (see _RADIUS_MARGIN). Rows whose ball under-fills are re-queried over the full mesh, so the matrix is independent of the ball radius and identical to a brute-force nearest-neighbour accumulation.

Parameters

centroids : (M, 3) triangle centroids areas : (M,) triangle areas in m^2 target_area_m2 : target averaging area (default 4e-4 = 4 cm^2)

Returns

G : (M, M) sparse CSR matrix, row-stochastic

Source code in src/aegis/geometry/averaging.py
def precompute_averaging_matrix(
    centroids: np.ndarray,
    areas: np.ndarray,
    target_area_m2: float = 4e-4,
) -> sparse.csr_array:
    """Build a sparse area-weighted averaging matrix G.

    G is row-stochastic: each row sums to 1, so ``G @ sab`` gives the
    spatially averaged absorbed power density. Precomputing G lets us
    reuse the same geometry for many fields and, when converted to a
    dense JAX array, enables automatic differentiation through the
    averaging step.

    Uses Numba JIT compilation when available for ~15-30x speedup over
    the pure-Python loop. Falls back to NumPy otherwise.

    The query radius is sized just above the largest patch that fills
    ``target_area`` (see ``_RADIUS_MARGIN``). Rows whose ball under-fills are
    re-queried over the full mesh, so the matrix is independent of the ball
    radius and identical to a brute-force nearest-neighbour accumulation.

    Parameters
    ----------
    centroids : (M, 3) triangle centroids
    areas : (M,) triangle areas in m^2
    target_area_m2 : target averaging area (default 4e-4 = 4 cm^2)

    Returns
    -------
    G : (M, M) sparse CSR matrix, row-stochastic
    """
    from scipy.spatial import cKDTree

    M = len(areas)
    centroids = np.ascontiguousarray(centroids, dtype=np.float64)
    areas = np.ascontiguousarray(areas, dtype=np.float64)
    tree = cKDTree(centroids)

    r_est = np.sqrt(target_area_m2 / np.pi) * _RADIUS_MARGIN

    # Batch query: get all neighbor lists at once (much faster than per-point).
    # workers=-1 spreads the query across all cores (bit-identical results).
    all_neighbors = tree.query_ball_point(centroids, r_est, workers=-1)

    if _HAS_NUMBA:
        return _precompute_numba(centroids, areas, all_neighbors, target_area_m2, M)

    return _precompute_numpy(centroids, areas, all_neighbors, target_area_m2, M)

averaging_matrix_to_jax

averaging_matrix_to_jax(G: csr_array)

Convert a scipy sparse averaging matrix to a JAX BCOO sparse array.

Uses JAX experimental sparse BCOO format instead of materializing a dense (M, M) matrix, which would OOM for meshes with >10k triangles.

Parameters

G : sparse averaging matrix from precompute_averaging_matrix

Returns

jax.experimental.sparse.BCOO : sparse JAX array supporting G_jax @ sab and automatic differentiation through the averaging step.

Source code in src/aegis/geometry/averaging.py
def averaging_matrix_to_jax(G: sparse.csr_array):
    """Convert a scipy sparse averaging matrix to a JAX BCOO sparse array.

    Uses JAX experimental sparse BCOO format instead of materializing a
    dense (M, M) matrix, which would OOM for meshes with >10k triangles.

    Parameters
    ----------
    G : sparse averaging matrix from ``precompute_averaging_matrix``

    Returns
    -------
    jax.experimental.sparse.BCOO : sparse JAX array supporting ``G_jax @ sab``
        and automatic differentiation through the averaging step.
    """
    try:
        import jax.numpy as jnp
        from jax.experimental.sparse import BCOO
    except ImportError as err:
        raise ImportError("JAX required for differentiable averaging") from err

    coo = G.tocoo()
    indices = jnp.column_stack([jnp.array(coo.row), jnp.array(coo.col)])
    data = jnp.array(coo.data)
    return BCOO((data, indices), shape=G.shape)

Cauchy formula

aegis.geometry.cauchy

Cauchy surface area formula: A_ab = A_total / 4 for convex bodies.

For non-convex bodies, A_ab = mean(A_perp) which equals A_total/4 only in the convex case.

cauchy_projected_area

cauchy_projected_area(total_area: float) -> float

Cauchy formula: mean projected area of a convex body = total_area / 4.

Source code in src/aegis/geometry/cauchy.py
def cauchy_projected_area(total_area: float) -> float:
    """Cauchy formula: mean projected area of a convex body = total_area / 4."""
    return total_area / 4.0

mean_projected_area

mean_projected_area(A_perp: ndarray) -> float

Mean projected area from a sampled LUT. Works for non-convex bodies.

Source code in src/aegis/geometry/cauchy.py
def mean_projected_area(A_perp: np.ndarray) -> float:
    """Mean projected area from a sampled LUT. Works for non-convex bodies."""
    return float(np.mean(A_perp))

cauchy_relative_error

cauchy_relative_error(A_perp: ndarray, total_area: float) -> float

Relative deviation of mean(A_perp) from the Cauchy value A_total/4.

Positive means the body "exposes more" than a convex body of the same surface area (self-occlusion reduces this for non-convex bodies).

Source code in src/aegis/geometry/cauchy.py
def cauchy_relative_error(A_perp: np.ndarray, total_area: float) -> float:
    """Relative deviation of mean(A_perp) from the Cauchy value A_total/4.

    Positive means the body "exposes more" than a convex body of the same
    surface area (self-occlusion reduces this for non-convex bodies).
    """
    cauchy = cauchy_projected_area(total_area)
    if cauchy == 0:
        return 0.0
    return (mean_projected_area(A_perp) - cauchy) / cauchy

Kernels

Level 0: worst-case bound

aegis.kernels.level0_bound

Level 0: Worst-case power bound.

level0_bound

level0_bound(total_area: float, A_ab: float, D_max: float, power: NDArray[floating], T0: float, n_triangles: int) -> tuple[NDArray[np.floating], np.floating]

Compute worst-case absorbed power bound.

Source code in src/aegis/kernels/level0_bound.py
@partial(jit, static_argnums=(0, 1, 2, 4, 5))
def level0_bound(
    total_area: float,
    A_ab: float,
    D_max: float,
    power: NDArray[np.floating],
    T0: float,
    n_triangles: int,
) -> tuple[NDArray[np.floating], np.floating]:
    """Compute worst-case absorbed power bound."""
    S_total = xp.sum(power)
    p_abs_bound = T0 * (A_ab * D_max / 4.0) * S_total
    sab_uniform = p_abs_bound / total_area if total_area > 0 else 0.0
    sab = xp.full(n_triangles, sab_uniform)
    return sab, p_abs_bound

Level 1: aggregate

aegis.kernels.level1_aggregate

Level 1: Aggregate absorbed power via directivity.

level1_aggregate

level1_aggregate(total_area: float, A_ab: float, k_hat: NDArray[floating], power: NDArray[floating], T0: float, n_triangles: int, sh_coeffs: NDArray[number] | None = None, sh_L: int = 4, D_table: NDArray[floating] | None = None, D_dirs: NDArray[floating] | None = None) -> tuple[NDArray[np.floating], float]

Compute aggregate absorbed power via directivity.

Source code in src/aegis/kernels/level1_aggregate.py
def level1_aggregate(
    total_area: float,
    A_ab: float,
    k_hat: NDArray[np.floating],
    power: NDArray[np.floating],
    T0: float,
    n_triangles: int,
    sh_coeffs: NDArray[np.number] | None = None,
    sh_L: int = 4,
    D_table: NDArray[np.floating] | None = None,
    D_dirs: NDArray[np.floating] | None = None,
) -> tuple[NDArray[np.floating], float]:
    """Compute aggregate absorbed power via directivity."""
    n_paths: int = int(k_hat.shape[0])

    directivity: NDArray[np.floating]
    if sh_coeffs is not None:
        theta, phi = spherical_angles_from_k_hat(np.asarray(k_hat))
        directivity = eval_sh(sh_coeffs, theta, phi, sh_L)
    elif D_table is not None and D_dirs is not None:
        dots = xp.asarray(k_hat) @ xp.asarray(D_dirs).T
        nearest = xp.argmax(dots, axis=1)
        directivity = xp.asarray(D_table)[nearest]
    else:
        directivity = np.ones(n_paths)

    p_abs = T0 * (A_ab / 4.0) * float(xp.sum(np.asarray(power) * directivity))

    sab_uniform = p_abs / total_area if total_area > 0 else 0.0
    sab = xp.full(n_triangles, sab_uniform)
    return sab, p_abs

Level 2: geometric

aegis.kernels.level2_geometric

Level 2: Geometric ReLU spatial map.

level2_geometric

level2_geometric(normals: NDArray[floating], k_hat: NDArray[floating], power: NDArray[floating], T0: float) -> NDArray[np.floating]

Compute per-triangle S_ab using the geometric absorption law.

Parameters

normals : (M, 3) unit outward normals k_hat : (N, 3) incident directions (unit vectors) power : (N,) per-path power density [W/m^2] T0 : normal-incidence transmission coefficient

Returns

sab : (M,) absorbed power density per triangle [W/m^2]

Source code in src/aegis/kernels/level2_geometric.py
@jit
def level2_geometric(
    normals: NDArray[np.floating],
    k_hat: NDArray[np.floating],
    power: NDArray[np.floating],
    T0: float,
) -> NDArray[np.floating]:
    """Compute per-triangle S_ab using the geometric absorption law.

    Parameters
    ----------
    normals : (M, 3) unit outward normals
    k_hat : (N, 3) incident directions (unit vectors)
    power : (N,) per-path power density [W/m^2]
    T0 : normal-incidence transmission coefficient

    Returns
    -------
    sab : (M,) absorbed power density per triangle [W/m^2]
    """
    _mu, mu_plus = incidence_geometry(normals, k_hat)
    sab = T0 * (mu_plus @ power)
    return sab

Level 3: Fresnel

aegis.kernels.level3_fresnel

Level 3: Exact Fresnel spatial map.

level3_fresnel

level3_fresnel(normals: NDArray[floating], k_hat: NDArray[floating], power: NDArray[floating], n_tilde: complex | NDArray[complexfloating]) -> NDArray[np.floating]

Compute per-triangle S_ab with angle-dependent Fresnel transmission.

Source code in src/aegis/kernels/level3_fresnel.py
@jit
def level3_fresnel(
    normals: NDArray[np.floating],
    k_hat: NDArray[np.floating],
    power: NDArray[np.floating],
    n_tilde: complex | NDArray[np.complexfloating],
) -> NDArray[np.floating]:
    """Compute per-triangle S_ab with angle-dependent Fresnel transmission."""
    mu, mu_plus = incidence_geometry(normals, k_hat)
    _T_s, _T_p, T_avg = fresnel_weights(mu, n_tilde)
    sab = (T_avg * mu_plus) @ power
    return sab

Level 4: polarisation

aegis.kernels.level4_polarisation

Level 4: Polarisation-aware Fresnel map.

level4_polarisation

level4_polarisation(normals: NDArray[floating], k_hat: NDArray[floating], power: NDArray[floating], n_tilde: complex | NDArray[complexfloating], q: float | NDArray[floating] = 0.0, psi: NDArray[complexfloating] | None = None) -> NDArray[np.floating]

Compute per-triangle S_ab with polarisation correction.

When psi is given the physical per-(triangle, path) TE/TM split is used (T_eff = w_s*T_s + w_p*T_p); otherwise the legacy scalar/array q knob applies (T_eff = T_avg + (q/2) DeltaT).

Source code in src/aegis/kernels/level4_polarisation.py
@jit
def level4_polarisation(
    normals: NDArray[np.floating],
    k_hat: NDArray[np.floating],
    power: NDArray[np.floating],
    n_tilde: complex | NDArray[np.complexfloating],
    q: float | NDArray[np.floating] = 0.0,
    psi: NDArray[np.complexfloating] | None = None,
) -> NDArray[np.floating]:
    """Compute per-triangle S_ab with polarisation correction.

    When ``psi`` is given the physical per-(triangle, path) TE/TM split is used
    (``T_eff = w_s*T_s + w_p*T_p``); otherwise the legacy scalar/array ``q``
    knob applies (``T_eff = T_avg + (q/2) DeltaT``).
    """
    mu, mu_plus = incidence_geometry(normals, k_hat)
    T_s, T_p, T_avg = fresnel_weights(mu, n_tilde)
    if psi is not None:
        w_s, w_p = te_tm_power_weights(normals, k_hat, psi)
        T_eff = w_s * T_s + w_p * T_p
    else:
        DeltaT = T_p - T_s
        T_eff = T_avg + 0.5 * q * DeltaT
    sab = (T_eff * mu_plus) @ power
    return sab

Level 5: curvature

aegis.kernels.level5_curvature

Level 5: Curvature correction.

level5_curvature

level5_curvature(normals: NDArray[floating], k_hat: NDArray[floating], power: NDArray[floating], n_tilde: complex | NDArray[complexfloating], T0: float, curvature_H: NDArray[floating], freq_hz: float) -> NDArray[np.floating]

Compute per-triangle S_ab with Fresnel + curvature correction.

Source code in src/aegis/kernels/level5_curvature.py
@jit
def level5_curvature(
    normals: NDArray[np.floating],
    k_hat: NDArray[np.floating],
    power: NDArray[np.floating],
    n_tilde: complex | NDArray[np.complexfloating],
    T0: float,
    curvature_H: NDArray[np.floating],
    freq_hz: float,
) -> NDArray[np.floating]:
    """Compute per-triangle S_ab with Fresnel + curvature correction."""
    # Floor k to avoid division by near-zero at very low frequencies
    k = xp.maximum(2.0 * xp.pi * freq_hz / C_0, 1e-6)

    mu, mu_plus = incidence_geometry(normals, k_hat)
    _T_s, _T_p, T_avg = fresnel_weights(mu, n_tilde)
    sab_base = (T_avg * mu_plus) @ power

    H_safe = xp.maximum(curvature_H, 0.0)
    sab_curvature = T0 * (H_safe / k) * xp.einsum("mn,mn,n->m", mu_plus, mu_plus, power)

    return xp.maximum(sab_base + sab_curvature, 0.0)

Level 6: diffraction

aegis.kernels.level6_diffraction

Level 6: Diffraction smoothing (ReLU -> physical GELU -> Fock).

level6_diffraction

level6_diffraction(normals: NDArray[floating], k_hat: NDArray[floating], power: NDArray[floating], n_tilde: complex | NDArray[complexfloating], T0: float, curvature_H: NDArray[floating], freq_hz: float, *, diffraction_model: str | None = None, fock_R: NDArray[floating] | None = None, q_F_s: complex | None = None, q_F_h: complex | None = None, clearance: NDArray[floating] | None = None, R_occ: NDArray[floating] | None = None, distal_d1: NDArray[floating] | None = None, distal_d2: NDArray[floating] | None = None) -> NDArray[np.floating]

Compute per-triangle S_ab with Fresnel + curvature + diffraction.

The shadow-edge gate is selected by diffraction_model: "none" is exact ReLU, "gelu" is the legacy physical-GELU smoothing (the historical default), "fock" is the smooth-convex-body (Fock) gate (requires fock_R from geometry.curvature.fock_radius). When diffraction_model is None the gate defaults to "gelu" to preserve the pre-selector behaviour of this kernel.

Source code in src/aegis/kernels/level6_diffraction.py
@jit(static_argnames=("diffraction_model", "q_F_s", "q_F_h"))
def level6_diffraction(
    normals: NDArray[np.floating],
    k_hat: NDArray[np.floating],
    power: NDArray[np.floating],
    n_tilde: complex | NDArray[np.complexfloating],
    T0: float,
    curvature_H: NDArray[np.floating],
    freq_hz: float,
    *,
    diffraction_model: str | None = None,
    fock_R: NDArray[np.floating] | None = None,
    q_F_s: complex | None = None,
    q_F_h: complex | None = None,
    clearance: NDArray[np.floating] | None = None,
    R_occ: NDArray[np.floating] | None = None,
    distal_d1: NDArray[np.floating] | None = None,
    distal_d2: NDArray[np.floating] | None = None,
) -> NDArray[np.floating]:
    """Compute per-triangle S_ab with Fresnel + curvature + diffraction.

    The shadow-edge gate is selected by ``diffraction_model``: ``"none"`` is
    exact ReLU, ``"gelu"`` is the legacy physical-GELU smoothing (the historical
    default), ``"fock"`` is the smooth-convex-body (Fock) gate (requires
    ``fock_R`` from ``geometry.curvature.fock_radius``). When
    ``diffraction_model`` is ``None`` the gate defaults to ``"gelu"`` to preserve
    the pre-selector behaviour of this kernel.
    """
    # This kernel has always applied the GELU gate, so the legacy default is
    # "gelu" (diffraction=True), mirroring spatial.py's bool mapping.
    model = resolve_diffraction_model(True, diffraction_model)

    wavelength = C_0 / freq_hz
    # Floor k to avoid division by near-zero at very low frequencies
    k = xp.maximum(2.0 * xp.pi / wavelength, 1e-6)

    mu = normals @ (-k_hat).T

    H_safe = xp.maximum(curvature_H, 0.0)

    # Activation gate: ReLU ("none"), GELU smoothing ("gelu"), or Fock ("fock").
    if model == "none":
        g = xp.maximum(mu, 0.0)
    elif model == "gelu":
        # Floor sigma to avoid derivative discontinuity at H=0 (for autodiff)
        sigma = xp.sqrt(xp.maximum(wavelength * H_safe / (4.0 * xp.pi), 1e-20))
        g = physical_gelu(mu, sigma)
    else:  # "fock"
        if fock_R is None:
            raise ValueError("fock_R is required when diffraction_model='fock'")
        # Incoherent, no incident polarisation state: equal TE/TM power split.
        R = fock_R[:, None] if fock_R.ndim == 1 else fock_R
        g = fock_local(mu, R, freq_hz, 0.5, 0.5, q_F_s, q_F_h)

    # Distal self-shadowing gate (same dual-width Fock/knife treatment as the
    # spatial kernel), gated on the would-be-lit response so the curvature term
    # below is gated by the same g.
    if clearance is not None:
        if R_occ is None or distal_d1 is None or distal_d2 is None:
            raise ValueError("clearance requires R_occ, distal_d1 and distal_d2")
        g_distal = distal_gate(
            clearance,
            R_occ,
            freq_hz,
            0.5,
            0.5,
            d1=distal_d1,
            d2=distal_d2,
            q_F_s=q_F_s,
            q_F_h=q_F_h,
            diffraction_model=model,
        )
        g = xp.where(mu > 0.0, g * g_distal, g)

    _T_s, _T_p, T_avg = fresnel_weights(mu, n_tilde)

    sab_base = (T_avg * g) @ power

    g_sq = g**2
    sab_curvature = T0 * ((H_safe / k)[:, None] * g_sq) @ power

    return xp.maximum(sab_base + sab_curvature, 0.0)

Level 7: coherent

aegis.kernels.level7_coherent

Level 7: coherent MIMO absorption map.

S_ab® = ||G_tilde® @ x||^2

Computes per-triangle absorbed power density from the body-surface channel G_tilde® and a precoding vector x. Uses Approximations 1 and 2 from the monograph (combined error < 5% for skin at 28 GHz).

Monograph: thm:coherent-law (Theorem 4.1).

level7_coherent

level7_coherent(normals: NDArray[floating], centroids: NDArray[floating], areas: NDArray[floating], k_hat: NDArray[floating], psi: NDArray[complexfloating], element_index: NDArray[integer], x: NDArray[complexfloating], n_tilde: complex | NDArray[complexfloating], sigma: float, freq_hz: float, n_elements: int, h: NDArray[complexfloating] | None = None, fock_R: NDArray[floating] | None = None, q_F_s: complex | None = None, q_F_h: complex | None = None, clearance: NDArray[floating] | None = None, R_occ: NDArray[floating] | None = None, distal_d1: NDArray[floating] | None = None, distal_d2: NDArray[floating] | None = None) -> tuple[NDArray[np.floating], NDArray[np.complexfloating], NDArray[np.floating], float | None]

Compute coherent absorbed power density map.

Parameters

normals : (M, 3) centroids : (M, 3) areas : (M,) k_hat : (N, 3) psi : (N, 3) element_index : (N,) x : (M_ant,) precoding vector n_tilde : complex refractive index sigma : tissue conductivity [S/m] freq_hz : frequency [Hz] n_elements : int h : (M_ant,) UE channel vector (optional, for rho computation) fock_R : (M,) or (M, N) or None In-incidence-plane curvature radius [m] for the Fock shadow gate. None disables the gate (exact GO/Fresnel channel, back-compat). q_F_s, q_F_h : complex or None Soft/hard impedance-Fock parameters (None selects the PEC gate).

Returns

sab : (M,) absorbed power density per triangle [W/m^2] Q : (M_ant, M_ant) exposure operator eigenvalues : (M_ant,) or None, eigenvalues of Q rho : float or None, exposure-signal alignment

Source code in src/aegis/kernels/level7_coherent.py
def level7_coherent(
    normals: NDArray[np.floating],
    centroids: NDArray[np.floating],
    areas: NDArray[np.floating],
    k_hat: NDArray[np.floating],
    psi: NDArray[np.complexfloating],
    element_index: NDArray[np.integer],
    x: NDArray[np.complexfloating],
    n_tilde: complex | NDArray[np.complexfloating],
    sigma: float,
    freq_hz: float,
    n_elements: int,
    h: NDArray[np.complexfloating] | None = None,
    fock_R: NDArray[np.floating] | None = None,
    q_F_s: complex | None = None,
    q_F_h: complex | None = None,
    clearance: NDArray[np.floating] | None = None,
    R_occ: NDArray[np.floating] | None = None,
    distal_d1: NDArray[np.floating] | None = None,
    distal_d2: NDArray[np.floating] | None = None,
) -> tuple[
    NDArray[np.floating],
    NDArray[np.complexfloating],
    NDArray[np.floating],
    float | None,
]:
    """Compute coherent absorbed power density map.

    Parameters
    ----------
    normals : (M, 3)
    centroids : (M, 3)
    areas : (M,)
    k_hat : (N, 3)
    psi : (N, 3)
    element_index : (N,)
    x : (M_ant,) precoding vector
    n_tilde : complex refractive index
    sigma : tissue conductivity [S/m]
    freq_hz : frequency [Hz]
    n_elements : int
    h : (M_ant,) UE channel vector (optional, for rho computation)
    fock_R : (M,) or (M, N) or None
        In-incidence-plane curvature radius [m] for the Fock shadow gate. ``None``
        disables the gate (exact GO/Fresnel channel, back-compat).
    q_F_s, q_F_h : complex or None
        Soft/hard impedance-Fock parameters (``None`` selects the PEC gate).

    Returns
    -------
    sab : (M,) absorbed power density per triangle [W/m^2]
    Q : (M_ant, M_ant) exposure operator
    eigenvalues : (M_ant,) or None, eigenvalues of Q
    rho : float or None, exposure-signal alignment
    """
    # Process triangles in blocks. Building G_tilde for all M triangles at once
    # peaks at the (M, N, 3) body-channel intermediate, which overflows a GPU at
    # full resolution. Q is a triangle-sum and sab is per-triangle, so blocking
    # gives the identical result (up to floating-point summation order) at a
    # bounded memory footprint.
    M = normals.shape[0]
    n_paths = int(np.asarray(k_hat).shape[0]) if np.asarray(k_hat).ndim else 0
    chunk = _triangle_chunk(M, n_paths)

    sab_blocks: list[NDArray[np.floating]] = []
    # Lowercase accumulator inside the loop: Q is bound once, after the loop, so
    # the all-caps return name is a single definition, not a reassigned constant.
    q_acc: NDArray[np.complexfloating] = xp.zeros((n_elements, n_elements), dtype=complex)
    # Slice the per-triangle Fock radius to match each triangle block. A (M, N)
    # radius (per path) is row-sliced too; a scalar/None passes through unchanged.
    fock_R_arr = None if fock_R is None else xp.asarray(fock_R)
    # The distal arrays are per-(triangle, path), so row-slice them per block.
    clr_arr = None if clearance is None else xp.asarray(clearance)
    R_occ_arr = None if R_occ is None else xp.asarray(R_occ)
    d1_arr = None if distal_d1 is None else xp.asarray(distal_d1)
    d2_arr = None if distal_d2 is None else xp.asarray(distal_d2)

    for start in range(0, M, chunk):
        sl = slice(start, start + chunk)
        fock_R_blk = None if fock_R_arr is None else fock_R_arr[sl]
        G_blk = compute_body_channel(
            normals[sl],
            centroids[sl],
            k_hat,
            psi,
            element_index,
            n_tilde,
            sigma,
            freq_hz,
            n_elements,
            fock_R=fock_R_blk,
            q_F_s=q_F_s,
            q_F_h=q_F_h,
            clearance=None if clr_arr is None else clr_arr[sl],
            R_occ=None if R_occ_arr is None else R_occ_arr[sl],
            distal_d1=None if d1_arr is None else d1_arr[sl],
            distal_d2=None if d2_arr is None else d2_arr[sl],
        )  # (B, 3, M_ant)

        # S_ab(r) = ||G_tilde(r) @ x||^2 for this block
        field = xp.einsum("mia,a->mi", G_blk, x)  # (B, 3)
        power: NDArray[np.floating] = xp.real(xp.sum(xp.conj(field) * field, axis=1))
        sab_blocks.append(xp.maximum(power, 0.0))

        # Q accumulates over triangles (each block already Hermitian-symmetrised)
        q_acc = q_acc + compute_exposure_operator(G_blk, areas[sl])

    sab: NDArray[np.floating] = sab_blocks[0] if len(sab_blocks) == 1 else xp.concatenate(sab_blocks)
    Q: NDArray[np.complexfloating] = q_acc
    eigenvalues, _ = eigendecompose_Q(Q)

    # Exposure-signal alignment rho
    rho: float | None = None
    if h is not None:
        rho = compute_rho(h, Q, lambda_max=float(eigenvalues[0]))

    return sab, Q, eigenvalues, rho

Level 8: ECBF

aegis.kernels.level8_ecbf

Level 8: exposure-constrained beamforming (ECBF).

Same as Level 7, but solves the QCQP to find the optimal precoder x* that maximizes signal power |h^T x|^2 subject to absorbed power and transmit power constraints.

Monograph: sec:ecbf, eq:QCQP, eq:optimal-x.

level8_ecbf

level8_ecbf(normals: NDArray[floating], centroids: NDArray[floating], areas: NDArray[floating], k_hat: NDArray[floating], psi: NDArray[complexfloating], element_index: NDArray[integer], h: NDArray[complexfloating], n_tilde: complex | NDArray[complexfloating], sigma: float, freq_hz: float, n_elements: int, P: float = 1.0, P_abs_max: float = DEFAULT_P_ABS_MAX, fock_R: NDArray[floating] | None = None, q_F_s: complex | None = None, q_F_h: complex | None = None, clearance: NDArray[floating] | None = None, R_occ: NDArray[floating] | None = None, distal_d1: NDArray[floating] | None = None, distal_d2: NDArray[floating] | None = None) -> tuple[NDArray[np.floating], NDArray[np.complexfloating], NDArray[np.floating], NDArray[np.complexfloating], float]

Compute ECBF-optimised absorbed power density map.

Parameters

normals : (M, 3) centroids : (M, 3) areas : (M,) k_hat : (N, 3) psi : (N, 3) element_index : (N,) h : (M_ant,) UE channel vector n_tilde : complex refractive index sigma : tissue conductivity [S/m] freq_hz : frequency [Hz] n_elements : int P : total transmit power [W] P_abs_max : maximum absorbed power [W] fock_R : (M,) or (M, N) or None In-incidence-plane curvature radius [m] for the Fock shadow gate. None disables the gate (exact GO/Fresnel channel, back-compat). q_F_s, q_F_h : complex or None Soft/hard impedance-Fock parameters (None selects the PEC gate).

Returns

sab : (M,) absorbed power density per triangle [W/m^2] Q : (M_ant, M_ant) exposure operator eigenvalues : (M_ant,) eigenvalues of Q (descending) x_star : (M_ant,) optimal precoding vector rho : float, exposure-signal alignment

Source code in src/aegis/kernels/level8_ecbf.py
def level8_ecbf(
    normals: NDArray[np.floating],
    centroids: NDArray[np.floating],
    areas: NDArray[np.floating],
    k_hat: NDArray[np.floating],
    psi: NDArray[np.complexfloating],
    element_index: NDArray[np.integer],
    h: NDArray[np.complexfloating],
    n_tilde: complex | NDArray[np.complexfloating],
    sigma: float,
    freq_hz: float,
    n_elements: int,
    P: float = 1.0,
    P_abs_max: float = DEFAULT_P_ABS_MAX,
    fock_R: NDArray[np.floating] | None = None,
    q_F_s: complex | None = None,
    q_F_h: complex | None = None,
    clearance: NDArray[np.floating] | None = None,
    R_occ: NDArray[np.floating] | None = None,
    distal_d1: NDArray[np.floating] | None = None,
    distal_d2: NDArray[np.floating] | None = None,
) -> tuple[
    NDArray[np.floating],
    NDArray[np.complexfloating],
    NDArray[np.floating],
    NDArray[np.complexfloating],
    float,
]:
    """Compute ECBF-optimised absorbed power density map.

    Parameters
    ----------
    normals : (M, 3)
    centroids : (M, 3)
    areas : (M,)
    k_hat : (N, 3)
    psi : (N, 3)
    element_index : (N,)
    h : (M_ant,) UE channel vector
    n_tilde : complex refractive index
    sigma : tissue conductivity [S/m]
    freq_hz : frequency [Hz]
    n_elements : int
    P : total transmit power [W]
    P_abs_max : maximum absorbed power [W]
    fock_R : (M,) or (M, N) or None
        In-incidence-plane curvature radius [m] for the Fock shadow gate. ``None``
        disables the gate (exact GO/Fresnel channel, back-compat).
    q_F_s, q_F_h : complex or None
        Soft/hard impedance-Fock parameters (``None`` selects the PEC gate).

    Returns
    -------
    sab : (M,) absorbed power density per triangle [W/m^2]
    Q : (M_ant, M_ant) exposure operator
    eigenvalues : (M_ant,) eigenvalues of Q (descending)
    x_star : (M_ant,) optimal precoding vector
    rho : float, exposure-signal alignment
    """
    G_tilde = compute_body_channel(
        normals,
        centroids,
        k_hat,
        psi,
        element_index,
        n_tilde,
        sigma,
        freq_hz,
        n_elements,
        fock_R=fock_R,
        q_F_s=q_F_s,
        q_F_h=q_F_h,
        clearance=clearance,
        R_occ=R_occ,
        distal_d1=distal_d1,
        distal_d2=distal_d2,
    )

    # Exposure operator Q
    Q = compute_exposure_operator(G_tilde, areas)
    eigenvalues, _ = eigendecompose_Q(Q)

    # Solve ECBF QCQP
    x_star = solve_ecbf(h, Q, P_abs_max, P)

    # S_ab with optimal precoder
    field = xp.einsum("mia,a->mi", G_tilde, x_star)  # (M, 3)
    sab = xp.real(xp.sum(xp.conj(field) * field, axis=1))  # (M,)
    sab = xp.maximum(sab, 0.0)

    # Exposure-signal alignment
    rho = compute_rho(h, Q, lambda_max=float(eigenvalues[0]))

    return sab, Q, eigenvalues, x_star, rho

Coherent module

Fresnel operator

aegis.coherent.fresnel_operator

Fresnel transmission operator F_n® for coherent dosimetry.

Each path n at each surface point r has a rank-2 operator that projects the incident polarisation-amplitude vector onto TE/TM components and scales by the complex Fresnel transmission coefficients t_s, t_p.

F_n(r) = t_s * e_s @ e_s^T + t_p * e_p @ e_p^T     (front-facing)
F_n(r) = 0                                            (back-facing)

Monograph: Definition in sec:fresnel-operator, Approximation 1 applied.

te_tm_basis

te_tm_basis(k_hat: NDArray[floating], normals: NDArray[floating]) -> tuple[NDArray[np.floating], NDArray[np.floating]]

Compute TE and TM basis vectors for each (path, triangle) pair.

Parameters

k_hat : (N, 3) Unit directions of arrival. normals : (M, 3) Unit outward normals per triangle.

Returns

e_s : (M, N, 3) TE (s-polarisation) unit vectors. e_p : (M, N, 3) TM (p-polarisation) unit vectors.

Source code in src/aegis/coherent/fresnel_operator.py
def te_tm_basis(
    k_hat: NDArray[np.floating],
    normals: NDArray[np.floating],
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
    """Compute TE and TM basis vectors for each (path, triangle) pair.

    Parameters
    ----------
    k_hat : (N, 3)
        Unit directions of arrival.
    normals : (M, 3)
        Unit outward normals per triangle.

    Returns
    -------
    e_s : (M, N, 3)
        TE (s-polarisation) unit vectors.
    e_p : (M, N, 3)
        TM (p-polarisation) unit vectors.
    """
    M = normals.shape[0]
    N = k_hat.shape[0]

    # e_s = k_hat x n / |k_hat x n|  for each (m, n) pair
    # normals: (M, 1, 3), k_hat: (1, N, 3) -> cross: (M, N, 3)
    cross = xp.cross(k_hat[None, :, :], normals[:, None, :])
    cross_norm = xp.linalg.norm(cross, axis=2, keepdims=True)

    # At normal incidence (k_hat parallel to n), cross product is zero.
    # Use an arbitrary perpendicular direction as fallback.
    abs_k = xp.abs(k_hat)
    min_ax = xp.argmin(abs_k, axis=1)
    if JAX_AVAILABLE:
        ref = xp.zeros((N, 3))
        ref = ref.at[xp.arange(N), min_ax].set(1.0)
    else:
        ref = _set_ref_numpy(N, min_ax)
    fb = xp.cross(k_hat, ref)
    fb_norm = xp.linalg.norm(fb, axis=1, keepdims=True)
    fb = fb / xp.where(fb_norm > 0, fb_norm, 1.0)
    fallback = fb[None, :, :]

    small = cross_norm < 1e-12
    e_s = xp.where(small, xp.broadcast_to(fallback, (M, N, 3)), cross)
    e_s_norm = xp.linalg.norm(e_s, axis=2, keepdims=True)
    e_s = e_s / xp.where(e_s_norm > 0, e_s_norm, 1.0)

    # e_p = e_s x k_hat (incident TM direction, Approximation 1)
    e_p = xp.cross(e_s, k_hat[None, :, :])
    e_p_norm = xp.linalg.norm(e_p, axis=2, keepdims=True)
    e_p = e_p / xp.where(e_p_norm > 0, e_p_norm, 1.0)

    return e_s, e_p

fresnel_coeffs_from_mu

fresnel_coeffs_from_mu(mu, n_tilde)

TE/TM transmission amplitudes from a precomputed incidence cosine.

The frequency- and tissue-dependent half of :func:compute_fresnel_operator: given mu = n_hat . (-k_hat) (pure geometry, freq-invariant) and the complex refractive index n_tilde, returns the gated (t_s, t_p). Split out so a multi-frequency sweep over fixed geometry can reuse mu (and the TE/TM basis) and recompute only this part per frequency.

Returns (t_s, t_p) each shaped like mu, zero for back-facing paths.

Source code in src/aegis/coherent/fresnel_operator.py
def fresnel_coeffs_from_mu(mu, n_tilde):
    """TE/TM transmission amplitudes from a precomputed incidence cosine.

    The frequency- and tissue-dependent half of :func:`compute_fresnel_operator`:
    given ``mu = n_hat . (-k_hat)`` (pure geometry, freq-invariant) and the
    complex refractive index ``n_tilde``, returns the gated ``(t_s, t_p)``. Split
    out so a multi-frequency sweep over fixed geometry can reuse ``mu`` (and the
    TE/TM basis) and recompute only this part per frequency.

    Returns ``(t_s, t_p)`` each shaped like ``mu``, zero for back-facing paths.
    """
    # _fresnel_core is element-wise; pass (M, N) directly, no ravel needed
    mu_complex = xp.asarray(mu, dtype=complex)
    _, _, _, _, t_s_out, t_p_out = _fresnel_core(mu_complex, n_tilde)

    # Heaviside gate: zero for back-facing paths
    mask = mu > 0
    t_s_out = xp.where(mask, t_s_out, 0.0 + 0j)
    t_p_out = xp.where(mask, t_p_out, 0.0 + 0j)
    return t_s_out, t_p_out

compute_fresnel_operator

compute_fresnel_operator(normals, k_hat, n_tilde)

Compute Fresnel operator components for each (triangle, path) pair.

Returns the ingredients needed to build F_n® * psi_n for each pair.

Parameters

normals : (M, 3) Unit outward normals. k_hat : (N, 3) Unit directions of arrival. n_tilde : complex Complex refractive index.

Returns

mu : (M, N) Incidence cosine n_hat . (-k_hat). Negative for back-facing. t_s : (M, N) Complex TE transmission amplitude. Zero where mu <= 0. t_p : (M, N) Complex TM transmission amplitude. Zero where mu <= 0. e_s : (M, N, 3) TE basis vectors. e_p : (M, N, 3) TM basis vectors.

Source code in src/aegis/coherent/fresnel_operator.py
def compute_fresnel_operator(
    normals,
    k_hat,
    n_tilde,
):
    """Compute Fresnel operator components for each (triangle, path) pair.

    Returns the ingredients needed to build F_n(r) * psi_n for each pair.

    Parameters
    ----------
    normals : (M, 3)
        Unit outward normals.
    k_hat : (N, 3)
        Unit directions of arrival.
    n_tilde : complex
        Complex refractive index.

    Returns
    -------
    mu : (M, N)
        Incidence cosine n_hat . (-k_hat). Negative for back-facing.
    t_s : (M, N)
        Complex TE transmission amplitude. Zero where mu <= 0.
    t_p : (M, N)
        Complex TM transmission amplitude. Zero where mu <= 0.
    e_s : (M, N, 3)
        TE basis vectors.
    e_p : (M, N, 3)
        TM basis vectors.
    """
    # mu = n_hat . (-k_hat), shape (M, N)
    mu = normals @ (-k_hat).T

    # Fresnel amplitude coefficients (vectorised over all M*N pairs)
    t_s_out, t_p_out = fresnel_coeffs_from_mu(mu, n_tilde)

    # TE/TM basis vectors
    e_s, e_p = te_tm_basis(k_hat, normals)

    return mu, t_s_out, t_p_out, e_s, e_p

apply_fresnel_operator

apply_fresnel_operator(psi, t_s, t_p, e_s, e_p)

Apply Fresnel operator: F_n® @ psi_n for each (triangle, path) pair.

F_n @ psi = t_s * (e_s . psi) * e_s + t_p * (e_p . psi) * e_p

Parameters

psi : (N, 3) Complex polarisation-amplitude vectors. t_s : (M, N) TE amplitude coefficients. t_p : (M, N) TM amplitude coefficients. e_s : (M, N, 3) TE basis vectors. e_p : (M, N, 3) TM basis vectors.

Returns

F_psi : (M, N, 3) Fresnel-transmitted field component per (triangle, path).

Source code in src/aegis/coherent/fresnel_operator.py
def apply_fresnel_operator(
    psi,
    t_s,
    t_p,
    e_s,
    e_p,
):
    """Apply Fresnel operator: F_n(r) @ psi_n for each (triangle, path) pair.

    F_n @ psi = t_s * (e_s . psi) * e_s + t_p * (e_p . psi) * e_p

    Parameters
    ----------
    psi : (N, 3)
        Complex polarisation-amplitude vectors.
    t_s : (M, N)
        TE amplitude coefficients.
    t_p : (M, N)
        TM amplitude coefficients.
    e_s : (M, N, 3)
        TE basis vectors.
    e_p : (M, N, 3)
        TM basis vectors.

    Returns
    -------
    F_psi : (M, N, 3)
        Fresnel-transmitted field component per (triangle, path).
    """
    # psi_s = e_s . psi, psi_p = e_p . psi  (scalar projections)
    # einsum avoids creating (M, N, 3) broadcast intermediate
    psi_s = xp.einsum("mnj,nj->mn", e_s, psi)  # (M, N)
    psi_p = xp.einsum("mnj,nj->mn", e_p, psi)  # (M, N)

    # F @ psi = t_s * psi_s * e_s + t_p * psi_p * e_p
    F_psi = (t_s * psi_s)[:, :, None] * e_s + (t_p * psi_p)[:, :, None] * e_p

    return F_psi

Field channel

aegis.coherent.field_channel

Field channel matrix G® construction from propagation paths.

G® = [g_1®, ..., g_M®] in C^{3 x M_ant} where g_j® = sum_{n: j(n)=j} psi_n * exp(-i*k0 * k_hat_n . r)

The total electric field at surface point r is E® = G® @ x.

Monograph: Definition in sec:field-channel, eq:G-def.

compute_field_channel

compute_field_channel(centroids, k_hat, psi, element_index, freq_hz, n_elements)

Build the field channel matrix G® at each triangle centroid.

Parameters

centroids : (M, 3) Triangle centroid positions [m]. k_hat : (N, 3) Unit directions of arrival. psi : (N, 3) Complex polarisation-amplitude vectors. element_index : (N,) Antenna element index j(n) for each path. freq_hz : float Frequency [Hz]. n_elements : int Total number of antenna elements M_ant.

Returns

G : (M_tri, 3, M_ant) Field channel matrix at each triangle centroid.

Source code in src/aegis/coherent/field_channel.py
def compute_field_channel(
    centroids,
    k_hat,
    psi,
    element_index,
    freq_hz,
    n_elements,
):
    """Build the field channel matrix G(r) at each triangle centroid.

    Parameters
    ----------
    centroids : (M, 3)
        Triangle centroid positions [m].
    k_hat : (N, 3)
        Unit directions of arrival.
    psi : (N, 3)
        Complex polarisation-amplitude vectors.
    element_index : (N,)
        Antenna element index j(n) for each path.
    freq_hz : float
        Frequency [Hz].
    n_elements : int
        Total number of antenna elements M_ant.

    Returns
    -------
    G : (M_tri, 3, M_ant)
        Field channel matrix at each triangle centroid.
    """
    M = centroids.shape[0]

    # Validate element_index bounds to prevent silent data loss
    element_index = np.asarray(element_index)
    if element_index.size > 0:
        idx_min, idx_max = int(element_index.min()), int(element_index.max())
        if idx_min < 0 or idx_max >= n_elements:
            raise ValueError(f"element_index values must be in [0, {n_elements}), got range [{idx_min}, {idx_max}]")

    k0 = 2 * xp.pi * freq_hz / C_0

    # Phase: exp(-i*k0 * k_hat_n . r_m) for each (m, n)
    phase_arg = -k0 * (centroids @ k_hat.T)  # (M, N)
    phase = xp.exp(1j * phase_arg)  # (M, N)

    # psi_n * phase_mn: (M, N, 3) = phase[:,:,None] * psi[None,:,:]
    weighted = phase[:, :, None] * psi[None, :, :]  # (M, N, 3)

    # Accumulate by element: g_j(r_m) = sum_{n: j(n)=j} weighted[m, n, :]
    return accumulate_by_element(weighted, element_index, M, n_elements)

Body channel

aegis.coherent.body_channel

Body-surface channel G_tilde® with Fresnel filtering and depth coupling.

G_tilde® = [g_tilde_1®, ..., g_tilde_M®] in C^{3 x M_ant} where g_tilde_j® = sum_{n: j(n)=j} sqrt(sigma/(4*alpha_n)) * F_n® @ psi_n * exp(-i*k0*k_hat_n.r)

S_ab® = ||G_tilde® @ x||^2 (Theorem 4.1, coherent absorption law)

Uses Approximation 2: depth coupling factors Gamma_{nn'} ~ 1 (error < 0.44% for skin at 28 GHz), which allows the double sum to factor into a squared norm.

Monograph: eq:Gtilde-def, thm:coherent-law.

BodyChannelGeometry dataclass

Frequency-invariant geometry for the simple (ungated) body channel.

Holds the parts of G_tilde that depend only on geometry, not on frequency or tissue: the incidence cosine mu, the TE/TM basis vectors, and the geometric phase dot centroids @ k_hat.T. Built once per (body chunk, ray set) and reused across a frequency sweep by :func:body_channel_from_geometry.

Source code in src/aegis/coherent/body_channel.py
@dataclass
class BodyChannelGeometry:
    """Frequency-invariant geometry for the simple (ungated) body channel.

    Holds the parts of G_tilde that depend only on geometry, not on frequency or
    tissue: the incidence cosine ``mu``, the TE/TM basis vectors, and the
    geometric phase dot ``centroids @ k_hat.T``. Built once per (body chunk, ray
    set) and reused across a frequency sweep by :func:`body_channel_from_geometry`.
    """

    mu: np.ndarray  # (M, N) incidence cosine n_hat . (-k_hat)
    e_s: np.ndarray  # (M, N, 3) TE basis
    e_p: np.ndarray  # (M, N, 3) TM basis
    geom_phase: np.ndarray  # (M, N) = centroids @ k_hat.T (phase before -k0 scale)
    psi: np.ndarray  # (N, 3) polarisation-amplitude vectors (passthrough)
    element_index: np.ndarray  # (N,) antenna element per path
    n_triangles: int
    n_elements: int

compute_body_channel

compute_body_channel(normals: ndarray, centroids: ndarray, k_hat: ndarray, psi: ndarray, element_index: ndarray, n_tilde: complex | ndarray, sigma: float, freq_hz: float, n_elements: int, fock_R: ndarray | None = None, q_F_s: complex | None = None, q_F_h: complex | None = None, clearance: ndarray | None = None, R_occ: ndarray | None = None, distal_d1: ndarray | None = None, distal_d2: ndarray | None = None) -> np.ndarray

Build the body-surface channel G_tilde® at each triangle centroid.

Parameters

normals : (M, 3) Unit outward normals. centroids : (M, 3) Triangle centroid positions [m]. k_hat : (N, 3) Unit directions of arrival. psi : (N, 3) Complex polarisation-amplitude vectors. element_index : (N,) Antenna element index j(n) for each path. n_tilde : complex Complex refractive index of tissue. sigma : float Tissue conductivity [S/m]. freq_hz : float Frequency [Hz]. n_elements : int Total number of antenna elements M_ant. fock_R : (M,) or (M, N) or None In-incidence-plane radius of curvature [m] for the Fock shadow gate. None (default) disables the gate, reproducing the ungated channel bit-for-bit (back-compat). q_F_s, q_F_h : complex or None Impedance-Fock parameters for the soft (TE) and hard (TM) creeping constants. None selects the PEC Fock gate.

Returns

G_tilde : (M_tri, 3, M_ant) Body-surface channel matrix at each triangle centroid.

Source code in src/aegis/coherent/body_channel.py
def compute_body_channel(
    normals: np.ndarray,
    centroids: np.ndarray,
    k_hat: np.ndarray,
    psi: np.ndarray,
    element_index: np.ndarray,
    n_tilde: complex | np.ndarray,
    sigma: float,
    freq_hz: float,
    n_elements: int,
    fock_R: np.ndarray | None = None,
    q_F_s: complex | None = None,
    q_F_h: complex | None = None,
    clearance: np.ndarray | None = None,
    R_occ: np.ndarray | None = None,
    distal_d1: np.ndarray | None = None,
    distal_d2: np.ndarray | None = None,
) -> np.ndarray:
    """Build the body-surface channel G_tilde(r) at each triangle centroid.

    Parameters
    ----------
    normals : (M, 3)
        Unit outward normals.
    centroids : (M, 3)
        Triangle centroid positions [m].
    k_hat : (N, 3)
        Unit directions of arrival.
    psi : (N, 3)
        Complex polarisation-amplitude vectors.
    element_index : (N,)
        Antenna element index j(n) for each path.
    n_tilde : complex
        Complex refractive index of tissue.
    sigma : float
        Tissue conductivity [S/m].
    freq_hz : float
        Frequency [Hz].
    n_elements : int
        Total number of antenna elements M_ant.
    fock_R : (M,) or (M, N) or None
        In-incidence-plane radius of curvature [m] for the Fock shadow gate.
        ``None`` (default) disables the gate, reproducing the ungated channel
        bit-for-bit (back-compat).
    q_F_s, q_F_h : complex or None
        Impedance-Fock parameters for the soft (TE) and hard (TM) creeping
        constants. ``None`` selects the PEC Fock gate.

    Returns
    -------
    G_tilde : (M_tri, 3, M_ant)
        Body-surface channel matrix at each triangle centroid.
    """
    M = normals.shape[0]

    # Validate element_index bounds to prevent silent data loss
    element_index = np.asarray(element_index)
    if element_index.size > 0:
        idx_min, idx_max = int(element_index.min()), int(element_index.max())
        if idx_min < 0 or idx_max >= n_elements:
            raise ValueError(f"element_index values must be in [0, {n_elements}), got range [{idx_min}, {idx_max}]")

    # Fast path for the common ungated case (no Fock shadow gate, no distal
    # self-shadow): route through the geometry / frequency split. Bit-identical
    # to the full path below for these inputs (it is the same operations, just
    # factored), and it lets a multi-frequency sweep hoist the geometry via
    # precompute_body_channel_geometry. The Fock / distal branch keeps the
    # original inline path unchanged.
    if fock_R is None and clearance is None:
        geom = precompute_body_channel_geometry(normals, centroids, k_hat, psi, element_index, n_elements)
        return body_channel_from_geometry(geom, n_tilde, sigma, freq_hz)

    k0 = 2 * xp.pi * freq_hz / C_0

    # Fresnel operator components
    mu, t_s, t_p, e_s, e_p = compute_fresnel_operator(normals, k_hat, n_tilde)

    # F_n(r) @ psi_n for each (m, n): shape (M, N, 3). With the Fock gate the
    # soft/hard creeping constants fold into the TE/TM transmission coefficients
    # (a per-(m, n) scalar multiplier), so the canonical Fresnel operator applies
    # unchanged. This is identical to gating the combined output because the
    # projection is linear in t_s/t_p.
    if fock_R is None:
        F_psi = apply_fresnel_operator(psi, t_s, t_p, e_s, e_p)
    else:
        g_soft, g_hard = _fock_gate_factors(mu, fock_R, freq_hz, q_F_s, q_F_h)
        F_psi = apply_fresnel_operator(psi, g_soft * t_s, g_hard * t_p, e_s, e_p)

    # Depth coupling weight: sqrt(sigma / (4 * alpha_n))
    # alpha_n is the amplitude decay rate: k0*xi = beta - i*alpha, so alpha = -Im(k0*xi)
    # xi depends on incidence angle, so alpha varies per (m, n) pair
    # xi_from_mu is element-wise; pass (M, N) directly, no ravel needed
    xi = xi_from_mu(mu, n_tilde)
    k0_xi = k0 * xi
    alpha = -xp.imag(k0_xi)  # (M, N), amplitude decay rate [1/m]
    alpha = xp.maximum(alpha, NUMERICAL_FLOOR)  # avoid division by zero

    depth_weight = xp.sqrt(sigma / (4 * alpha))  # (M, N)

    # Phase: exp(-i*k0 * k_hat_n . r_m)
    phase_arg = -k0 * (centroids @ k_hat.T)  # (M, N)
    phase = xp.exp(1j * phase_arg)

    # Weighted contribution per (m, n):
    # w_{m,n} = depth_weight * F_psi * phase
    weighted = depth_weight[:, :, None] * F_psi * phase[:, :, None]
    # weighted: (M, N, 3) complex

    # Distal self-shadowing (A3 approximation, DECISIONS L9 / sec_08): fold the
    # real amplitude sqrt(G_d) into the channel on the would-be-lit response
    # (mu > 0), keeping the existing plane-wave phase. Correct amplitude,
    # approximate interference phase; never overpredicts.
    weighted = _apply_distal_amplitude(weighted, mu, clearance, R_occ, distal_d1, distal_d2, freq_hz, q_F_s, q_F_h)

    # Accumulate by element
    return accumulate_by_element(weighted, element_index, M, n_elements)

precompute_body_channel_geometry

precompute_body_channel_geometry(normals, centroids, k_hat, psi, element_index, n_elements) -> BodyChannelGeometry

Build the frequency-invariant geometry for the simple body channel.

Pairs with :func:body_channel_from_geometry. For a single frequency the two together reproduce :func:compute_body_channel (ungated: no Fock gate, no distal shadow) bit-for-bit. The payoff is a frequency sweep over fixed geometry: mu, the TE/TM basis, and the geometric phase are computed once and reused for every frequency, so only the cheaper Fresnel / depth / phase assembly re-runs per frequency. On the studio 6-frequency grid that removes the dominant Fresnel-basis cost from five of every six builds.

Source code in src/aegis/coherent/body_channel.py
def precompute_body_channel_geometry(normals, centroids, k_hat, psi, element_index, n_elements) -> BodyChannelGeometry:
    """Build the frequency-invariant geometry for the simple body channel.

    Pairs with :func:`body_channel_from_geometry`. For a single frequency the two
    together reproduce :func:`compute_body_channel` (ungated: no Fock gate, no
    distal shadow) bit-for-bit. The payoff is a frequency sweep over fixed
    geometry: ``mu``, the TE/TM basis, and the geometric phase are computed once
    and reused for every frequency, so only the cheaper Fresnel / depth / phase
    assembly re-runs per frequency. On the studio 6-frequency grid that removes
    the dominant Fresnel-basis cost from five of every six builds.
    """
    element_index = np.asarray(element_index)
    if element_index.size > 0:
        idx_min, idx_max = int(element_index.min()), int(element_index.max())
        if idx_min < 0 or idx_max >= n_elements:
            raise ValueError(f"element_index values must be in [0, {n_elements}), got range [{idx_min}, {idx_max}]")

    # Keep the geometry on the active backend (device arrays under JAX) so a
    # frequency sweep reuses one on-device copy instead of re-transferring it per
    # frequency. Under JAX the whole geometry is one jitted dispatch
    # (:func:`_geometry_body_channel_jax`); under NumPy these are plain eager
    # arrays, bit-identical to compute_body_channel.
    if JAX_AVAILABLE:
        mu, e_s, e_p, geom_phase = _geometry_body_channel_jax(normals, centroids, k_hat)
    else:
        mu = xp.asarray(normals @ (-k_hat).T)  # (M, N)
        e_s, e_p = te_tm_basis(k_hat, normals)
        geom_phase = xp.asarray(centroids @ k_hat.T)  # (M, N)
    return BodyChannelGeometry(
        mu=mu,
        e_s=e_s,
        e_p=e_p,
        geom_phase=geom_phase,
        psi=xp.asarray(psi),
        element_index=xp.asarray(element_index),
        n_triangles=normals.shape[0],
        n_elements=n_elements,
    )

body_channel_from_geometry

body_channel_from_geometry(geom: BodyChannelGeometry, n_tilde, sigma, freq_hz) -> np.ndarray

Assemble G_tilde for one frequency from precomputed geometry.

The frequency- and tissue-dependent half of the simple (ungated) body channel: Fresnel transmission, depth coupling, and the plane-wave phase, reusing the cached mu / TE-TM basis / geometric phase from :func:precompute_body_channel_geometry. The operations match :func:compute_body_channel exactly, so the NumPy result is bit-identical; under JAX the fused kernel (:func:_assemble_body_channel_jax) matches to FP32 reduction-order noise.

Source code in src/aegis/coherent/body_channel.py
def body_channel_from_geometry(geom: BodyChannelGeometry, n_tilde, sigma, freq_hz) -> np.ndarray:
    """Assemble G_tilde for one frequency from precomputed geometry.

    The frequency- and tissue-dependent half of the simple (ungated) body
    channel: Fresnel transmission, depth coupling, and the plane-wave phase,
    reusing the cached ``mu`` / TE-TM basis / geometric phase from
    :func:`precompute_body_channel_geometry`. The operations match
    :func:`compute_body_channel` exactly, so the NumPy result is bit-identical;
    under JAX the fused kernel (:func:`_assemble_body_channel_jax`) matches to
    FP32 reduction-order noise.
    """
    if JAX_AVAILABLE:
        params = xp.asarray([n_tilde, sigma + 0j, freq_hz + 0j])
        return _assemble_body_channel_jax(
            geom.mu,
            geom.e_s,
            geom.e_p,
            geom.geom_phase,
            geom.psi,
            geom.element_index,
            params,
            geom.n_triangles,
            geom.n_elements,
        )

    k0 = 2 * xp.pi * freq_hz / C_0

    t_s, t_p = fresnel_coeffs_from_mu(geom.mu, n_tilde)
    F_psi = apply_fresnel_operator(geom.psi, t_s, t_p, geom.e_s, geom.e_p)

    # Depth coupling weight sqrt(sigma / (4 * alpha)), alpha = -Im(k0 * xi).
    xi = xi_from_mu(geom.mu, n_tilde)
    alpha = xp.maximum(-xp.imag(k0 * xi), NUMERICAL_FLOOR)
    depth_weight = xp.sqrt(sigma / (4 * alpha))

    # Phase: exp(-i*k0 * k_hat_n . r_m) with the geometric dot precomputed.
    phase = xp.exp(1j * (-k0 * geom.geom_phase))

    weighted = depth_weight[:, :, None] * F_psi * phase[:, :, None]
    return accumulate_by_element(weighted, geom.element_index, geom.n_triangles, geom.n_elements)

compute_body_channel_factored

compute_body_channel_factored(normals, centroids, center_k_hat, center_psi, element_psi, element_index, n_tilde, sigma, freq_hz, n_elements, fock_R=None, q_F_s=None, q_F_h=None, clearance=None, R_occ=None, distal_d1=None, distal_d2=None)

Build G_tilde using factored Fresnel for array-expanded paths.

When paths are expanded from N_center center paths to N_center*M_elements per-element paths (via expand_paths_to_array), the k_hat directions repeat across elements. This function computes the expensive Fresnel operator and depth coupling only for the N_center unique directions, then applies the per-element psi vectors. For a 4x4 UPA this is 16x less Fresnel work.

Parameters

normals : (M, 3) centroids : (M, 3) center_k_hat : (N_center, 3) Unique propagation directions (before array expansion). center_psi : (N_center, 3) Center-path psi (before element gain/phase, used only for shape). element_psi : (N_total, 3) Per-element psi vectors from expand_paths_to_array. element_index : (N_total,) Element index for each expanded path. n_tilde, sigma, freq_hz, n_elements : same as compute_body_channel. fock_R, q_F_s, q_F_h : same as compute_body_channel. fock_R is keyed on the center directions, so it is (M,) or (M, N_center). None disables the gate (back-compat).

Returns

G_tilde : (M, 3, n_elements) complex

Source code in src/aegis/coherent/body_channel.py
def compute_body_channel_factored(
    normals,
    centroids,
    center_k_hat,
    center_psi,
    element_psi,
    element_index,
    n_tilde,
    sigma,
    freq_hz,
    n_elements,
    fock_R=None,
    q_F_s=None,
    q_F_h=None,
    clearance=None,
    R_occ=None,
    distal_d1=None,
    distal_d2=None,
):
    """Build G_tilde using factored Fresnel for array-expanded paths.

    When paths are expanded from N_center center paths to N_center*M_elements
    per-element paths (via expand_paths_to_array), the k_hat directions repeat
    across elements. This function computes the expensive Fresnel operator and
    depth coupling only for the N_center unique directions, then applies the
    per-element psi vectors. For a 4x4 UPA this is 16x less Fresnel work.

    Parameters
    ----------
    normals : (M, 3)
    centroids : (M, 3)
    center_k_hat : (N_center, 3)
        Unique propagation directions (before array expansion).
    center_psi : (N_center, 3)
        Center-path psi (before element gain/phase, used only for shape).
    element_psi : (N_total, 3)
        Per-element psi vectors from expand_paths_to_array.
    element_index : (N_total,)
        Element index for each expanded path.
    n_tilde, sigma, freq_hz, n_elements : same as compute_body_channel.
    fock_R, q_F_s, q_F_h : same as compute_body_channel. ``fock_R`` is keyed on
        the center directions, so it is ``(M,)`` or ``(M, N_center)``. ``None``
        disables the gate (back-compat).

    Returns
    -------
    G_tilde : (M, 3, n_elements) complex
    """
    M = normals.shape[0]
    N_center = center_k_hat.shape[0]

    element_index = np.asarray(element_index)
    if element_index.size > 0:
        idx_min, idx_max = int(element_index.min()), int(element_index.max())
        if idx_min < 0 or idx_max >= n_elements:
            raise ValueError(f"element_index values must be in [0, {n_elements}), got range [{idx_min}, {idx_max}]")

    k0 = 2 * xp.pi * freq_hz / C_0

    # Compute Fresnel for N_center directions only (not N_total)
    mu, t_s, t_p, e_s, e_p = compute_fresnel_operator(normals, center_k_hat, n_tilde)

    # Depth coupling per (M, N_center)
    # xi_from_mu is element-wise; pass (M, N_center) directly, no ravel needed
    xi = xi_from_mu(mu, n_tilde)
    k0_xi = k0 * xi
    alpha = -xp.imag(k0_xi)
    alpha = xp.maximum(alpha, NUMERICAL_FLOOR)
    depth_weight = xp.sqrt(sigma / (4 * alpha))  # (M, N_center)

    # Phase per (M, N_center)
    phase_arg = -k0 * (centroids @ center_k_hat.T)
    phase = xp.exp(1j * phase_arg)  # (M, N_center)

    # Precomputed scalar factor per (M, N_center)
    scalar = depth_weight * phase  # (M, N_center)

    # Polarization-resolved Fock gate per (M, N_center). The soft/hard factors
    # fold into the TE/TM transmission coefficients per center direction, so the
    # factoring (one Fresnel solve per unique direction) is preserved.
    if fock_R is None:
        g_soft = g_hard = None
    else:
        g_soft, g_hard = _fock_gate_factors(mu, fock_R, freq_hz, q_F_s, q_F_h)

    # Distal self-shadowing amplitude per (M, N_center) (A3; 1.0 on the back face).
    distal_amp = None
    if clearance is not None:
        distal_amp = _distal_amplitude(mu, clearance, R_occ, distal_d1, distal_d2, freq_hz, q_F_s, q_F_h)

    # For each expanded path, apply F @ psi_n using precomputed Fresnel components
    # element_psi is laid out as [elem0_path0..N, elem1_path0..N, ...] (element-major)
    # so expanded path (j * N_center + c) maps to center path c
    # Accumulate in numpy (mutable) then convert to xp at the end.
    # JAX arrays are immutable and do not support in-place +=.
    G = np.zeros((M, 3, n_elements), dtype=complex)

    for c in range(N_center):
        # Fresnel-filtered psi for each element at center direction c
        # t_s[:, c], t_p[:, c]: (M,) coefficients
        # e_s[:, c, :], e_p[:, c, :]: (M, 3) basis vectors
        # scalar[:, c]: (M,) combined depth*phase weight
        t_s_c = t_s[:, c]  # (M,)
        t_p_c = t_p[:, c]  # (M,)
        if g_soft is not None:
            t_s_c = t_s_c * g_soft[:, c]  # gate the TE transmission
            t_p_c = t_p_c * g_hard[:, c]  # gate the TM transmission
        e_s_c = e_s[:, c, :]  # (M, 3)
        e_p_c = e_p[:, c, :]  # (M, 3)
        sc = scalar[:, c]  # (M,)
        if distal_amp is not None:
            sc = sc * distal_amp[:, c]  # A3 distal amplitude for this direction

        # Gather all element psi vectors for this center direction
        # element_psi layout is element-major: elem j has indices [j*N_center : (j+1)*N_center]
        elem_psi_c = element_psi[c::N_center]  # (M_elem, 3) - psi for each element at direction c

        # Project each element's psi onto TE/TM basis using einsum
        # Avoids creating (M_elem, M, 3) broadcast intermediate for the dot product
        proj_s = xp.einsum("mj,ej->em", e_s_c, elem_psi_c)  # (M_elem, M)
        proj_p = xp.einsum("mj,ej->em", e_p_c, elem_psi_c)  # (M_elem, M)

        # F @ psi for each element: (M_elem, M, 3)
        # t_s_c * proj_s: (M_elem, M), broadcast with e_s_c: (M, 3)
        F_psi_elems = (t_s_c[None, :] * proj_s)[:, :, None] * e_s_c[None, :, :] + (t_p_c[None, :] * proj_p)[
            :, :, None
        ] * e_p_c[None, :, :]  # (M_elem, M, 3)

        # Apply depth*phase weight and accumulate into G
        weighted_elems = np.asarray(sc[None, :, None] * F_psi_elems)  # (M_elem, M, 3)

        # Scatter-add all elements at once (vectorized, no Python for-loop)
        elem_indices = element_index[c::N_center]  # (M_elem,)
        np.add.at(G, (slice(None), slice(None), elem_indices), weighted_elems.transpose(1, 2, 0))

    return xp.asarray(G)

Exposure operator

aegis.coherent.exposure_operator

Exposure operator Q and its eigendecomposition.

Q = integral_Sigma G_tilde®^H @ G_tilde® dA in C^{M_ant x M_ant}

Total absorbed power: P_abs = x^H @ Q @ x Q is Hermitian positive-semidefinite by construction.

Monograph: def:Q, sec:exposure-operator.

compute_exposure_operator

compute_exposure_operator(G_tilde: ndarray, areas: ndarray) -> np.ndarray

Compute the exposure operator Q from the body-surface channel.

Q = sum_m G_tilde[m]^H @ G_tilde[m] * area[m]

Parameters

G_tilde : (M_tri, 3, M_ant) Body-surface channel at each triangle centroid. areas : (M_tri,) Triangle areas [m^2].

Returns

Q : (M_ant, M_ant) Hermitian PSD exposure operator.

Source code in src/aegis/coherent/exposure_operator.py
def compute_exposure_operator(
    G_tilde: np.ndarray,
    areas: np.ndarray,
) -> np.ndarray:
    """Compute the exposure operator Q from the body-surface channel.

    Q = sum_m G_tilde[m]^H @ G_tilde[m] * area[m]

    Parameters
    ----------
    G_tilde : (M_tri, 3, M_ant)
        Body-surface channel at each triangle centroid.
    areas : (M_tri,)
        Triangle areas [m^2].

    Returns
    -------
    Q : (M_ant, M_ant)
        Hermitian PSD exposure operator.
    """
    # Q = sum_m area_m * G_tilde_m^H @ G_tilde_m
    # G_tilde_m is (3, M_ant), so G_tilde_m^H @ G_tilde_m is (M_ant, M_ant)
    # Vectorised: einsum over triangles
    Q = xp.einsum(
        "m,mia,mib->ab",
        areas,
        xp.conj(G_tilde),
        G_tilde,
    )

    # Enforce exact Hermitian symmetry (numerical cleanup)
    Q = (Q + xp.conj(Q).T) / 2

    return Q

eigendecompose_Q

eigendecompose_Q(Q: ndarray) -> tuple[np.ndarray, np.ndarray]

Eigendecompose the exposure operator Q.

Returns eigenvalues in descending order with corresponding eigenvectors.

Parameters

Q : (M_ant, M_ant) Hermitian PSD exposure operator.

Returns

eigenvalues : (M_ant,) Eigenvalues in descending order (non-negative). eigenvectors : (M_ant, M_ant) Columns are eigenvectors, sorted to match eigenvalues.

Source code in src/aegis/coherent/exposure_operator.py
def eigendecompose_Q(
    Q: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
    """Eigendecompose the exposure operator Q.

    Returns eigenvalues in descending order with corresponding eigenvectors.

    Parameters
    ----------
    Q : (M_ant, M_ant)
        Hermitian PSD exposure operator.

    Returns
    -------
    eigenvalues : (M_ant,)
        Eigenvalues in descending order (non-negative).
    eigenvectors : (M_ant, M_ant)
        Columns are eigenvectors, sorted to match eigenvalues.
    """
    eigenvalues, eigenvectors = xp.linalg.eigh(Q)

    # Reverse to descending order (flip instead of argsort[::-1] for JAX traceability)
    eigenvalues = xp.flip(eigenvalues)
    eigenvectors = xp.flip(eigenvectors, axis=1)

    # Clamp small negatives from numerical noise
    eigenvalues = xp.maximum(eigenvalues, 0.0)

    return eigenvalues, eigenvectors

compute_rho

compute_rho(h: ndarray, Q: ndarray, lambda_max: float | None = None) -> float

Compute exposure-signal alignment rho.

rho = h^T @ Q @ h* / (||h||^2 * lambda_max(Q))

This is the fraction of worst-case absorption achieved by MRT, where x_MRT = sqrt(P) * h* / ||h||. Monograph: eq:rho-def.

Parameters

h : (M_ant,) UE channel vector (complex). Q : (M_ant, M_ant) Exposure operator. lambda_max : float or None Largest eigenvalue of Q. Computed if not provided.

Returns

rho : float Alignment metric in [0, 1].

Source code in src/aegis/coherent/exposure_operator.py
def compute_rho(
    h: np.ndarray,
    Q: np.ndarray,
    lambda_max: float | None = None,
) -> float:
    """Compute exposure-signal alignment rho.

    rho = h^T @ Q @ h* / (||h||^2 * lambda_max(Q))

    This is the fraction of worst-case absorption achieved by MRT, where
    x_MRT = sqrt(P) * h* / ||h||.  Monograph: eq:rho-def.

    Parameters
    ----------
    h : (M_ant,)
        UE channel vector (complex).
    Q : (M_ant, M_ant)
        Exposure operator.
    lambda_max : float or None
        Largest eigenvalue of Q. Computed if not provided.

    Returns
    -------
    rho : float
        Alignment metric in [0, 1].
    """
    h = xp.asarray(h, dtype=complex)
    h_norm_sq = float(xp.real(xp.vdot(h, h)))

    if h_norm_sq < NUMERICAL_FLOOR:
        return 0.0

    if lambda_max is None:
        eigenvalues = xp.linalg.eigvalsh(Q)
        lambda_max = float(xp.max(eigenvalues))

    if lambda_max < NUMERICAL_FLOOR:
        return 0.0

    # h^T @ Q @ h* (MRT quadratic form, monograph eq:rho-def)
    # P_abs_MRT = x^H Q x = P/||h||^2 * h^T Q h*, so rho = h^T Q h* / (||h||^2 * lam_max)
    h_conj = xp.conj(h)
    Qh_conj = Q @ h_conj
    numerator = float(xp.real(xp.sum(h * Qh_conj)))

    return float(xp.clip(numerator / (h_norm_sq * lambda_max), 0.0, 1.0))

ECBF solver

aegis.coherent.ecbf

Exposure-constrained beamforming (ECBF) QCQP solver.

Solves

max_x |h^T x|^2 s.t. x^H Q x <= P_abs_max ||x||^2 <= P

Optimal solution

x* = sqrt(P) * (lambda*Q + nu*I)^{-1} h* / ||(lambda*Q + nu*I)^{-1} h*||

where lambda, nu >= 0 are Lagrange multipliers found by bisection on the complementary slackness conditions.

Monograph: sec:ecbf, eq:QCQP, eq:optimal-x.

solve_ecbf

solve_ecbf(h: ndarray, Q: ndarray, P_abs_max: float, P: float, tol: float = 1e-10) -> np.ndarray

Solve the ECBF QCQP for the optimal precoding vector.

Parameters

h : (M_ant,) UE channel vector (complex). Q : (M_ant, M_ant) Exposure operator (Hermitian PSD). P_abs_max : float Maximum allowed absorbed power [W]. P : float Total transmit power budget [W]. tol : float Solver tolerance.

Returns

x_star : (M_ant,) Optimal precoding vector with ||x*||^2 <= P. The power constraint is active (||x*||^2 = P) at the QCQP optimum unless the parametric family cannot reach P_abs_max with full power, in which case the QCQP optimum lies in the power-slack regime with ||x*||^2 < P.

Source code in src/aegis/coherent/ecbf.py
def solve_ecbf(
    h: np.ndarray,
    Q: np.ndarray,
    P_abs_max: float,
    P: float,
    tol: float = 1e-10,
) -> np.ndarray:
    """Solve the ECBF QCQP for the optimal precoding vector.

    Parameters
    ----------
    h : (M_ant,)
        UE channel vector (complex).
    Q : (M_ant, M_ant)
        Exposure operator (Hermitian PSD).
    P_abs_max : float
        Maximum allowed absorbed power [W].
    P : float
        Total transmit power budget [W].
    tol : float
        Solver tolerance.

    Returns
    -------
    x_star : (M_ant,)
        Optimal precoding vector with ||x*||^2 <= P. The power constraint
        is active (||x*||^2 = P) at the QCQP optimum unless the parametric
        family cannot reach P_abs_max with full power, in which case the
        QCQP optimum lies in the power-slack regime with ||x*||^2 < P.
    """
    if P <= 0:
        raise ValueError(f"Transmit power P must be positive, got {P}")
    if P_abs_max <= 0:
        raise ValueError(f"P_abs_max must be positive, got {P_abs_max}")

    h = np.asarray(h, dtype=complex)
    Q = np.asarray(Q)

    if h.ndim != 1:
        raise ValueError(f"Channel vector h must be 1D, got shape {h.shape}")
    if Q.ndim != 2 or Q.shape[0] != Q.shape[1]:
        raise ValueError(f"Exposure operator Q must be square, got shape {Q.shape}")
    if h.shape[0] != Q.shape[0]:
        raise ValueError(f"Dimension mismatch: h has {h.shape[0]} elements, Q is {Q.shape[0]}x{Q.shape[1]}")

    # First check if unconstrained MRT satisfies the exposure constraint
    x_mrt = _mrt_precoder(h, P)
    p_abs_mrt = float(np.real(x_mrt.conj() @ Q @ x_mrt))

    if p_abs_mrt <= P_abs_max + tol:
        return xp.asarray(x_mrt)

    # Eigendecompose Q for efficient solver
    eigenvalues, V = np.linalg.eigh(Q)
    eigenvalues = np.maximum(eigenvalues, 0.0)

    # Numerical rank tolerance for separating null-space eigenvalues from
    # genuine nonzero ones. NUMERICAL_FLOOR (1e-30) is a safe-division guard,
    # not a rank threshold. Use numpy.matrix_rank's convention, relative to
    # the largest eigenvalue, so the classification is invariant to
    # platform-dependent eigh roundoff (see Windows-3.12 flake: zero
    # eigenvalues recovered as ~1e-15).
    rank_tol = max(Q.shape) * np.finfo(eigenvalues.dtype).eps * float(eigenvalues.max(initial=0.0))

    # Transform h into Q eigenbasis: h_tilde = V^H @ h*
    h_conj = h.conj()
    h_tilde = V.conj().T @ h_conj

    # x(lambda) = sqrt(P) * (lambda*Q + I)^{-1} h* / ||...||
    # In eigenbasis: x_tilde_k = h_tilde_k / (lambda * eigenvalues_k + 1)
    # P_abs = P * sum(eigenvalues_k * |x_tilde_k|^2) / ||x_tilde||^2

    def p_abs_at_lambda(lam):
        """Compute P_abs for given lambda (power constraint always active)."""
        weights = 1.0 / (lam * eigenvalues + 1.0)
        x_tilde = h_tilde * weights
        norm_sq = float(np.real(np.vdot(x_tilde, x_tilde)))
        if norm_sq < NUMERICAL_FLOOR:
            return 0.0
        p_abs = P * float(np.real(np.sum(eigenvalues * np.abs(x_tilde) ** 2))) / norm_sq
        return p_abs

    # Compute the true asymptotic minimum P_abs as lambda -> infinity.
    # When h has a component in the null space of Q, the null-space directions
    # dominate (their weights stay 1) and p_abs -> 0. Otherwise, the limit is
    # a weighted average: P * sum(a_k^2/mu_k) / sum(a_k^2/mu_k^2) where
    # a_k = |h_tilde_k| and mu_k are the nonzero eigenvalues.
    a_sq = np.abs(h_tilde) ** 2
    nonzero_mask = eigenvalues > rank_tol
    null_energy = float(np.sum(a_sq[~nonzero_mask]))
    energy_tol = np.finfo(a_sq.dtype).eps * float(a_sq.sum(initial=0.0))

    if null_energy > energy_tol:
        # h has a null-space component: as lambda -> inf, x concentrates
        # there and p_abs -> 0.
        p_abs_inf = 0.0
    elif np.any(nonzero_mask):
        mu_nz = eigenvalues[nonzero_mask]
        a_sq_nz = a_sq[nonzero_mask]
        denom = float(np.sum(a_sq_nz / mu_nz**2))
        p_abs_inf = P * float(np.sum(a_sq_nz / mu_nz)) / denom if denom > NUMERICAL_FLOOR else 0.0
    else:
        p_abs_inf = 0.0

    # Power-slack regime: when Q is invertible and h is not aligned with
    # the smallest eigenvalue directions, the parametric family
    # x(lambda) = sqrt(P)*(lambda*Q+I)^{-1}h*/||...|| (which forces
    # ||x||^2 = P) may never reach P_abs_max. The true QCQP optimum then
    # has ||x||^2 < P with x proportional to Q^{-1} h*. This must be
    # checked before the infeasibility branch below, since when Q is
    # invertible p_abs_inf == p_abs_asymp and the QCQP is always feasible
    # via the slack solution.
    if eigenvalues[0] > rank_tol:
        h_abs_sq = np.abs(h_tilde) ** 2
        inv_eigvals = 1.0 / eigenvalues
        # h*^H Q^{-1} h* and h*^H Q^{-2} h*
        qinv_form = float(np.sum(h_abs_sq * inv_eigvals))
        qinv2_form = float(np.sum(h_abs_sq * inv_eigvals**2))
        if qinv2_form > NUMERICAL_FLOOR:
            p_abs_asymp = P * qinv_form / qinv2_form
            if p_abs_asymp > P_abs_max:
                # Power constraint is slack at optimality.
                # x = alpha * Q^{-1} h*, scaled so x^H Q x = P_abs_max.
                alpha = np.sqrt(P_abs_max / qinv_form)
                x_tilde_slack = alpha * h_tilde * inv_eigvals
                x_slack = V @ x_tilde_slack
                return xp.asarray(x_slack)

    if p_abs_inf > P_abs_max:
        # Infeasible: return smallest-eigenvalue direction
        warnings.warn(
            "ECBF constraint infeasible: minimum achievable P_abs "
            f"({p_abs_inf:.4g} W) exceeds P_abs_max ({P_abs_max:.4g} W); "
            "returning minimum-absorption precoder",
            stacklevel=2,
        )
        return xp.asarray(np.sqrt(P) * V[:, 0])

    # Bisect on lambda to find P_abs = P_abs_max
    lam_low = 0.0
    lam_high = 1.0
    while p_abs_at_lambda(lam_high) > P_abs_max:
        lam_high *= 10.0
        if lam_high > 1e20:
            warnings.warn(
                "ECBF lambda bracket expansion exceeded 1e20 without "
                "satisfying absorption constraint; returning minimum-absorption direction",
                stacklevel=2,
            )
            return xp.asarray(np.sqrt(P) * V[:, 0])

    lam_star = _bisect(
        lambda lam: p_abs_at_lambda(lam) - P_abs_max,
        lam_low,
        lam_high,
        tol=tol,
    )

    if lam_star is None:
        # Fallback: return minimum-absorption direction
        warnings.warn(
            "ECBF bisection solver failed; falling back to minimum-absorption direction",
            stacklevel=2,
        )
        return xp.asarray(np.sqrt(P) * V[:, 0])

    # Reconstruct optimal precoder
    weights = 1.0 / (lam_star * eigenvalues + 1.0)
    x_tilde = h_tilde * weights
    x_conj = V @ x_tilde
    norm = np.sqrt(float(np.real(np.vdot(x_conj, x_conj))))
    if norm < NUMERICAL_FLOOR:
        return xp.asarray(np.sqrt(P) * V[:, 0])

    x_star = np.sqrt(P) * x_conj / norm
    return xp.asarray(x_star)

solve_ecbf_sweep

solve_ecbf_sweep(h: ndarray, Q: ndarray, p_abs_max_list, P: float, tol: float = 1e-10) -> list

Solve the ECBF QCQP for many absorbed-power budgets sharing one (h, Q).

Equivalent to [solve_ecbf(h, Q, b, P) for b in p_abs_max_list], but the budget-independent work (the MRT reference and the eigendecomposition of Q) is done once and reused across every budget, so a budget sweep costs a single eigh instead of one per point. The exposure operator and the channel are fixed along an absorbed-power sweep, so only the scalar budget changes between points. Returns a list of precoders aligned with p_abs_max_list.

Source code in src/aegis/coherent/ecbf.py
def solve_ecbf_sweep(
    h: np.ndarray,
    Q: np.ndarray,
    p_abs_max_list,
    P: float,
    tol: float = 1e-10,
) -> list:
    """Solve the ECBF QCQP for many absorbed-power budgets sharing one ``(h, Q)``.

    Equivalent to ``[solve_ecbf(h, Q, b, P) for b in p_abs_max_list]``, but the
    budget-independent work (the MRT reference and the eigendecomposition of ``Q``)
    is done once and reused across every budget, so a budget sweep costs a single
    ``eigh`` instead of one per point. The exposure operator and the channel are
    fixed along an absorbed-power sweep, so only the scalar budget changes between
    points. Returns a list of precoders aligned with ``p_abs_max_list``.
    """
    if P <= 0:
        raise ValueError(f"Transmit power P must be positive, got {P}")

    h = np.asarray(h, dtype=complex)
    Q = np.asarray(Q)
    if h.ndim != 1:
        raise ValueError(f"Channel vector h must be 1D, got shape {h.shape}")
    if Q.ndim != 2 or Q.shape[0] != Q.shape[1]:
        raise ValueError(f"Exposure operator Q must be square, got shape {Q.shape}")
    if h.shape[0] != Q.shape[0]:
        raise ValueError(f"Dimension mismatch: h has {h.shape[0]} elements, Q is {Q.shape[0]}x{Q.shape[1]}")

    # Budget-independent factorisation, computed once for the whole sweep.
    x_mrt = _mrt_precoder(h, P)
    p_abs_mrt = float(np.real(x_mrt.conj() @ Q @ x_mrt))

    eigenvalues, V = np.linalg.eigh(Q)
    eigenvalues = np.maximum(eigenvalues, 0.0)
    rank_tol = max(Q.shape) * np.finfo(eigenvalues.dtype).eps * float(eigenvalues.max(initial=0.0))

    h_conj = h.conj()
    h_tilde = V.conj().T @ h_conj
    a_sq = np.abs(h_tilde) ** 2
    nonzero_mask = eigenvalues > rank_tol
    null_energy = float(np.sum(a_sq[~nonzero_mask]))
    energy_tol = np.finfo(a_sq.dtype).eps * float(a_sq.sum(initial=0.0))

    if null_energy > energy_tol:
        p_abs_inf = 0.0
    elif np.any(nonzero_mask):
        mu_nz = eigenvalues[nonzero_mask]
        a_sq_nz = a_sq[nonzero_mask]
        denom = float(np.sum(a_sq_nz / mu_nz**2))
        p_abs_inf = P * float(np.sum(a_sq_nz / mu_nz)) / denom if denom > NUMERICAL_FLOOR else 0.0
    else:
        p_abs_inf = 0.0

    invertible = eigenvalues[0] > rank_tol
    if invertible:
        inv_eigvals = 1.0 / eigenvalues
        qinv_form = float(np.sum(a_sq * inv_eigvals))
        qinv2_form = float(np.sum(a_sq * inv_eigvals**2))

    def p_abs_at_lambda(lam):
        weights = 1.0 / (lam * eigenvalues + 1.0)
        x_tilde = h_tilde * weights
        norm_sq = float(np.real(np.vdot(x_tilde, x_tilde)))
        if norm_sq < NUMERICAL_FLOOR:
            return 0.0
        return P * float(np.real(np.sum(eigenvalues * np.abs(x_tilde) ** 2))) / norm_sq

    def solve_for_budget(p_abs_max):
        # Mirrors the tail of solve_ecbf, but on the shared factorisation above.
        if p_abs_max <= 0:
            raise ValueError(f"P_abs_max must be positive, got {p_abs_max}")
        if p_abs_mrt <= p_abs_max + tol:
            return xp.asarray(x_mrt)
        if invertible and qinv2_form > NUMERICAL_FLOOR:
            p_abs_asymp = P * qinv_form / qinv2_form
            if p_abs_asymp > p_abs_max:
                alpha = np.sqrt(p_abs_max / qinv_form)
                return xp.asarray(V @ (alpha * h_tilde * inv_eigvals))
        if p_abs_inf > p_abs_max:
            warnings.warn(
                "ECBF constraint infeasible: minimum achievable P_abs "
                f"({p_abs_inf:.4g} W) exceeds P_abs_max ({p_abs_max:.4g} W); "
                "returning minimum-absorption precoder",
                stacklevel=2,
            )
            return xp.asarray(np.sqrt(P) * V[:, 0])
        lam_high = 1.0
        while p_abs_at_lambda(lam_high) > p_abs_max:
            lam_high *= 10.0
            if lam_high > 1e20:
                warnings.warn(
                    "ECBF lambda bracket expansion exceeded 1e20 without "
                    "satisfying absorption constraint; returning minimum-absorption direction",
                    stacklevel=2,
                )
                return xp.asarray(np.sqrt(P) * V[:, 0])
        lam_star = _bisect(lambda lam: p_abs_at_lambda(lam) - p_abs_max, 0.0, lam_high, tol=tol)
        if lam_star is None:
            warnings.warn(
                "ECBF bisection solver failed; falling back to minimum-absorption direction",
                stacklevel=2,
            )
            return xp.asarray(np.sqrt(P) * V[:, 0])
        x_conj = V @ (h_tilde / (lam_star * eigenvalues + 1.0))
        norm = np.sqrt(float(np.real(np.vdot(x_conj, x_conj))))
        if norm < NUMERICAL_FLOOR:
            return xp.asarray(np.sqrt(P) * V[:, 0])
        return xp.asarray(np.sqrt(P) * x_conj / norm)

    return [solve_for_budget(float(b)) for b in p_abs_max_list]

Optimization

Loss functions

aegis.optim

Differentiable optimization helpers for exposure-aware design.

Functions return raw arrays (JAX when available) for use inside jax.grad boundaries. All functions work with or without JAX installed.

peak_exposure

peak_exposure(sab)

Peak per-triangle S_ab [W/m^2].

Differentiable via JAX subgradient of max.

Source code in src/aegis/optim/__init__.py
def peak_exposure(sab):
    """Peak per-triangle S_ab [W/m^2].

    Differentiable via JAX subgradient of max.
    """
    return xp.max(sab)

total_absorbed_power

total_absorbed_power(sab, areas)

Total absorbed power P_abs = sum(S_ab * area) [W].

Source code in src/aegis/optim/__init__.py
def total_absorbed_power(sab, areas):
    """Total absorbed power P_abs = sum(S_ab * area) [W]."""
    return xp.sum(sab * areas)

soft_peak_exposure

soft_peak_exposure(sab, temperature=100.0)

Smooth approximation to peak S_ab via log-sum-exp.

Higher temperature gives a tighter bound but sharper gradients. temperature=100 gives < 0.1 W/m^2 error for typical inputs.

Source code in src/aegis/optim/__init__.py
def soft_peak_exposure(sab, temperature=100.0):
    """Smooth approximation to peak S_ab via log-sum-exp.

    Higher temperature gives a tighter bound but sharper gradients.
    temperature=100 gives < 0.1 W/m^2 error for typical inputs.
    """
    sab_max = xp.max(sab)
    shifted = temperature * (sab - sab_max)
    return sab_max + xp.log(xp.sum(xp.exp(shifted))) / temperature

coherent_sab

coherent_sab(G_tilde, x)

Per-triangle S_ab from body channel and precoding vector.

S_ab® = ||G_tilde® @ x||^2

This is the inner loop of beamforming optimization. Build G_tilde once with compute_body_channel(), then call this repeatedly while optimizing x.

Parameters

G_tilde : (M, 3, M_ant) body-surface channel x : (M_ant,) complex precoding vector

Returns

sab : (M,) absorbed power density [W/m^2]

Source code in src/aegis/optim/__init__.py
def coherent_sab(G_tilde, x):
    """Per-triangle S_ab from body channel and precoding vector.

    S_ab(r) = ||G_tilde(r) @ x||^2

    This is the inner loop of beamforming optimization. Build G_tilde
    once with compute_body_channel(), then call this repeatedly while
    optimizing x.

    Parameters
    ----------
    G_tilde : (M, 3, M_ant) body-surface channel
    x : (M_ant,) complex precoding vector

    Returns
    -------
    sab : (M,) absorbed power density [W/m^2]
    """
    field = xp.einsum("mia,a->mi", G_tilde, x)
    sab = xp.real(xp.sum(xp.conj(field) * field, axis=1))
    return xp.maximum(sab, 0.0)

Constants

aegis.constants

Physical constants used across AEGIS.

Compliance

aegis.compliance

ICNIRP 2020 compliance limits for EMF exposure (100 kHz to 300 GHz).

Reference: ICNIRP, "Guidelines for Limiting Exposure to Electromagnetic Fields (100 kHz to 300 GHz)," Health Physics, vol. 118, no. 5, 2020.

Covers both general public and occupational scenarios. Above 6 GHz, absorbed power density (S_ab), incident power density (S_inc), and whole-body SAR limits are evaluated. Below 6 GHz, only whole-body SAR is checked (local SAR over 10 g cubic mass is not yet implemented).

ExposureScenario

Bases: Enum

ICNIRP exposure scenario.

Source code in src/aegis/compliance/__init__.py
class ExposureScenario(enum.Enum):
    """ICNIRP exposure scenario."""

    GENERAL_PUBLIC = "general_public"
    OCCUPATIONAL = "occupational"

ICNIRPLimits dataclass

ICNIRP 2020 limits for a specific scenario and frequency.

Attributes

scenario : ExposureScenario freq_hz : float sab_4cm2 : absorbed power density limit over 4 cm^2 [W/m^2], None if <= 6 GHz sab_1cm2 : absorbed power density limit over 1 cm^2 [W/m^2], None if <= 30 GHz sar_wb : whole-body SAR [W/kg] sinc_local : local incident power density limit [W/m^2] (Table 6), None if <= 6 GHz sinc_whole_body : whole-body incident power density limit [W/m^2] (Table 5), None if <= 6 GHz

Source code in src/aegis/compliance/__init__.py
@dataclass(frozen=True)
class ICNIRPLimits:
    """ICNIRP 2020 limits for a specific scenario and frequency.

    Attributes
    ----------
    scenario : ExposureScenario
    freq_hz : float
    sab_4cm2 : absorbed power density limit over 4 cm^2 [W/m^2], None if <= 6 GHz
    sab_1cm2 : absorbed power density limit over 1 cm^2 [W/m^2], None if <= 30 GHz
    sar_wb : whole-body SAR [W/kg]
    sinc_local : local incident power density limit [W/m^2] (Table 6), None if <= 6 GHz
    sinc_whole_body : whole-body incident power density limit [W/m^2] (Table 5), None if <= 6 GHz
    """

    scenario: ExposureScenario
    freq_hz: float
    sab_4cm2: float | None
    sab_1cm2: float | None
    sar_wb: float
    sinc_local: float | None
    sinc_whole_body: float | None

ComplianceCheck dataclass

Result of checking a single quantity against its ICNIRP limit.

Attributes

value : measured or computed value limit : ICNIRP limit unit : physical unit string label : human-readable name for this check

Source code in src/aegis/compliance/__init__.py
@dataclass(frozen=True)
class ComplianceCheck:
    """Result of checking a single quantity against its ICNIRP limit.

    Attributes
    ----------
    value : measured or computed value
    limit : ICNIRP limit
    unit : physical unit string
    label : human-readable name for this check
    """

    value: float
    limit: float
    unit: str
    label: str

    @property
    def compliant(self) -> bool:
        """True if value <= limit (ICNIRP uses <= for compliance)."""
        return self.value <= self.limit

    @property
    def margin_db(self) -> float:
        """Compliance margin in dB: 10 * log10(limit / value).

        Positive means compliant, negative means exceeded.
        """
        if self.value <= 0:
            return float("inf")
        if self.limit <= 0:
            return float("-inf")
        return float(10.0 * math.log10(self.limit / self.value))

    @property
    def ratio(self) -> float:
        """value / limit. Values > 1.0 indicate exceedance."""
        if self.limit == 0:
            return float("inf")
        return self.value / self.limit

compliant property

compliant: bool

True if value <= limit (ICNIRP uses <= for compliance).

margin_db property

margin_db: float

Compliance margin in dB: 10 * log10(limit / value).

Positive means compliant, negative means exceeded.

ratio property

ratio: float

value / limit. Values > 1.0 indicate exceedance.

ComplianceResult dataclass

Full ICNIRP 2020 compliance assessment.

Attributes

scenario : ExposureScenario freq_hz : float sab_4cm2 : ComplianceCheck for S_ab over 4 cm^2 sab_1cm2 : ComplianceCheck for S_ab over 1 cm^2, or None if <= 30 GHz sar_wb : ComplianceCheck for whole-body SAR sinc_local : ComplianceCheck for local incident power density sinc_whole_body : ComplianceCheck for whole-body incident power density

Source code in src/aegis/compliance/__init__.py
@dataclass(frozen=True)
class ComplianceResult:
    """Full ICNIRP 2020 compliance assessment.

    Attributes
    ----------
    scenario : ExposureScenario
    freq_hz : float
    sab_4cm2 : ComplianceCheck for S_ab over 4 cm^2
    sab_1cm2 : ComplianceCheck for S_ab over 1 cm^2, or None if <= 30 GHz
    sar_wb : ComplianceCheck for whole-body SAR
    sinc_local : ComplianceCheck for local incident power density
    sinc_whole_body : ComplianceCheck for whole-body incident power density
    """

    scenario: ExposureScenario
    freq_hz: float
    sab_4cm2: ComplianceCheck | None
    sab_1cm2: ComplianceCheck | None
    sar_wb: ComplianceCheck | None
    sinc_local: ComplianceCheck | None
    sinc_whole_body: ComplianceCheck | None

    @property
    def all_checks(self) -> list[ComplianceCheck]:
        """All non-None compliance checks."""
        checks: list[ComplianceCheck] = []
        for field in (
            self.sab_4cm2,
            self.sab_1cm2,
            self.sar_wb,
            self.sinc_local,
            self.sinc_whole_body,
        ):
            if field is not None:
                checks.append(field)
        return checks

    @property
    def overall_pass(self) -> bool | None:
        """True if all checks pass, False if any fail, None if no checks present."""
        checks = self.all_checks
        if not checks:
            return None
        return all(c.compliant for c in checks)

    @property
    def margin_db(self) -> float:
        """Tightest (smallest) margin across all checks, in dB.

        Returns inf if no checks are present.
        """
        checks = self.all_checks
        if not checks:
            return float("inf")
        return min(c.margin_db for c in checks)

all_checks property

all_checks: list[ComplianceCheck]

All non-None compliance checks.

overall_pass property

overall_pass: bool | None

True if all checks pass, False if any fail, None if no checks present.

margin_db property

margin_db: float

Tightest (smallest) margin across all checks, in dB.

Returns inf if no checks are present.

icnirp_limits

icnirp_limits(scenario: ExposureScenario = ExposureScenario.GENERAL_PUBLIC, freq_hz: float = DEFAULT_FREQ_HZ) -> ICNIRPLimits

Compute ICNIRP 2020 limits for a given scenario and frequency.

Parameters

scenario : ExposureScenario General public or occupational. freq_hz : float Frequency in Hz. Must be >= 100 kHz and <= 300 GHz. SAR_wb is returned across the whole range. Above 6 GHz the Sab and S_inc limits are also returned; below 6 GHz they are set to None because those basic restrictions do not apply.

Returns

ICNIRPLimits All applicable limits at this frequency.

Source code in src/aegis/compliance/__init__.py
def icnirp_limits(
    scenario: ExposureScenario = ExposureScenario.GENERAL_PUBLIC,
    freq_hz: float = DEFAULT_FREQ_HZ,
) -> ICNIRPLimits:
    """Compute ICNIRP 2020 limits for a given scenario and frequency.

    Parameters
    ----------
    scenario : ExposureScenario
        General public or occupational.
    freq_hz : float
        Frequency in Hz. Must be >= 100 kHz and <= 300 GHz.
        SAR_wb is returned across the whole range. Above 6 GHz the
        Sab and S_inc limits are also returned; below 6 GHz they are
        set to None because those basic restrictions do not apply.

    Returns
    -------
    ICNIRPLimits
        All applicable limits at this frequency.
    """
    _validate_freq(freq_hz)

    freq_ghz = freq_hz / 1e9

    sar_wb = 0.08 if scenario == ExposureScenario.GENERAL_PUBLIC else 0.4

    # Below 6 GHz: only SAR_wb applies. S_ab and S_inc limits are for >6 GHz.
    if freq_hz <= _FREQ_SAB_THRESHOLD_HZ:
        return ICNIRPLimits(
            scenario=scenario,
            freq_hz=freq_hz,
            sab_4cm2=None,
            sab_1cm2=None,
            sar_wb=sar_wb,
            sinc_local=None,
            sinc_whole_body=None,
        )

    if scenario == ExposureScenario.GENERAL_PUBLIC:
        sab_4cm2 = 20.0
        sab_1cm2 = 40.0 if freq_hz > _FREQ_1CM2_THRESHOLD_HZ else None
        sinc_local = 55.0 / freq_ghz**0.177
        sinc_whole_body = 10.0
    else:
        # Occupational
        sab_4cm2 = 100.0
        sab_1cm2 = 200.0 if freq_hz > _FREQ_1CM2_THRESHOLD_HZ else None
        sinc_local = 275.0 / freq_ghz**0.177
        sinc_whole_body = 50.0

    return ICNIRPLimits(
        scenario=scenario,
        freq_hz=freq_hz,
        sab_4cm2=sab_4cm2,
        sab_1cm2=sab_1cm2,
        sar_wb=sar_wb,
        sinc_local=sinc_local,
        sinc_whole_body=sinc_whole_body,
    )

evaluate_compliance

evaluate_compliance(*, freq_hz: float, scenario: ExposureScenario = ExposureScenario.GENERAL_PUBLIC, sab_4cm2: float | None = None, sab_1cm2: float | None = None, sar_wb: float | None = None, sinc_local: float | None = None, sinc_whole_body: float | None = None) -> ComplianceResult

Evaluate ICNIRP 2020 compliance for measured/computed quantities.

Only checks applicable at freq_hz per ICNIRP 2020 are included:

  • Whole-body SAR (sar_wb) is a basic restriction across the entire 100 kHz - 300 GHz range (Table 2 and Table 5 both list it at 0.08 W/kg general public / 0.4 W/kg occupational), so it is always checked when provided.
  • Below or at 6 GHz (Table 2 regime): Sab (4 cm^2, 1 cm^2) and Sinc_local/whole_body limits do not apply and are omitted.
  • Above 6 GHz (Table 5 regime): Sab and Sinc limits apply in addition to SAR_wb.
Parameters

freq_hz : float Frequency in Hz. Must be >= 100 kHz and <= 300 GHz. scenario : ExposureScenario General public or occupational. sab_4cm2 : float or None Measured S_ab averaged over 4 cm^2 [W/m^2]. sab_1cm2 : float or None Measured S_ab averaged over 1 cm^2 [W/m^2]. Ignored if freq <= 30 GHz. sar_wb : float or None Whole-body SAR [W/kg]. sinc_local : float or None Local incident power density [W/m^2]. sinc_whole_body : float or None Whole-body incident power density [W/m^2].

Returns

ComplianceResult

Source code in src/aegis/compliance/__init__.py
def evaluate_compliance(
    *,
    freq_hz: float,
    scenario: ExposureScenario = ExposureScenario.GENERAL_PUBLIC,
    sab_4cm2: float | None = None,
    sab_1cm2: float | None = None,
    sar_wb: float | None = None,
    sinc_local: float | None = None,
    sinc_whole_body: float | None = None,
) -> ComplianceResult:
    """Evaluate ICNIRP 2020 compliance for measured/computed quantities.

    Only checks applicable at ``freq_hz`` per ICNIRP 2020 are included:

    - Whole-body SAR (``sar_wb``) is a basic restriction across the entire
      100 kHz - 300 GHz range (Table 2 and Table 5 both list it at
      0.08 W/kg general public / 0.4 W/kg occupational), so it is always
      checked when provided.
    - Below or at 6 GHz (Table 2 regime): Sab (4 cm^2, 1 cm^2) and
      Sinc_local/whole_body limits do not apply and are omitted.
    - Above 6 GHz (Table 5 regime): Sab and Sinc limits apply in addition
      to SAR_wb.

    Parameters
    ----------
    freq_hz : float
        Frequency in Hz. Must be >= 100 kHz and <= 300 GHz.
    scenario : ExposureScenario
        General public or occupational.
    sab_4cm2 : float or None
        Measured S_ab averaged over 4 cm^2 [W/m^2].
    sab_1cm2 : float or None
        Measured S_ab averaged over 1 cm^2 [W/m^2]. Ignored if freq <= 30 GHz.
    sar_wb : float or None
        Whole-body SAR [W/kg].
    sinc_local : float or None
        Local incident power density [W/m^2].
    sinc_whole_body : float or None
        Whole-body incident power density [W/m^2].

    Returns
    -------
    ComplianceResult
    """
    limits = icnirp_limits(scenario, freq_hz)

    check_sab_4 = None
    if sab_4cm2 is not None and limits.sab_4cm2 is not None:
        check_sab_4 = ComplianceCheck(
            value=sab_4cm2,
            limit=limits.sab_4cm2,
            unit=_UNIT_WM2,
            label="S_ab (4 cm^2)",
        )

    check_sab_1 = None
    if sab_1cm2 is not None and limits.sab_1cm2 is not None:
        check_sab_1 = ComplianceCheck(
            value=sab_1cm2,
            limit=limits.sab_1cm2,
            unit=_UNIT_WM2,
            label="S_ab (1 cm^2)",
        )

    check_sar = None
    if sar_wb is not None:
        check_sar = ComplianceCheck(
            value=sar_wb,
            limit=limits.sar_wb,
            unit="W/kg",
            label="SAR_wb",
        )

    check_sinc_local = None
    if sinc_local is not None and limits.sinc_local is not None:
        check_sinc_local = ComplianceCheck(
            value=sinc_local,
            limit=limits.sinc_local,
            unit=_UNIT_WM2,
            label="S_inc (local)",
        )

    check_sinc_wb = None
    if sinc_whole_body is not None and limits.sinc_whole_body is not None:
        check_sinc_wb = ComplianceCheck(
            value=sinc_whole_body,
            limit=limits.sinc_whole_body,
            unit=_UNIT_WM2,
            label="S_inc (whole-body)",
        )

    return ComplianceResult(
        scenario=scenario,
        freq_hz=freq_hz,
        sab_4cm2=check_sab_4,
        sab_1cm2=check_sab_1,
        sar_wb=check_sar,
        sinc_local=check_sinc_local,
        sinc_whole_body=check_sinc_wb,
    )

margin_db

margin_db(value: float, limit: float) -> float

Compliance margin in dB: 10 * log10(limit / value).

Positive means compliant (value below limit), negative means exceeded. Raises ValueError if value <= 0.

Source code in src/aegis/compliance/__init__.py
def margin_db(value: float, limit: float) -> float:
    """Compliance margin in dB: 10 * log10(limit / value).

    Positive means compliant (value below limit), negative means exceeded.
    Raises ValueError if value <= 0.
    """
    if value <= 0:
        raise ValueError("value must be positive")
    if limit <= 0:
        return float("-inf")
    return float(10.0 * math.log10(limit / value))

max_compliant_power

max_compliant_power(result: ComplianceResult, ref_power_w: float) -> float

Compute the maximum transmit power that keeps all checks compliant.

For incoherent dosimetry (levels 0-6) and coherent (levels 7-8), S_ab scales linearly with transmit power P. Given a ComplianceResult computed at reference power ref_power_w, this function finds the largest P such that all measured quantities stay within their ICNIRP limits.

Parameters

result : ComplianceResult A compliance evaluation from evaluate_compliance(). ref_power_w : float The transmit power [W] at which the result was computed. Must be positive.

Returns

float Maximum compliant transmit power in watts. Returns inf if no checks are present or all measured values are zero. Returns 0.0 if any check has a zero limit (should not happen for valid ICNIRP).

Source code in src/aegis/compliance/__init__.py
def max_compliant_power(
    result: ComplianceResult,
    ref_power_w: float,
) -> float:
    """Compute the maximum transmit power that keeps all checks compliant.

    For incoherent dosimetry (levels 0-6) and coherent (levels 7-8), S_ab
    scales linearly with transmit power P. Given a ComplianceResult computed
    at reference power ``ref_power_w``, this function finds the largest P
    such that all measured quantities stay within their ICNIRP limits.

    Parameters
    ----------
    result : ComplianceResult
        A compliance evaluation from ``evaluate_compliance()``.
    ref_power_w : float
        The transmit power [W] at which the result was computed. Must be positive.

    Returns
    -------
    float
        Maximum compliant transmit power in watts. Returns ``inf`` if no
        checks are present or all measured values are zero. Returns 0.0
        if any check has a zero limit (should not happen for valid ICNIRP).
    """
    if ref_power_w <= 0:
        raise ValueError("ref_power_w must be positive")

    checks = result.all_checks
    if not checks:
        return float("inf")

    min_ratio = float("inf")
    for check in checks:
        if check.value <= 0:
            continue
        ratio = check.limit / check.value
        min_ratio = min(min_ratio, ratio)

    if min_ratio == float("inf"):
        return float("inf")

    return ref_power_w * min_ratio

summary_text

summary_text(result: ComplianceResult, tx_power_dbm: float | None = None) -> str

Human-readable compliance summary.

Parameters

result : ComplianceResult The compliance evaluation result. tx_power_dbm : float or None Transmit power in dBm, shown if provided.

Returns

str Multi-line plain-text summary.

Source code in src/aegis/compliance/__init__.py
def summary_text(
    result: ComplianceResult,
    tx_power_dbm: float | None = None,
) -> str:
    """Human-readable compliance summary.

    Parameters
    ----------
    result : ComplianceResult
        The compliance evaluation result.
    tx_power_dbm : float or None
        Transmit power in dBm, shown if provided.

    Returns
    -------
    str
        Multi-line plain-text summary.
    """
    lines: list[str] = []
    lines.append(f"ICNIRP 2020 compliance ({result.scenario.value})")
    lines.append(f"Frequency: {result.freq_hz / 1e9:.3f} GHz")

    if tx_power_dbm is not None:
        lines.append(f"Tx power: {tx_power_dbm:.1f} dBm")

    lines.append("")

    for check in result.all_checks:
        status = "PASS" if check.compliant else "FAIL"
        lines.append(
            f"  {check.label}: {check.value:.6g} / {check.limit:.6g} {check.unit} "
            f"[{status}] (margin {check.margin_db:+.1f} dB)"
        )

    lines.append("")
    overall = "N/A" if result.overall_pass is None else "PASS" if result.overall_pass else "FAIL"
    lines.append(f"Overall: {overall} (tightest margin: {result.margin_db:+.1f} dB)")

    return "\n".join(lines)

power_sweep

power_sweep(result: ComplianceResult, ref_power_w: float, p_min_w: float | None = None, p_max_w: float | None = None, n_points: int = 200) -> dict[str, Any]

Compute compliance margin vs transmit power.

S_ab scales linearly with P, so all checks scale by P/P_ref. This function evaluates compliance across a power range without re-running the dosimetry engine.

Parameters

result : ComplianceResult A compliance evaluation at reference power ref_power_w. ref_power_w : float Transmit power [W] at which result was computed. p_min_w : float or None Minimum power [W]. Defaults to ref_power_w / 100. p_max_w : float or None Maximum power [W]. Defaults to ref_power_w * 100. n_points : int Number of power samples.

Returns

dict with keys: power_w : (n_points,) power in watts power_dbm : (n_points,) power in dBm margin_db : (n_points,) tightest compliance margin in dB compliant : (n_points,) boolean mask p_max_compliant_w : float, maximum compliant power [W]

Source code in src/aegis/compliance/__init__.py
def power_sweep(
    result: ComplianceResult,
    ref_power_w: float,
    p_min_w: float | None = None,
    p_max_w: float | None = None,
    n_points: int = 200,
) -> dict[str, Any]:
    """Compute compliance margin vs transmit power.

    S_ab scales linearly with P, so all checks scale by P/P_ref. This
    function evaluates compliance across a power range without re-running
    the dosimetry engine.

    Parameters
    ----------
    result : ComplianceResult
        A compliance evaluation at reference power ref_power_w.
    ref_power_w : float
        Transmit power [W] at which result was computed.
    p_min_w : float or None
        Minimum power [W]. Defaults to ref_power_w / 100.
    p_max_w : float or None
        Maximum power [W]. Defaults to ref_power_w * 100.
    n_points : int
        Number of power samples.

    Returns
    -------
    dict with keys:
        power_w : (n_points,) power in watts
        power_dbm : (n_points,) power in dBm
        margin_db : (n_points,) tightest compliance margin in dB
        compliant : (n_points,) boolean mask
        p_max_compliant_w : float, maximum compliant power [W]
    """
    import numpy as np

    if ref_power_w <= 0:
        raise ValueError("ref_power_w must be positive")

    if p_min_w is None:
        p_min_w = ref_power_w / 100.0
    if p_max_w is None:
        p_max_w = ref_power_w * 100.0

    power_w = np.geomspace(p_min_w, p_max_w, n_points)
    power_dbm = 10.0 * np.log10(power_w * 1e3)  # W -> mW -> dBm

    checks = result.all_checks
    p_max_compliant = max_compliant_power(result, ref_power_w)

    if not checks:
        return {
            "power_w": power_w,
            "power_dbm": power_dbm,
            "margin_db": np.full(n_points, float("inf")),
            "compliant": np.ones(n_points, dtype=bool),
            "p_max_compliant_w": float("inf"),
        }

    # Vectorized: compute margin for all power levels and checks at once.
    values = np.array([c.value for c in checks])
    limits = np.array([c.limit for c in checks])

    # Filter out checks with non-positive values (cannot compute log)
    valid = values > 0
    if not np.any(valid):
        return {
            "power_w": power_w,
            "power_dbm": power_dbm,
            "margin_db": np.full(n_points, float("inf")),
            "compliant": np.ones(n_points, dtype=bool),
            "p_max_compliant_w": p_max_compliant,
        }

    values = values[valid]
    limits = limits[valid]
    scales = power_w / ref_power_w  # (n_points,)
    # margins shape: (n_checks_valid, n_points)
    scaled_values = values[:, None] * scales[None, :]
    margins = 10.0 * np.log10(limits[:, None] / scaled_values)
    margin_db_arr = np.min(margins, axis=0)  # tightest check at each power

    return {
        "power_w": power_w,
        "power_dbm": power_dbm,
        "margin_db": margin_db_arr,
        "compliant": margin_db_arr >= 0,
        "p_max_compliant_w": p_max_compliant,
    }

frequency_sweep

frequency_sweep(*, sab_4cm2: float | None = None, sab_1cm2: float | None = None, sar_wb: float | None = None, sinc_local: float | None = None, sinc_whole_body: float | None = None, scenario: ExposureScenario = ExposureScenario.GENERAL_PUBLIC, freq_min_hz: float = 7000000000.0, freq_max_hz: float = 100000000000.0, n_points: int = 200) -> dict[str, Any]

Evaluate compliance across a frequency range.

The measured values are assumed constant (worst-case: same exposure level across frequency). The ICNIRP limits change with frequency (especially sinc_local ~ 1/f^0.177), so compliance margin varies.

This answers: "At which frequencies is this exposure level compliant?"

Parameters

sab_4cm2, sab_1cm2, sar_wb, sinc_local, sinc_whole_body : float or None Measured quantities (constant across frequency). scenario : ExposureScenario freq_min_hz, freq_max_hz : float Frequency range (must be within 100 kHz to 300 GHz). n_points : int Number of frequency samples.

Returns

dict with keys: freq_hz : (n_points,) frequency array freq_ghz : (n_points,) frequency in GHz margin_db : (n_points,) tightest margin at each frequency per_check_margin_db : dict[str, (n_points,) ndarray] Margin in dB for each individual check, by attribute name (sab_4cm2, sab_1cm2, sar_wb, sinc_local, sinc_whole_body). Points where the check does not apply at that frequency are NaN so the UI can break lines cleanly. compliant : (n_points,) boolean mask results : list of ComplianceResult at each frequency

Source code in src/aegis/compliance/__init__.py
def frequency_sweep(
    *,
    sab_4cm2: float | None = None,
    sab_1cm2: float | None = None,
    sar_wb: float | None = None,
    sinc_local: float | None = None,
    sinc_whole_body: float | None = None,
    scenario: ExposureScenario = ExposureScenario.GENERAL_PUBLIC,
    freq_min_hz: float = 7e9,
    freq_max_hz: float = 100e9,
    n_points: int = 200,
) -> dict[str, Any]:
    """Evaluate compliance across a frequency range.

    The measured values are assumed constant (worst-case: same exposure
    level across frequency). The ICNIRP limits change with frequency
    (especially sinc_local ~ 1/f^0.177), so compliance margin varies.

    This answers: "At which frequencies is this exposure level compliant?"

    Parameters
    ----------
    sab_4cm2, sab_1cm2, sar_wb, sinc_local, sinc_whole_body : float or None
        Measured quantities (constant across frequency).
    scenario : ExposureScenario
    freq_min_hz, freq_max_hz : float
        Frequency range (must be within 100 kHz to 300 GHz).
    n_points : int
        Number of frequency samples.

    Returns
    -------
    dict with keys:
        freq_hz : (n_points,) frequency array
        freq_ghz : (n_points,) frequency in GHz
        margin_db : (n_points,) tightest margin at each frequency
        per_check_margin_db : dict[str, (n_points,) ndarray]
            Margin in dB for each individual check, by attribute name
            (sab_4cm2, sab_1cm2, sar_wb, sinc_local, sinc_whole_body).
            Points where the check does not apply at that frequency are
            NaN so the UI can break lines cleanly.
        compliant : (n_points,) boolean mask
        results : list of ComplianceResult at each frequency
    """
    import numpy as np

    freq_hz_arr = np.geomspace(freq_min_hz, freq_max_hz, n_points)
    margin_db_arr = np.zeros(n_points)
    compliant_arr = np.ones(n_points, dtype=bool)
    results_list: list[ComplianceResult] = []
    check_names = ("sab_4cm2", "sab_1cm2", "sar_wb", "sinc_local", "sinc_whole_body")
    per_check: dict[str, np.ndarray] = {name: np.full(n_points, np.nan) for name in check_names}

    for i, f in enumerate(freq_hz_arr):
        cr = evaluate_compliance(
            freq_hz=float(f),
            scenario=scenario,
            sab_4cm2=sab_4cm2,
            sab_1cm2=sab_1cm2,
            sar_wb=sar_wb,
            sinc_local=sinc_local,
            sinc_whole_body=sinc_whole_body,
        )
        results_list.append(cr)
        margin_db_arr[i] = cr.margin_db
        overall = cr.overall_pass
        compliant_arr[i] = overall if overall is not None else True
        for name in check_names:
            check = getattr(cr, name)
            if check is not None:
                per_check[name][i] = check.margin_db

    return {
        "freq_hz": freq_hz_arr,
        "freq_ghz": freq_hz_arr / 1e9,
        "margin_db": margin_db_arr,
        "per_check_margin_db": per_check,
        "compliant": compliant_arr,
        "results": results_list,
    }

compliance_heatmap

compliance_heatmap(*, sab_4cm2: float, ref_power_w: float = 1.0, scenario: ExposureScenario = ExposureScenario.GENERAL_PUBLIC, freq_min_hz: float = 7000000000.0, freq_max_hz: float = 100000000000.0, p_min_w: float | None = None, p_max_w: float | None = None, n_freq: int = 50, n_power: int = 50, sinc_local: float | None = None) -> dict[str, Any]

2D compliance map over frequency and transmit power.

Given an S_ab measurement at reference power, compute the compliance margin across the (frequency, power) plane. S_ab scales linearly with power. The ICNIRP S_ab limit is constant (20 or 100 W/m^2) but other limits (sinc_local) vary with frequency.

This answers: "For what (frequency, power) combinations is this exposure scenario compliant?"

Parameters

sab_4cm2 : float Peak spatially averaged S_ab [W/m^2] at ref_power_w. ref_power_w : float Transmit power [W] at which sab_4cm2 was measured. scenario : ExposureScenario freq_min_hz, freq_max_hz : float Frequency range. p_min_w, p_max_w : float or None Power range. Defaults to ref_power_w / 100 .. ref_power_w * 100. n_freq, n_power : int Grid resolution. sinc_local : float or None Peak incident power density S_inc [W/m^2] at ref_power_w. When provided, also checks the ICNIRP sinc_local limit (55/f_GHz^0.177 for GP, 275/f_GHz^0.177 for occupational) which varies with frequency, making the heatmap non-degenerate.

Returns

dict with keys: freq_hz : (n_freq,) frequency array power_w : (n_power,) power array power_dbm : (n_power,) power in dBm margin_db : (n_power, n_freq) margin heatmap (positive = compliant) compliant : (n_power, n_freq) boolean mask p_max_per_freq : (n_freq,) max compliant power at each frequency

Source code in src/aegis/compliance/__init__.py
def compliance_heatmap(
    *,
    sab_4cm2: float,
    ref_power_w: float = 1.0,
    scenario: ExposureScenario = ExposureScenario.GENERAL_PUBLIC,
    freq_min_hz: float = 7e9,
    freq_max_hz: float = 100e9,
    p_min_w: float | None = None,
    p_max_w: float | None = None,
    n_freq: int = 50,
    n_power: int = 50,
    sinc_local: float | None = None,
) -> dict[str, Any]:
    """2D compliance map over frequency and transmit power.

    Given an S_ab measurement at reference power, compute the compliance
    margin across the (frequency, power) plane. S_ab scales linearly with
    power. The ICNIRP S_ab limit is constant (20 or 100 W/m^2) but other
    limits (sinc_local) vary with frequency.

    This answers: "For what (frequency, power) combinations is this
    exposure scenario compliant?"

    Parameters
    ----------
    sab_4cm2 : float
        Peak spatially averaged S_ab [W/m^2] at ref_power_w.
    ref_power_w : float
        Transmit power [W] at which sab_4cm2 was measured.
    scenario : ExposureScenario
    freq_min_hz, freq_max_hz : float
        Frequency range.
    p_min_w, p_max_w : float or None
        Power range. Defaults to ref_power_w / 100 .. ref_power_w * 100.
    n_freq, n_power : int
        Grid resolution.
    sinc_local : float or None
        Peak incident power density S_inc [W/m^2] at ref_power_w.
        When provided, also checks the ICNIRP sinc_local limit
        (55/f_GHz^0.177 for GP, 275/f_GHz^0.177 for occupational)
        which varies with frequency, making the heatmap non-degenerate.

    Returns
    -------
    dict with keys:
        freq_hz : (n_freq,) frequency array
        power_w : (n_power,) power array
        power_dbm : (n_power,) power in dBm
        margin_db : (n_power, n_freq) margin heatmap (positive = compliant)
        compliant : (n_power, n_freq) boolean mask
        p_max_per_freq : (n_freq,) max compliant power at each frequency
    """
    import numpy as np

    if ref_power_w <= 0:
        raise ValueError("ref_power_w must be positive")
    if p_min_w is None:
        p_min_w = ref_power_w / 100.0
    if p_max_w is None:
        p_max_w = ref_power_w * 100.0

    freq_hz_arr = np.geomspace(freq_min_hz, freq_max_hz, n_freq)
    power_w_arr = np.geomspace(p_min_w, p_max_w, n_power)
    power_dbm_arr = 10.0 * np.log10(power_w_arr * 1e3)

    # Vectorized: S_ab scales linearly with power
    # scaled_sab shape: (n_power,)
    scaled_sab = sab_4cm2 * (power_w_arr / ref_power_w)

    # S_ab limit per frequency (None below 6 GHz, constant above)
    sab_limit_vals = [icnirp_limits(scenario, float(f)).sab_4cm2 for f in freq_hz_arr]
    sab_limits = np.array([v if v is not None else np.inf for v in sab_limit_vals])
    has_any_sab_limit = any(v is not None for v in sab_limit_vals) and sab_4cm2 > 0

    if has_any_sab_limit:
        # sab margin: (n_power, 1) vs (1, n_freq) -> (n_power, n_freq)
        with np.errstate(divide="ignore"):
            sab_margin = np.where(
                scaled_sab[:, None] <= 0,
                np.inf,
                10.0 * np.log10(sab_limits[None, :] / scaled_sab[:, None]),
            )
        margin_grid = sab_margin
        p_max_per_freq = ref_power_w * sab_limits / sab_4cm2
    else:
        margin_grid = np.full((len(power_w_arr), len(freq_hz_arr)), np.inf)
        p_max_per_freq = np.full(len(freq_hz_arr), float("inf"))

    # If sinc_local provided, also check frequency-dependent sinc limit
    if sinc_local is not None and sinc_local > 0:
        # Get sinc limits at each frequency: shape (n_freq,)
        sinc_limit_vals = [icnirp_limits(scenario, float(f)).sinc_local for f in freq_hz_arr]
        sinc_limits = np.array([v if v is not None else np.inf for v in sinc_limit_vals])

        # Scaled sinc: (n_power,)
        scaled_sinc = sinc_local * (power_w_arr / ref_power_w)

        # sinc margin: (n_power, 1) vs (1, n_freq) -> (n_power, n_freq)
        with np.errstate(divide="ignore"):
            sinc_margin = np.where(
                scaled_sinc[:, None] <= 0,
                np.inf,
                10.0 * np.log10(sinc_limits[None, :] / scaled_sinc[:, None]),
            )

        # Take the tighter (minimum) margin
        margin_grid = np.minimum(margin_grid, sinc_margin)

        # p_max from sinc at each frequency
        p_max_sinc = ref_power_w * sinc_limits / sinc_local
        p_max_per_freq = np.minimum(p_max_per_freq, p_max_sinc)

    return {
        "freq_hz": freq_hz_arr,
        "power_w": power_w_arr,
        "power_dbm": power_dbm_arr,
        "margin_db": margin_grid,
        "compliant": margin_grid >= 0,
        "p_max_per_freq": p_max_per_freq,
    }
link_budget_compliance(*, tx_power_w: float, antenna_gain_dbi: float = 0.0, distance_m: float, freq_hz: float, T0: float | None = None, scenario: ExposureScenario = ExposureScenario.GENERAL_PUBLIC) -> dict[str, Any]

Quick compliance check from RF link budget parameters.

Estimates incident power density from free-space path loss and computes approximate S_ab using normal-incidence transmission.

This is a conservative (worst-case) estimate: it assumes the body intercepts the full antenna beam at the given distance, with all power arriving at normal incidence. Real dosimetry with mesh geometry will give lower (more accurate) values.

tx_power_w : float Transmit power [W]. Must be positive. antenna_gain_dbi : float Antenna gain [dBi]. Default 0 (isotropic). distance_m : float Distance from antenna to body [m]. Must be positive. freq_hz : float Frequency [Hz]. Must be in ICNIRP range (100 kHz to 300 GHz). T0 : float or None Normal-incidence transmission coefficient. If None, estimated from skin tissue at the given frequency. scenario : ExposureScenario General public or occupational.

dict with keys: sinc : float, incident power density [W/m^2] sab_estimate : float, estimated S_ab [W/m^2] T0 : float, transmission coefficient used compliance : ComplianceResult compliant : bool margin_db : float max_tx_power_w : float, max compliant TX power [W] max_tx_power_dbm : float, max compliant TX power [dBm]

Source code in src/aegis/compliance/__init__.py
def link_budget_compliance(
    *,
    tx_power_w: float,
    antenna_gain_dbi: float = 0.0,
    distance_m: float,
    freq_hz: float,
    T0: float | None = None,
    scenario: ExposureScenario = ExposureScenario.GENERAL_PUBLIC,
) -> dict[str, Any]:
    """Quick compliance check from RF link budget parameters.

    Estimates incident power density from free-space path loss and
    computes approximate S_ab using normal-incidence transmission.

    This is a conservative (worst-case) estimate: it assumes the body
    intercepts the full antenna beam at the given distance, with all
    power arriving at normal incidence. Real dosimetry with mesh geometry
    will give lower (more accurate) values.

    Parameters
    ----------
    tx_power_w : float
        Transmit power [W]. Must be positive.
    antenna_gain_dbi : float
        Antenna gain [dBi]. Default 0 (isotropic).
    distance_m : float
        Distance from antenna to body [m]. Must be positive.
    freq_hz : float
        Frequency [Hz]. Must be in ICNIRP range (100 kHz to 300 GHz).
    T0 : float or None
        Normal-incidence transmission coefficient. If None, estimated
        from skin tissue at the given frequency.
    scenario : ExposureScenario
        General public or occupational.

    Returns
    -------
    dict with keys:
        sinc : float, incident power density [W/m^2]
        sab_estimate : float, estimated S_ab [W/m^2]
        T0 : float, transmission coefficient used
        compliance : ComplianceResult
        compliant : bool
        margin_db : float
        max_tx_power_w : float, max compliant TX power [W]
        max_tx_power_dbm : float, max compliant TX power [dBm]
    """
    if tx_power_w <= 0:
        raise ValueError("tx_power_w must be positive")
    if distance_m <= 0:
        raise ValueError("distance_m must be positive")
    _validate_freq(freq_hz)

    # Incident power density from free-space spreading + antenna gain
    gain_linear = 10.0 ** (antenna_gain_dbi / 10.0)
    sinc = tx_power_w * gain_linear / (4.0 * math.pi * distance_m**2)

    # Estimate T0 from skin tissue if not provided
    t0_value: float
    if T0 is None:
        try:
            from aegis.tissue.dielectric import TissueModel

            tissue = TissueModel.from_database("Skin", freq_hz)
            t0_value = tissue.T0
        except ImportError:
            t0_value = 0.4
        except Exception:
            logger.warning(
                "Failed to load tissue T0 for %.1f MHz, using fallback T0=0.4",
                freq_hz / 1e6,
                exc_info=True,
            )
            t0_value = 0.4
    else:
        t0_value = T0

    sab_estimate = sinc * t0_value

    # Evaluate compliance
    cr = evaluate_compliance(
        freq_hz=freq_hz,
        scenario=scenario,
        sab_4cm2=sab_estimate,
        sinc_local=sinc,
    )

    # Max compliant TX power: scale linearly
    p_max_w = max_compliant_power(cr, tx_power_w) if cr.all_checks else float("inf")
    p_max_dbm = 10.0 * math.log10(p_max_w * 1e3) if p_max_w < float("inf") else float("inf")

    return {
        "sinc": sinc,
        "sab_estimate": sab_estimate,
        "T0": t0_value,
        "compliance": cr,
        "compliant": cr.overall_pass,
        "margin_db": cr.margin_db,
        "max_tx_power_w": p_max_w,
        "max_tx_power_dbm": p_max_dbm,
    }

spatial_compliance_grid

spatial_compliance_grid(*, station_lats: ndarray, station_lons: ndarray, station_eirp_dbm: ndarray, station_freq_hz: ndarray, station_heights_m: ndarray, grid_lats: ndarray, grid_lons: ndarray, scenario: ExposureScenario = ExposureScenario.GENERAL_PUBLIC, receiver_height_m: float = 1.5, T0: float | None = None) -> dict[str, Any]

Compute ICNIRP compliance margin at a grid of receiver locations.

For each grid point, sums incident power density from all stations using free-space path loss, estimates absorbed power density via T0, and evaluates the tightest ICNIRP compliance margin.

Uses per-station frequency for correct limit evaluation. Multi-frequency cumulative exposure is assessed using the ICNIRP summation rule: sum(value_i / limit_i) <= 1 across frequency groups.

Parameters

station_lats, station_lons : (N,) arrays Station positions in WGS84 degrees. station_eirp_dbm : (N,) array EIRP per station in dBm. station_freq_hz : (N,) array Operating frequency per station in Hz. station_heights_m : (N,) array Antenna height above ground per station in meters. grid_lats, grid_lons : (M,) arrays Receiver grid positions in WGS84 degrees. scenario : ExposureScenario receiver_height_m : float Receiver (body) height above ground in meters. T0 : float or None Normal-incidence transmission coefficient. If None, estimated from skin tissue at the EIRP-weighted mean frequency.

Returns

dict with keys: sinc : (M,) total incident power density at each grid point [W/m^2] sab_estimate : (M,) estimated S_ab at each grid point [W/m^2] margin_db : (M,) tightest ICNIRP compliance margin [dB] compliant : (M,) boolean, True if all limits satisfied freq_hz_dominant : float, EIRP-weighted mean frequency T0 : float, transmission coefficient used

Source code in src/aegis/compliance/__init__.py
def spatial_compliance_grid(
    *,
    station_lats: np.ndarray,
    station_lons: np.ndarray,
    station_eirp_dbm: np.ndarray,
    station_freq_hz: np.ndarray,
    station_heights_m: np.ndarray,
    grid_lats: np.ndarray,
    grid_lons: np.ndarray,
    scenario: ExposureScenario = ExposureScenario.GENERAL_PUBLIC,
    receiver_height_m: float = 1.5,
    T0: float | None = None,
) -> dict[str, Any]:
    """Compute ICNIRP compliance margin at a grid of receiver locations.

    For each grid point, sums incident power density from all stations
    using free-space path loss, estimates absorbed power density via T0,
    and evaluates the tightest ICNIRP compliance margin.

    Uses per-station frequency for correct limit evaluation. Multi-frequency
    cumulative exposure is assessed using the ICNIRP summation rule:
    sum(value_i / limit_i) <= 1 across frequency groups.

    Parameters
    ----------
    station_lats, station_lons : (N,) arrays
        Station positions in WGS84 degrees.
    station_eirp_dbm : (N,) array
        EIRP per station in dBm.
    station_freq_hz : (N,) array
        Operating frequency per station in Hz.
    station_heights_m : (N,) array
        Antenna height above ground per station in meters.
    grid_lats, grid_lons : (M,) arrays
        Receiver grid positions in WGS84 degrees.
    scenario : ExposureScenario
    receiver_height_m : float
        Receiver (body) height above ground in meters.
    T0 : float or None
        Normal-incidence transmission coefficient. If None, estimated
        from skin tissue at the EIRP-weighted mean frequency.

    Returns
    -------
    dict with keys:
        sinc : (M,) total incident power density at each grid point [W/m^2]
        sab_estimate : (M,) estimated S_ab at each grid point [W/m^2]
        margin_db : (M,) tightest ICNIRP compliance margin [dB]
        compliant : (M,) boolean, True if all limits satisfied
        freq_hz_dominant : float, EIRP-weighted mean frequency
        T0 : float, transmission coefficient used
    """
    import numpy as np

    station_lats = np.asarray(station_lats, dtype=np.float64)
    station_lons = np.asarray(station_lons, dtype=np.float64)
    station_eirp_dbm = np.asarray(station_eirp_dbm, dtype=np.float64)
    station_freq_hz = np.asarray(station_freq_hz, dtype=np.float64)
    station_heights_m = np.asarray(station_heights_m, dtype=np.float64)
    grid_lats = np.asarray(grid_lats, dtype=np.float64)
    grid_lons = np.asarray(grid_lons, dtype=np.float64)

    n_stations = len(station_lats)
    n_grid = len(grid_lats)

    if n_stations == 0:
        return {
            "sinc": np.zeros(n_grid),
            "sab_estimate": np.zeros(n_grid),
            "margin_db": np.full(n_grid, float("inf")),
            "compliant": np.ones(n_grid, dtype=bool),
            "freq_hz_dominant": 0.0,
            "T0": T0 or 0.4,
        }

    # EIRP in watts: shape (N,)
    eirp_w = 10.0 ** ((station_eirp_dbm - 30.0) / 10.0)

    # EIRP-weighted mean frequency for T0 estimation
    total_eirp = np.sum(eirp_w)
    if total_eirp > 0:
        freq_mean_hz = float(np.sum(station_freq_hz * eirp_w) / total_eirp)
    else:
        freq_mean_hz = float(np.median(station_freq_hz))

    # Estimate T0 from skin tissue if not provided
    t0_value: float
    if T0 is None:
        try:
            from aegis.tissue.dielectric import TissueModel

            tissue = TissueModel.from_database("Skin", freq_mean_hz)
            t0_value = tissue.T0
        except Exception:
            t0_value = 0.4
    else:
        t0_value = T0

    # Haversine distance: grid (M,) x stations (N,) -> (M, N)
    lat1 = np.radians(grid_lats[:, None])  # (M, 1)
    lat2 = np.radians(station_lats[None, :])  # (1, N)
    dlat = lat2 - lat1
    dlon = np.radians(station_lons[None, :] - grid_lons[:, None])

    a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
    horiz_dist_m = 6_371_000.0 * 2.0 * np.arctan2(np.sqrt(a), np.sqrt(1.0 - a))

    # 3D distance including height difference
    dh = station_heights_m[None, :] - receiver_height_m  # (1, N) broadcast to (M, N)
    dist_3d = np.sqrt(horiz_dist_m**2 + dh**2)
    dist_3d = np.maximum(dist_3d, 1.0)  # clamp to 1m minimum

    # Free-space incident power density: S_inc = EIRP / (4 pi d^2)
    # Shape: (M, N)
    sinc_per_station = eirp_w[None, :] / (4.0 * np.pi * dist_3d**2)

    # Total incident power density at each grid point: (M,)
    sinc_total = np.sum(sinc_per_station, axis=1)

    # Estimated absorbed power density (normal-incidence worst case)
    sab_estimate = sinc_total * t0_value

    # Evaluate compliance at the mean frequency
    margin_db_arr = np.full(n_grid, float("inf"))
    compliant_arr = np.ones(n_grid, dtype=bool)

    try:
        limits = icnirp_limits(scenario=scenario, freq_hz=freq_mean_hz)
    except ValueError:
        # Frequency outside ICNIRP range
        return {
            "sinc": sinc_total,
            "sab_estimate": sab_estimate,
            "margin_db": margin_db_arr,
            "compliant": compliant_arr,
            "freq_hz_dominant": freq_mean_hz,
            "T0": t0_value,
        }

    # Check S_ab (4 cm^2) - most relevant above 6 GHz
    if limits.sab_4cm2 is not None:
        with np.errstate(divide="ignore"):
            m = np.where(sab_estimate > 0, 10.0 * np.log10(limits.sab_4cm2 / sab_estimate), np.inf)
        margin_db_arr = np.minimum(margin_db_arr, m)
        compliant_arr &= sab_estimate <= limits.sab_4cm2

    # Check S_ab (1 cm^2) for >30 GHz
    if limits.sab_1cm2 is not None:
        with np.errstate(divide="ignore"):
            m = np.where(sab_estimate > 0, 10.0 * np.log10(limits.sab_1cm2 / sab_estimate), np.inf)
        margin_db_arr = np.minimum(margin_db_arr, m)
        compliant_arr &= sab_estimate <= limits.sab_1cm2

    # Check S_inc (whole-body)
    if limits.sinc_whole_body is not None:
        with np.errstate(divide="ignore"):
            m = np.where(sinc_total > 0, 10.0 * np.log10(limits.sinc_whole_body / sinc_total), np.inf)
        margin_db_arr = np.minimum(margin_db_arr, m)
        compliant_arr &= sinc_total <= limits.sinc_whole_body

    # Check S_inc (local)
    if limits.sinc_local is not None:
        with np.errstate(divide="ignore"):
            m = np.where(sinc_total > 0, 10.0 * np.log10(limits.sinc_local / sinc_total), np.inf)
        margin_db_arr = np.minimum(margin_db_arr, m)
        compliant_arr &= sinc_total <= limits.sinc_local

    return {
        "sinc": sinc_total,
        "sab_estimate": sab_estimate,
        "margin_db": margin_db_arr,
        "compliant": compliant_arr,
        "freq_hz_dominant": freq_mean_hz,
        "T0": t0_value,
    }

is_compliant_sab

is_compliant_sab(peak_sab_averaged: float, limits: _LegacyLimits = ICNIRP_2020) -> bool

Check if peak spatially averaged S_ab is below the ICNIRP limit.

Backward-compat function. Prefer evaluate_compliance() for new code.

Source code in src/aegis/compliance/__init__.py
def is_compliant_sab(peak_sab_averaged: float, limits: _LegacyLimits = ICNIRP_2020) -> bool:
    """Check if peak spatially averaged S_ab is below the ICNIRP limit.

    Backward-compat function. Prefer evaluate_compliance() for new code.
    """
    return peak_sab_averaged <= limits.sab_peak

is_compliant_sar

is_compliant_sar(sar_wb: float, limits: _LegacyLimits = ICNIRP_2020) -> bool

Check if whole-body SAR is below the ICNIRP limit.

Backward-compat function. Prefer evaluate_compliance() for new code.

Source code in src/aegis/compliance/__init__.py
def is_compliant_sar(sar_wb: float, limits: _LegacyLimits = ICNIRP_2020) -> bool:
    """Check if whole-body SAR is below the ICNIRP limit.

    Backward-compat function. Prefer evaluate_compliance() for new code.
    """
    return sar_wb <= limits.sar_wb

Analysis

Path contributions

aegis.analysis

Path contribution analysis for dosimetry results.

Answers: which propagation paths contribute most to the peak exposure? This is essential for importance sampling in ray tracing, understanding exposure hotspots, and identifying dominant paths for mitigation.

path_contributions

path_contributions(body: BodyMesh, paths: PropagationPaths, tissue: TissueModel, *, triangle_index: int | None = None, top_k: int | None = None) -> dict

Compute per-path contribution to absorbed power density.

For the geometric+Fresnel kernel (level 3), the contribution of path n to triangle m is: c_{m,n} = T_avg(mu_{m,n}) * ReLU(mu_{m,n}) * power_n. Total S_ab(m) = sum_n c_{m,n}.

Parameters

body : BodyMesh paths : PropagationPaths tissue : TissueModel triangle_index : int or None If given, compute contributions to this specific triangle. If None, compute contributions to the triangle with peak S_ab. top_k : int or None If given, return only the top-k contributing paths.

Returns

dict with keys: triangle_index : int, the target triangle sab_total : float, total S_ab at the target triangle path_indices : (K,) indices of contributing paths (sorted by contribution) contributions : (K,) per-path contribution to S_ab [W/m^2] fractions : (K,) fractional contribution (sums to 1) cumulative : (K,) cumulative fraction k_hat : (K, 3) directions of contributing paths power : (K,) incident power density of contributing paths

Source code in src/aegis/analysis.py
def path_contributions(
    body: BodyMesh,
    paths: PropagationPaths,
    tissue: TissueModel,
    *,
    triangle_index: int | None = None,
    top_k: int | None = None,
) -> dict:
    """Compute per-path contribution to absorbed power density.

    For the geometric+Fresnel kernel (level 3), the contribution of path n
    to triangle m is: c_{m,n} = T_avg(mu_{m,n}) * ReLU(mu_{m,n}) * power_n.
    Total S_ab(m) = sum_n c_{m,n}.

    Parameters
    ----------
    body : BodyMesh
    paths : PropagationPaths
    tissue : TissueModel
    triangle_index : int or None
        If given, compute contributions to this specific triangle.
        If None, compute contributions to the triangle with peak S_ab.
    top_k : int or None
        If given, return only the top-k contributing paths.

    Returns
    -------
    dict with keys:
        triangle_index : int, the target triangle
        sab_total : float, total S_ab at the target triangle
        path_indices : (K,) indices of contributing paths (sorted by contribution)
        contributions : (K,) per-path contribution to S_ab [W/m^2]
        fractions : (K,) fractional contribution (sums to 1)
        cumulative : (K,) cumulative fraction
        k_hat : (K, 3) directions of contributing paths
        power : (K,) incident power density of contributing paths
    """
    mu, mu_plus = incidence_geometry(body.normals, paths.k_hat)
    _, _, T_avg = fresnel_weights(mu, tissue.n_complex)

    # Per-(triangle, path) contribution matrix: (M, N)
    C = np.asarray(T_avg * mu_plus)
    power = np.asarray(paths.power)

    # Total S_ab per triangle
    sab = C @ power

    if triangle_index is None:
        triangle_index = int(np.argmax(sab))

    # Contributions from each path to the target triangle
    c_m = C[triangle_index, :] * power  # (N,)
    sab_total = float(np.sum(c_m))

    # Sort by contribution (descending)
    order = np.argsort(c_m)[::-1]

    if top_k is not None:
        order = order[:top_k]

    c_sorted = c_m[order]
    fractions = c_sorted / sab_total if sab_total > 0 else np.zeros_like(c_sorted)

    return {
        "triangle_index": triangle_index,
        "sab_total": sab_total,
        "path_indices": order,
        "contributions": c_sorted,
        "fractions": fractions,
        "cumulative": np.cumsum(fractions),
        "k_hat": paths.k_hat[order],
        "power": power[order],
    }

exposure_heatmap

exposure_heatmap(body: BodyMesh, paths: PropagationPaths, tissue: TissueModel) -> np.ndarray

Compute full (M, N) contribution matrix.

Returns C where C[m, n] is the contribution of path n to triangle m's S_ab. S_ab = C @ ones gives per-triangle totals (but S_ab is already C @ power, so this matrix shows the spatial-angular coupling).

Parameters

body : BodyMesh paths : PropagationPaths tissue : TissueModel

Returns

C : (M, N) contribution matrix where C[m,n] = T_avg(mu) * ReLU(mu) * power_n

Source code in src/aegis/analysis.py
def exposure_heatmap(
    body: BodyMesh,
    paths: PropagationPaths,
    tissue: TissueModel,
) -> np.ndarray:
    """Compute full (M, N) contribution matrix.

    Returns C where C[m, n] is the contribution of path n to triangle m's S_ab.
    S_ab = C @ ones gives per-triangle totals (but S_ab is already C @ power,
    so this matrix shows the spatial-angular coupling).

    Parameters
    ----------
    body : BodyMesh
    paths : PropagationPaths
    tissue : TissueModel

    Returns
    -------
    C : (M, N) contribution matrix where C[m,n] = T_avg(mu) * ReLU(mu) * power_n
    """
    mu, mu_plus = incidence_geometry(body.normals, paths.k_hat)
    _, _, T_avg = fresnel_weights(mu, tissue.n_complex)
    power = np.asarray(paths.power)
    return np.asarray(T_avg * mu_plus) * power[np.newaxis, :]

path_importance

path_importance(body: BodyMesh, paths: PropagationPaths, tissue: TissueModel) -> np.ndarray

Compute per-path importance score for the overall exposure.

importance_n = sum_m area_m * C[m, n], i.e. the contribution of path n to total absorbed power P_abs. Paths with high importance are the ones to keep when pruning for faster computation.

Parameters

body : BodyMesh paths : PropagationPaths tissue : TissueModel

Returns

importance : (N,) per-path importance [W], sums to P_abs

Source code in src/aegis/analysis.py
def path_importance(
    body: BodyMesh,
    paths: PropagationPaths,
    tissue: TissueModel,
) -> np.ndarray:
    """Compute per-path importance score for the overall exposure.

    importance_n = sum_m area_m * C[m, n], i.e. the contribution of path n
    to total absorbed power P_abs. Paths with high importance are the ones
    to keep when pruning for faster computation.

    Parameters
    ----------
    body : BodyMesh
    paths : PropagationPaths
    tissue : TissueModel

    Returns
    -------
    importance : (N,) per-path importance [W], sums to P_abs
    """
    mu, mu_plus = incidence_geometry(body.normals, paths.k_hat)
    _, _, T_avg = fresnel_weights(mu, tissue.n_complex)
    # Contract areas with the (M, N) kernel without materializing it:
    # importance_n = sum_m area_m * T_avg(m,n) * mu_plus(m,n) * power_n
    #              = (areas @ (T_avg * mu_plus)) * power
    weighted = np.asarray(body.areas) @ np.asarray(T_avg * mu_plus)
    return weighted * np.asarray(paths.power)

Integration

DiffeRT bridge

aegis.integration.differt

DiffeRT ray tracer integration.

Loads propagation paths from DiffeRT scene output and converts them to PropagationPaths for use with any AEGIS fidelity level.

Requires: pip install aegis[rt] (installs differt>=0.7.0)

paths_from_differt

paths_from_differt(vertices: ndarray, normals: ndarray, path_vertices: ndarray, tx_positions: ndarray, freq_hz: float, tx_power_dbm: float = DEFAULT_POWER_DBM, element_indices: ndarray | None = None, object_indices: ndarray | None = None, material_indices: ndarray | None = None, material_n_tilde: list[complex] | None = None, initial_polarisation: str = 'vertical') -> PropagationPaths

Build PropagationPaths from DiffeRT ray tracing output.

This function takes the raw geometric output from DiffeRT's path solver and converts it to AEGIS PropagationPaths. It computes: - k_hat from the last path segment direction - psi from free-space path loss and Fresnel reflection at interactions - element_index from the transmitter array structure

When object_indices and material_n_tilde are provided, polarisation is tracked through each reflection using TE/TM decomposition and Fresnel coefficients. Otherwise, an arbitrary perpendicular is used (sufficient for incoherent levels 0-6 where only |psi|^2 matters).

Parameters

vertices : (N_scene, 3) scene triangle vertices (for material lookup) normals : (N_scene, 3) scene triangle normals path_vertices : (N_paths, N_bounces+2, 3) path vertex positions. First vertex is TX, last is the arrival point near the body. tx_positions : (M_ant, 3) transmitter antenna element positions freq_hz : operating frequency [Hz] tx_power_dbm : transmit power per element [dBm], default 43 (20 W) element_indices : (N_paths,) which TX element each path originates from. If None, inferred from nearest TX position. object_indices : (N_paths, path_length) triangle index at each path vertex from DiffeRT Paths.objects. -1 for TX/RX placeholders. material_indices : (N_triangles,) per-triangle material index from DiffeRT mesh.face_materials. material_n_tilde : list of complex refractive indices per material at the operating frequency. Use fresnel.n_complex() to compute. initial_polarisation : "vertical" or "horizontal" TX antenna polarisation.

Returns

PropagationPaths ready for any AEGIS level.

Source code in src/aegis/integration/differt.py
def paths_from_differt(
    vertices: np.ndarray,
    normals: np.ndarray,
    path_vertices: np.ndarray,
    tx_positions: np.ndarray,
    freq_hz: float,
    tx_power_dbm: float = DEFAULT_POWER_DBM,
    element_indices: np.ndarray | None = None,
    object_indices: np.ndarray | None = None,
    material_indices: np.ndarray | None = None,
    material_n_tilde: list[complex] | None = None,
    initial_polarisation: str = "vertical",
) -> PropagationPaths:
    """Build PropagationPaths from DiffeRT ray tracing output.

    This function takes the raw geometric output from DiffeRT's path solver
    and converts it to AEGIS PropagationPaths. It computes:
    - k_hat from the last path segment direction
    - psi from free-space path loss and Fresnel reflection at interactions
    - element_index from the transmitter array structure

    When object_indices and material_n_tilde are provided, polarisation is
    tracked through each reflection using TE/TM decomposition and Fresnel
    coefficients. Otherwise, an arbitrary perpendicular is used (sufficient
    for incoherent levels 0-6 where only |psi|^2 matters).

    Parameters
    ----------
    vertices : (N_scene, 3) scene triangle vertices (for material lookup)
    normals : (N_scene, 3) scene triangle normals
    path_vertices : (N_paths, N_bounces+2, 3) path vertex positions.
        First vertex is TX, last is the arrival point near the body.
    tx_positions : (M_ant, 3) transmitter antenna element positions
    freq_hz : operating frequency [Hz]
    tx_power_dbm : transmit power per element [dBm], default 43 (20 W)
    element_indices : (N_paths,) which TX element each path originates from.
        If None, inferred from nearest TX position.
    object_indices : (N_paths, path_length) triangle index at each path
        vertex from DiffeRT Paths.objects. -1 for TX/RX placeholders.
    material_indices : (N_triangles,) per-triangle material index from
        DiffeRT mesh.face_materials.
    material_n_tilde : list of complex refractive indices per material at
        the operating frequency. Use fresnel.n_complex() to compute.
    initial_polarisation : "vertical" or "horizontal" TX antenna polarisation.

    Returns
    -------
    PropagationPaths ready for any AEGIS level.
    """
    path_vertices = np.asarray(path_vertices, dtype=np.float64)
    tx_positions = np.asarray(tx_positions, dtype=np.float64)

    n_paths = path_vertices.shape[0]
    if n_paths == 0:
        return PropagationPaths.from_powers(k_hat=np.zeros((0, 3)), power=np.zeros(0))

    # Direction of arrival: last non-degenerate segment (handles padded paths)
    segments = np.diff(path_vertices, axis=1)  # (N, n_segments, 3)
    seg_lengths = np.linalg.norm(segments, axis=2)  # (N, n_segments)

    # For each path, find the last segment with nonzero length (vectorized)
    nonzero_mask = seg_lengths > 1e-12  # (N, n_segments) bool
    # Multiply column index by mask, take argmax to get last nonzero segment
    col_indices = np.arange(segments.shape[1])[np.newaxis, :]  # (1, n_segments)
    # Where no nonzero segment exists, masked_cols stays 0
    masked_cols = np.where(nonzero_mask, col_indices, -1)
    last_seg_idx = np.argmax(masked_cols, axis=1)  # (N,)
    row_idx = np.arange(n_paths)
    last_seg = segments[row_idx, last_seg_idx]  # (N, 3)
    last_len = seg_lengths[row_idx, last_seg_idx]  # (N,)
    safe_len = np.where(last_len > 1e-12, last_len, 1.0)
    k_hat = last_seg / safe_len[:, np.newaxis]
    # Zero out paths with no valid segments
    k_hat[last_len <= 1e-12] = 0.0

    # Total path length (excluding zero-length padding segments)
    total_length = np.sum(seg_lengths, axis=1)  # (N,)

    # Filter out degenerate paths (zero total length means TX=RX coincidence
    # or fully padded geometry). These produce undefined k_hat and spurious
    # amplitude from division by ~0 distance.
    valid = total_length > 1e-10
    if not np.all(valid):
        keep = np.where(valid)[0]
        if len(keep) == 0:
            return PropagationPaths.from_powers(k_hat=np.zeros((0, 3)), power=np.zeros(0))
        path_vertices = path_vertices[keep]
        segments = segments[keep]
        seg_lengths = seg_lengths[keep]
        k_hat = k_hat[keep]
        total_length = total_length[keep]
        n_paths = len(keep)
        row_idx = np.arange(n_paths)
        if object_indices is not None:
            object_indices = np.asarray(object_indices, dtype=np.intp)[keep]
        # NOTE: material_indices and normals are per-scene-triangle, not per-path.
        # They must NOT be filtered by path index -- they are used for lookup
        # by triangle index inside _track_polarisation.
        if element_indices is not None:
            element_indices = np.asarray(element_indices, dtype=np.intp)[keep]

    # TX power in watts
    tx_power_w = 10 ** ((tx_power_dbm - 30) / 10)

    # E-field amplitude at distance d from isotropic radiator:
    # S_inc = P_tx / (4*pi*d^2),  |E| = sqrt(2*Z_0*S_inc)
    # So |E| = sqrt(2*Z_0*P_tx / (4*pi)) / d
    d_safe = np.maximum(total_length, 1e-10)
    amplitude = np.sqrt(2 * Z_0 * tx_power_w / (4 * np.pi)) / d_safe

    # Build polarisation vector
    if object_indices is not None and material_n_tilde is not None:
        # Track polarisation through reflections using TE/TM decomposition
        normals = np.asarray(normals, dtype=np.float64)
        object_indices = np.asarray(object_indices, dtype=np.intp)
        mat_idx = np.asarray(material_indices, dtype=np.intp) if material_indices is not None else None
        psi = _track_polarisation(
            path_vertices,
            normals,
            object_indices,
            mat_idx,
            material_n_tilde,
            amplitude,
            initial_polarisation,
        )
        polarised = True
    else:
        # Fallback: arbitrary perpendicular (sufficient for incoherent levels)
        e_perp = _arbitrary_perpendicular(k_hat)
        psi = (amplitude[:, np.newaxis] * e_perp).astype(complex)
        polarised = False

    # Element indices
    if element_indices is None:
        # Assign each path to nearest TX element
        tx_first = path_vertices[:, 0, :]  # (N, 3)
        dists = np.linalg.norm(tx_first[:, np.newaxis, :] - tx_positions[np.newaxis, :, :], axis=2)  # (N, M_ant)
        element_indices = np.argmin(dists, axis=1)
    element_indices = np.asarray(element_indices, dtype=np.intp)

    # Propagation delay and phase
    delay = total_length / C_0

    # Apply propagation phase exp(-j*k*d) for coherent levels (7-8).
    # For incoherent levels only |psi|^2 is used, so phase does not matter,
    # but coherent combination requires correct path-length-dependent phase.
    k0 = 2 * np.pi * freq_hz / C_0
    psi = psi * np.exp(-1j * k0 * total_length)[:, np.newaxis]

    # psi so far is the field AT the receiver, but the coherent kernels phase
    # at absolute coordinates (exp(-i k0 k_hat . r)): re-reference to the world
    # origin so the expansion reproduces the field around the actual rx point
    # (the path's final non-degenerate vertex).
    nz = seg_lengths > 1e-12
    last_idx = nz.shape[1] - 1 - np.argmax(nz[:, ::-1], axis=1)
    rx_point = path_vertices[row_idx, last_idx + 1]  # (N, 3), all equal to rx
    psi = psi * np.exp(1j * k0 * np.einsum("nj,nj->n", k_hat, rx_point))[:, np.newaxis]

    # Departure direction at the source: first non-degenerate segment.
    first_idx = np.argmax(nz, axis=1)
    first_seg = segments[row_idx, first_idx]
    first_len = seg_lengths[row_idx, first_idx]
    k_hat_tx = first_seg / np.maximum(first_len, 1e-12)[:, np.newaxis]

    # LOS: one non-degenerate segment (handles max-length padding / repeated RX verts)
    n_nonzero_segs = np.sum(seg_lengths > 1e-12, axis=1)
    is_los = n_nonzero_segs == 1

    return PropagationPaths(
        k_hat=k_hat,
        psi=psi,
        element_index=element_indices,
        delay=delay,
        is_los=is_los,
        polarised=polarised,
        k_hat_tx=k_hat_tx,
    )

paths_from_differt_scene

paths_from_differt_scene(scene_path: str | Path, tx_positions: ndarray, rx_position: ndarray, freq_hz: float, max_bounces: int = 3, tx_power_dbm: float = DEFAULT_POWER_DBM, initial_polarisation: str = 'vertical') -> PropagationPaths

Run DiffeRT on a scene file and return PropagationPaths.

This is the high-level entry point. It loads a scene, runs ray tracing via scene.compute_paths(), and returns paths with proper TE/TM polarisation tracking through reflections.

Parameters

scene_path : path to Sionna/Mitsuba XML scene file tx_positions : (M_ant, 3) transmitter element positions [m] rx_position : (3,) receiver (body) position [m] freq_hz : operating frequency [Hz] max_bounces : maximum number of reflections (default 3) tx_power_dbm : transmit power per element [dBm] initial_polarisation : "vertical" or "horizontal" TX antenna polarisation

Returns

PropagationPaths

Source code in src/aegis/integration/differt.py
def paths_from_differt_scene(
    scene_path: str | Path,
    tx_positions: np.ndarray,
    rx_position: np.ndarray,
    freq_hz: float,
    max_bounces: int = 3,
    tx_power_dbm: float = DEFAULT_POWER_DBM,
    initial_polarisation: str = "vertical",
) -> PropagationPaths:
    """Run DiffeRT on a scene file and return PropagationPaths.

    This is the high-level entry point. It loads a scene, runs ray tracing
    via scene.compute_paths(), and returns paths with proper TE/TM
    polarisation tracking through reflections.

    Parameters
    ----------
    scene_path : path to Sionna/Mitsuba XML scene file
    tx_positions : (M_ant, 3) transmitter element positions [m]
    rx_position : (3,) receiver (body) position [m]
    freq_hz : operating frequency [Hz]
    max_bounces : maximum number of reflections (default 3)
    tx_power_dbm : transmit power per element [dBm]
    initial_polarisation : "vertical" or "horizontal" TX antenna polarisation

    Returns
    -------
    PropagationPaths
    """
    _check_differt()
    from differt.scene import TriangleScene

    scene = TriangleScene.load_xml(str(scene_path))
    return paths_from_differt_scene_obj(
        scene,
        tx_positions=tx_positions,
        rx_position=rx_position,
        freq_hz=freq_hz,
        max_bounces=max_bounces,
        tx_power_dbm=tx_power_dbm,
        initial_polarisation=initial_polarisation,
    )

paths_from_differt_scene_obj

paths_from_differt_scene_obj(scene, tx_positions: ndarray, rx_position: ndarray, freq_hz: float, max_bounces: int = 3, tx_power_dbm: float = DEFAULT_POWER_DBM, initial_polarisation: str = 'vertical') -> PropagationPaths

Ray trace an in-memory DiffeRT TriangleScene and return PropagationPaths.

Same as :func:paths_from_differt_scene but takes an already-loaded scene object instead of an XML path. Use this for AEGIS-native meshes via EnvironmentMesh.to_differt_scene(): the to_sionna_xml export targets Sionna's lenient Mitsuba loader and is not accepted by differt_core's stricter parser, and building the scene once avoids re-parsing per call.

Parameters

scene : differt.scene.TriangleScene with face_materials and material_names tx_positions : (M_ant, 3) transmitter element positions [m] rx_position : (3,) receiver (body) position [m] freq_hz : operating frequency [Hz] max_bounces : maximum number of reflections (default 3) tx_power_dbm : transmit power per element [dBm] initial_polarisation : "vertical" or "horizontal" TX antenna polarisation

Returns

PropagationPaths

Source code in src/aegis/integration/differt.py
def paths_from_differt_scene_obj(
    scene,
    tx_positions: np.ndarray,
    rx_position: np.ndarray,
    freq_hz: float,
    max_bounces: int = 3,
    tx_power_dbm: float = DEFAULT_POWER_DBM,
    initial_polarisation: str = "vertical",
) -> PropagationPaths:
    """Ray trace an in-memory DiffeRT ``TriangleScene`` and return PropagationPaths.

    Same as :func:`paths_from_differt_scene` but takes an already-loaded scene
    object instead of an XML path. Use this for AEGIS-native meshes via
    ``EnvironmentMesh.to_differt_scene()``: the ``to_sionna_xml`` export targets
    Sionna's lenient Mitsuba loader and is not accepted by ``differt_core``'s
    stricter parser, and building the scene once avoids re-parsing per call.

    Parameters
    ----------
    scene : differt.scene.TriangleScene with face_materials and material_names
    tx_positions : (M_ant, 3) transmitter element positions [m]
    rx_position : (3,) receiver (body) position [m]
    freq_hz : operating frequency [Hz]
    max_bounces : maximum number of reflections (default 3)
    tx_power_dbm : transmit power per element [dBm]
    initial_polarisation : "vertical" or "horizontal" TX antenna polarisation

    Returns
    -------
    PropagationPaths
    """
    _check_differt()

    scene_vertices = np.asarray(scene.mesh.vertices)
    scene_normals = np.asarray(scene.mesh.normals)
    material_n_tilde = _extract_material_properties(scene, freq_hz)

    face_materials = None
    if scene.mesh.face_materials is not None:
        face_materials = np.asarray(scene.mesh.face_materials, dtype=np.intp)

    tx_positions = np.asarray(tx_positions, dtype=np.float64)
    rx_position = np.asarray(rx_position, dtype=np.float64)
    if tx_positions.ndim == 1:
        tx_positions = tx_positions[np.newaxis, :]

    all_pv: list[np.ndarray] = []
    all_oi: list[np.ndarray] = []
    all_ei: list[np.ndarray] = []

    for elem_idx in range(len(tx_positions)):
        pv, oi, ei = _compute_element_paths(scene, elem_idx, tx_positions, rx_position, max_bounces)
        all_pv.extend(pv)
        all_oi.extend(oi)
        all_ei.extend(ei)

    if not all_pv:
        return PropagationPaths.from_powers(k_hat=np.zeros((0, 3)), power=np.zeros(0))

    path_vertices, object_indices, element_indices = _pad_and_concatenate(all_pv, all_oi, all_ei)

    return paths_from_differt(
        vertices=scene_vertices,
        normals=scene_normals,
        path_vertices=path_vertices,
        tx_positions=tx_positions,
        freq_hz=freq_hz,
        tx_power_dbm=tx_power_dbm,
        element_indices=element_indices,
        object_indices=object_indices,
        material_indices=face_materials,
        material_n_tilde=material_n_tilde,
        initial_polarisation=initial_polarisation,
    )

Sionna RT bridge

aegis.integration.sionna

Sionna RT ray tracer integration.

Converts Sionna RT channel coefficients to AEGIS PropagationPaths. Uses a dual-polarized isotropic RX probe to capture the full E-field polarisation state, then scales to absolute V/m using:

psi = sqrt(8*pi*Z_0*P_T) / lambda * (a_theta * e_theta + a_phi * e_phi)

Requires: pip install aegis[sionna] (installs sionna-rt>=1.0)

paths_from_sionna_scene

paths_from_sionna_scene(scene, tx_positions: ndarray, rx_position: ndarray, freq_hz: float, max_bounces: int = 5, tx_power_dbm: float = DEFAULT_POWER_DBM, tx_pattern: str = 'isotropic', return_viz: bool = False, los: bool = True, specular_reflection: bool = True, diffuse_reflection: bool = False, refraction: bool = True, diffraction: bool = False, edge_diffraction: bool = False, diffraction_lit_region: bool = True, samples_per_src: int = 1000000, max_num_paths_per_src: int = 1000000, synthetic_array: bool = True, seed: int = DEFAULT_SEED, differentiable: bool = False) -> PropagationPaths | tuple[PropagationPaths, list[dict]]

Run Sionna RT and convert results to PropagationPaths.

Parameters

scene : sionna.rt Scene object (loaded externally) tx_positions : (M_ant, 3) transmitter element positions rx_position : (3,) body centroid position freq_hz : carrier frequency in Hz max_bounces : maximum number of ray interactions tx_power_dbm : transmit power per element [dBm] tx_pattern : TX antenna pattern name return_viz : if True, also return path visualization data differentiable : if True, return JAX arrays with Dr.Jit gradient tracking via paths.cir(out_type="jax"). Requires JAX. Invalid paths are masked to zero (static shapes) instead of filtered, enabling jax.grad through the conversion.

Returns

PropagationPaths with k_hat, psi, element_index, delay, is_los. If return_viz is True, returns (PropagationPaths, path_viz_list).

Source code in src/aegis/integration/sionna.py
def paths_from_sionna_scene(
    scene,
    tx_positions: np.ndarray,
    rx_position: np.ndarray,
    freq_hz: float,
    max_bounces: int = 5,
    tx_power_dbm: float = DEFAULT_POWER_DBM,
    tx_pattern: str = "isotropic",
    return_viz: bool = False,
    los: bool = True,
    specular_reflection: bool = True,
    diffuse_reflection: bool = False,
    refraction: bool = True,
    diffraction: bool = False,
    edge_diffraction: bool = False,
    diffraction_lit_region: bool = True,
    samples_per_src: int = 1_000_000,
    max_num_paths_per_src: int = 1_000_000,
    synthetic_array: bool = True,
    seed: int = DEFAULT_SEED,
    differentiable: bool = False,
) -> PropagationPaths | tuple[PropagationPaths, list[dict]]:
    """Run Sionna RT and convert results to PropagationPaths.

    Parameters
    ----------
    scene : sionna.rt Scene object (loaded externally)
    tx_positions : (M_ant, 3) transmitter element positions
    rx_position : (3,) body centroid position
    freq_hz : carrier frequency in Hz
    max_bounces : maximum number of ray interactions
    tx_power_dbm : transmit power per element [dBm]
    tx_pattern : TX antenna pattern name
    return_viz : if True, also return path visualization data
    differentiable : if True, return JAX arrays with Dr.Jit gradient
        tracking via ``paths.cir(out_type="jax")``. Requires JAX.
        Invalid paths are masked to zero (static shapes) instead of
        filtered, enabling ``jax.grad`` through the conversion.

    Returns
    -------
    PropagationPaths with k_hat, psi, element_index, delay, is_los.
    If return_viz is True, returns (PropagationPaths, path_viz_list).
    """
    _check_sionna()
    from sionna.rt import PathSolver, PlanarArray

    tx_positions = np.asarray(tx_positions, dtype=np.float64)
    rx_position = np.asarray(rx_position, dtype=np.float64)
    if tx_positions.ndim == 1:
        tx_positions = tx_positions[np.newaxis, :]
    if tx_positions.ndim != 2 or tx_positions.shape[1] != 3 or len(tx_positions) == 0:
        raise ValueError("tx_positions must have shape (M_ant, 3) with at least one element")
    if not np.all(np.isfinite(tx_positions)):
        raise ValueError("tx_positions must contain only finite coordinates")

    tx_power_w = 10 ** ((tx_power_dbm - 30) / 10)
    n_elements = tx_positions.shape[0]

    # Set the carrier on the scene before building arrays or solving. Sionna
    # defaults a loaded scene to 3.5 GHz, and the frequency drives the radio
    # material coefficients and path-loss wavelength. Leaving the default
    # silently traces the wrong band.
    scene.frequency = float(freq_hz)

    # Map common pattern names to Sionna v2 registry names
    _pattern_map = {"isotropic": "iso", "half_wave_dipole": "hw_dipole"}
    sionna_tx_pattern = _pattern_map.get(tx_pattern, tx_pattern)

    # Configure dual-polarized isotropic RX to capture theta/phi field components
    scene.rx_array = PlanarArray(
        num_rows=1,
        num_cols=1,
        pattern="iso",
        polarization="cross",
    )

    # Trace one antenna at the physical phase center. After CIR extraction, the
    # center coefficient is expanded analytically to the requested positions.
    scene.tx_array, tx_phase_center, tx_offsets = _build_sionna_tx_array(
        PlanarArray,
        tx_positions,
        sionna_tx_pattern,
    )

    # Set TX and RX positions (Sionna v2 API)
    # Remove stale TX/RX from cached scenes before adding new ones
    import contextlib

    from sionna.rt import Receiver, Transmitter

    for name in ("tx", "rx"):
        with contextlib.suppress(ValueError, KeyError):
            scene.remove(name)
    scene.add(Transmitter("tx", position=tx_phase_center.tolist()))
    scene.add(Receiver("rx", position=rx_position.tolist()))

    if not synthetic_array and n_elements > 1:
        raise NotImplementedError(
            "synthetic_array=False with multiple TX elements is not supported. "
            "The AEGIS Sionna bridge assumes synthetic_array=True for "
            "multi-element arrays (shared angles/delays across elements). "
            "Use synthetic_array=True (default) or a single TX element."
        )

    # Compute paths
    solver = PathSolver()
    paths = solver(
        scene=scene,
        max_depth=max_bounces,
        los=los,
        specular_reflection=specular_reflection,
        diffuse_reflection=diffuse_reflection,
        refraction=refraction,
        diffraction=diffraction,
        edge_diffraction=edge_diffraction,
        diffraction_lit_region=diffraction_lit_region,
        samples_per_src=samples_per_src,
        max_num_paths_per_src=max_num_paths_per_src,
        synthetic_array=synthetic_array,
        seed=seed,
    )

    # Extract path visualization before CIR (vertices are lazily computed)
    valid_solver = np.array(paths.valid)
    _validate_center_valid_shape(valid_solver, synthetic_array)
    valid_raw = valid_solver
    if not synthetic_array:
        valid_raw = valid_raw[:, 0, :, 0, :]
    path_viz = _extract_path_viz(paths, valid_raw, synthetic_array) if return_viz else []

    # --- Differentiable JAX path ---
    if differentiable:
        if not JAX_AVAILABLE:
            raise RuntimeError("differentiable=True requires JAX. Install with: pip install jax")
        result = _paths_from_sionna_jax(
            paths,
            valid_raw,
            tx_offsets,
            freq_hz,
            tx_power_w,
            rx_position,
            synthetic_array,
        )
        return (result, path_viz) if return_viz else result

    # --- Original NumPy path (unchanged) ---
    # Extract data as numpy
    # Sionna v2 cir() shape: a[num_rx, num_rx_ant, num_tx, num_tx_ant, num_paths, num_time_steps]
    # With cross-pol RX: num_rx_ant=2 (pol 0=theta, pol 1=phi)
    # tau shape: (num_rx, num_tx, num_paths)
    # normalize_delays=False makes cir() return true geometric delays AND bake
    # the full carrier phase exp(-j 2 pi f tau) into a (the default bakes only
    # the phase relative to the first arrival, which scrambles absolute path
    # phases). Empirically pinned for sionna-rt 2.0.1 by the coherent-phase
    # regression tests in tests/test_sionna.py.
    a_raw, tau_raw = paths.cir(out_type="numpy", normalize_delays=False)

    theta_r_raw = np.array(paths.theta_r)  # (num_rx, num_tx, num_paths)
    phi_r_raw = np.array(paths.phi_r)
    theta_t_raw = np.array(paths.theta_t)  # departure angles at the source
    phi_t_raw = np.array(paths.phi_t)
    _validate_center_cir_shapes(
        a_raw,
        tau_raw,
        {
            "theta_r": theta_r_raw,
            "phi_r": phi_r_raw,
            "theta_t": theta_t_raw,
            "phi_t": phi_t_raw,
        },
        valid_raw.shape[-1],
        synthetic_array,
    )
    a_raw = a_raw[..., 0]
    if not synthetic_array:
        tau_raw = tau_raw[:, 0, :, 0, :]
        theta_r_raw = theta_r_raw[:, 0, :, 0, :]
        phi_r_raw = phi_r_raw[:, 0, :, 0, :]
        theta_t_raw = theta_t_raw[:, 0, :, 0, :]
        phi_t_raw = phi_t_raw[:, 0, :, 0, :]
    valid = valid_raw

    all_k_hat = []
    all_k_hat_tx = []
    all_psi = []
    all_element_index = []
    all_delay = []
    all_is_los = []

    k0 = 2.0 * np.pi * freq_hz / C_0
    rx_idx = 0  # single RX (body centroid)
    tx_idx = 0  # single TX device
    for elem in range(n_elements):
        # Extract per-element data. rx_ant=0 is theta, rx_ant=1 is phi.
        a_theta = a_raw[rx_idx, 0, tx_idx, 0, :]  # center-traced (n_paths,)
        a_phi = a_raw[rx_idx, 1, tx_idx, 0, :]
        theta_r = theta_r_raw[rx_idx, tx_idx, :]
        phi_r = phi_r_raw[rx_idx, tx_idx, :]
        theta_t = theta_t_raw[rx_idx, tx_idx, :]
        phi_t = phi_t_raw[rx_idx, tx_idx, :]
        tau = tau_raw[rx_idx, tx_idx, :]
        mask = valid[rx_idx, tx_idx, :]

        # Filter valid paths
        idx = np.where(mask)[0]
        if len(idx) == 0:
            continue

        a_theta = a_theta[idx]
        a_phi = a_phi[idx]
        theta_r = theta_r[idx]
        phi_r = phi_r[idx]
        theta_t = theta_t[idx]
        phi_t = phi_t[idx]
        tau = tau[idx]

        # Convert to AEGIS psi
        psi = _convert_a_to_psi(a_theta, a_phi, theta_r, phi_r, freq_hz, tx_power_w)

        # k_hat from arrival angles (direction of propagation, toward the body)
        # Sionna's (theta_r, phi_r) gives the direction FROM the body TO the source.
        # AEGIS k_hat is the propagation direction (toward the body), so negate.
        k_hat = -np.column_stack(
            [
                np.sin(theta_r) * np.cos(phi_r),
                np.sin(theta_r) * np.sin(phi_r),
                np.cos(theta_r),
            ]
        )
        # Departure direction at the source (Sionna AoD points away from the tx,
        # which is already the propagation direction: no negation).
        k_hat_tx = np.column_stack(
            [
                np.sin(theta_t) * np.cos(phi_t),
                np.sin(theta_t) * np.sin(phi_t),
                np.cos(theta_t),
            ]
        )

        # Analytically move the center-traced source to this element. This is
        # exact under Sionna's synthetic-array far-field model and works for
        # arbitrary element layouts without a square-array assumption.
        tx_phase = _synthetic_tx_phase(k_hat_tx, tx_offsets[elem : elem + 1], freq_hz)[0]
        psi = psi * tx_phase[:, None]

        # a (and so psi) is the field AT the rx point, but every coherent
        # kernel phases at absolute coordinates, exp(-i k0 k_n . r). Re-reference
        # psi to the world origin so that expansion reproduces the traced field
        # at and around the receiver, wherever the body stands in the city.
        psi = psi * np.exp(1j * k0 * (k_hat @ rx_position))[:, None]

        # Detect LOS: shortest-delay path per element (lowest tau = closest to LOS)
        is_los = np.zeros(len(idx), dtype=bool)
        if len(idx) > 0:
            is_los[np.argmin(tau)] = True

        all_k_hat.append(k_hat)
        all_k_hat_tx.append(k_hat_tx)
        all_psi.append(psi)
        all_element_index.append(np.full(len(idx), elem, dtype=np.intp))
        all_delay.append(tau)
        all_is_los.append(is_los)

    if not all_k_hat:
        empty = PropagationPaths.from_powers(k_hat=np.zeros((0, 3)), power=np.zeros(0))
        return (empty, []) if return_viz else empty

    result = PropagationPaths(
        k_hat=np.vstack(all_k_hat),
        psi=np.vstack(all_psi),
        element_index=np.concatenate(all_element_index),
        delay=np.concatenate(all_delay),
        is_los=np.concatenate(all_is_los),
        polarised=True,
        k_hat_tx=np.vstack(all_k_hat_tx),
    )
    return (result, path_viz) if return_viz else result
WAVES Ghent University imec