Skip to content

API Reference

This page documents the public Python API of ng-imager.

This section is automatically generated from the Python docstrings using mkdocstrings. It covers the main simulation, imaging, and reconstruction modules that form the ng-imager pipeline.

The modules are grouped roughly by how you use them in an imaging workflow:

  • Pipelines: high-level entry points that wire everything together.
  • Physics & geometry: event / cone construction and projection math.
  • I/O & configuration: reading event data, HDF5 storage, configuration.
  • CLI & visualization: the command-line app and simple plotting utilities.
  • Simulation & tools: synthetic data generation and LUT utilities.

Pipelines

High-level orchestration for running NOVO imaging.

ngimager.pipelines.core

The CLI is implemented via Typer, so the script behaves like a simple one-argument command:

- argument = path to TOML config file

The pipeline will:

- Load the TOML config
- Detect the adapter (PHITS, ROOT, HDF5 restart)
- Shape/validate hits → events
- Build cones (now neutron + gamma depending on run.neutrons, run.gammas)
- Run SBP imaging
- Write unified HDF5 output

You can always show help via:

- `python -m ngimager.pipelines.core --help`

Example run commands from project root:

python -m ngimager.pipelines.core path/to/config.toml

python -m ngimager.pipelines.core examples/configs/phits_usrdef_simple.toml

python -m ngimager.pipelines.core .\examples\configs\phits_usrdef_simple.toml

main

main(cfg_path=typer.Argument(..., help='Path to TOML config file'), fast=typer.Option(False, '--fast', help='Override [run].fast = true (use aggressive fast settings)'), list_mode=typer.Option(False, '--list', help='Override [run].list = true (enable list-mode image output)'), neutrons=typer.Option(None, '--neutrons / --no-neutrons', help='Enable or disable neutron processing; overrides [run].neutrons when set'), gammas=typer.Option(None, '--gammas / --no-gammas', help='Enable or disable gamma processing; overrides [run].gammas when set'), input_path=typer.Option(None, '--input-path', '-i', help='Override [io].input_path from the TOML config.'), output_path=typer.Option(None, '--output-path', '-o', help='Override [io].output_path from the TOML config.'), plot_label=typer.Option(None, '--plot-label', help='Override [run].plot_label (annotation text used in visualization).'))

Run the unified ng-imager pipeline for a single config.

Source code in ngimager/pipelines/core.py
@app.command()
def main(
    cfg_path: str = typer.Argument(
        ...,
        help="Path to TOML config file",
    ),
    fast: bool = typer.Option(
        False,
        "--fast",
        help="Override [run].fast = true (use aggressive fast settings)",
    ),
    list_mode: bool = typer.Option(
        False,
        "--list",
        help="Override [run].list = true (enable list-mode image output)",
    ),
    neutrons: Optional[bool] = typer.Option(
        None,
        "--neutrons / --no-neutrons",
        help="Enable or disable neutron processing; overrides [run].neutrons when set",
    ),
    gammas: Optional[bool] = typer.Option(
        None,
        "--gammas / --no-gammas",
        help="Enable or disable gamma processing; overrides [run].gammas when set",
    ),
    input_path: Optional[str] = typer.Option(
        None,
        "--input-path",
        "-i",
        help="Override [io].input_path from the TOML config.",
    ),
    output_path: Optional[str] = typer.Option(
        None,
        "--output-path",
        "-o",
        help="Override [io].output_path from the TOML config.",
    ),
    plot_label: Optional[str] = typer.Option(
        None,
        "--plot-label",
        help="Override [run].plot_label (annotation text used in visualization).",
    ),
):
    """
    Run the unified ng-imager pipeline for a single config.
    """
    out_path = run_pipeline(
        cfg_path,
        fast=fast if fast else None,
        list_mode=list_mode if list_mode else None,
        neutrons=neutrons,
        gammas=gammas,
        input_path=input_path,
        output_path=output_path,
        plot_label=plot_label,
    )
    typer.echo(str(out_path))

run_pipeline

run_pipeline(cfg_path, *, fast=None, list_mode=None, neutrons=None, gammas=None, input_path=None, output_path=None, plot_label=None)

Orchestrate the full pipeline from a TOML config file.

CLI flags (--fast/--list/--neutrons/--no-neutrons/--gammas/--no-gammas) override the corresponding [run] fields when not None.

Parameters:

Name Type Description Default
cfg_path str

Path to TOML configuration file.

required
input_path str

When provided, overrides [io].input_path from the TOML file.

None
output_path str

When provided, overrides [io].output_path from the TOML file.

None
plot_label str

When provided, overrides [run].plot_label (used for HDF5 and visualization annotations).

None

Returns:

Type Description
Path to written HDF5 file.
Source code in ngimager/pipelines/core.py
 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
def run_pipeline(
    cfg_path: str,
    *,
    fast: Optional[bool] = None,
    list_mode: Optional[bool] = None,
    neutrons: Optional[bool] = None,
    gammas: Optional[bool] = None,
    input_path: Optional[str] = None,
    output_path: Optional[str] = None,
    plot_label: Optional[str] = None,
) -> Path:
    """
    Orchestrate the full pipeline from a TOML config file.

    CLI flags (--fast/--list/--neutrons/--no-neutrons/--gammas/--no-gammas)
    override the corresponding [run] fields when not None.

    Parameters
    ----------
    cfg_path : str
        Path to TOML configuration file.
    input_path : str, optional
        When provided, overrides [io].input_path from the TOML file.
    output_path : str, optional
        When provided, overrides [io].output_path from the TOML file.
    plot_label : str, optional
        When provided, overrides [run].plot_label (used for HDF5 and
        visualization annotations).

    Returns
    -------
    Path to written HDF5 file.
    """
    #cfg_path = str(cfg_path)
    cfg = load_config(cfg_path)

    # ---- apply CLI overrides on top of TOML ----
    if fast is not None:
        cfg.run.fast = fast
    if list_mode is not None:
        cfg.run.list = list_mode
    if neutrons is not None:
        cfg.run.neutrons = neutrons
    if gammas is not None:
        cfg.run.gammas = gammas

    if input_path is not None:
        cfg.io.input_path = input_path
    if output_path is not None:
        cfg.io.output_path = output_path
    if plot_label is not None:
        cfg.run.plot_label = plot_label

    # Conveniences
    diag_level = cfg.run.diagnostics_level
    verbose = diag_level >= 2

    # Basic logging
    if diag_level >= 1:
        print(f"[run] config = {cfg_path}")
        print(f"[run] neutrons={cfg.run.neutrons} gammas={cfg.run.gammas} "
              f"fast={cfg.run.fast} list={cfg.run.list}")
        print(f"[run] input={cfg.io.input_path} -> output={cfg.io.output_path}")
        if getattr(cfg.run, "plot_label", None):
            print(f"[run] plot_label={cfg.run.plot_label!r}")

    # Apply fast-mode overrides (if run.fast is true)
    _apply_fast_overrides(cfg, diag_level=diag_level)

    # Imaging plane
    plane = Plane.from_cfg(
        cfg.plane.origin,
        cfg.plane.normal,
        cfg.plane.u_min,
        cfg.plane.u_max,
        cfg.plane.du,
        cfg.plane.v_min,
        cfg.plane.v_max,
        cfg.plane.dv,
        eu=cfg.plane.u_axis,
        ev=cfg.plane.v_axis,
    )

    # HDF5 output
    out_path = Path(cfg.io.output_path)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    cli_argv = sys.argv
    f = write_init(str(out_path), cfg_path, cfg, plane, cli_argv=cli_argv)


    # If this is a NOVO DDAQ ROOT run, try to capture the 'meta' TTree.
    if cfg.io.input_format == "root_novo_ddaq":
        adapter_cfg_meta: Dict[str, object] = dict(cfg.io.adapter)

        adapter_for_meta = make_adapter(adapter_cfg_meta)
        extractor = getattr(adapter_for_meta, "read_meta_tree", None)

        if callable(extractor):
            try:
                meta_dict = extractor(str(cfg.io.input_path))
            except Exception as exc:
                if diag_level >= 1:
                    print(f"[meta] Failed to read ROOT meta tree: {exc}")
            else:
                if meta_dict:
                    write_root_novo_meta(f, meta_dict)
                    if diag_level >= 1:
                        print("[meta] ROOT meta tree written to /meta/root_novo_ddaq")
        else:
            if diag_level >= 1:
                print("[meta] Adapter does not support ROOT meta tree extraction; skipping")


    # Shared counters for this run
    counters: Dict[str, int] = {}

    # ---- Stage 1: adapter → raw events → hit-level filters → is_reconstructable ----
    if cfg.io.input_format in ("phits_usrdef", "root_novo_ddaq"):
        from ngimager.filters.shapers import shape_events_for_cones, ShapeConfig
        from ngimager.filters.to_typed_events import shaped_to_typed_events
        from ngimager.filters.hit_filters import apply_hit_filters, is_reconstructable

        if diag_level >= 1:
            print("\n[stage1] Raw events → hits")
            print("[stage1] Using staged path: raw events → hits → shaped → typed")

        # Build adapter config, injecting detector-level material info.
        adapter_cfg: Dict[str, object] = dict(cfg.io.adapter)

        det_cfg = getattr(cfg, "detectors", None)
        if det_cfg is not None:
            mat_map = getattr(det_cfg, "material_map", None)
            if mat_map and "material_map" not in adapter_cfg:
                adapter_cfg["material_map"] = mat_map

            default_mat = getattr(det_cfg, "default_material", None)
            if default_mat and "default_material" not in adapter_cfg:
                adapter_cfg["default_material"] = default_mat

        # --- Geometry: detector-frame → world-frame + per-detector corrections ----
        geom_cfg = getattr(det_cfg, "geometry", None) if det_cfg is not None else None

        # Global frame transform
        frame_cfg = getattr(geom_cfg, "frame", None) if geom_cfg is not None else None
        origin_cm = [0.0, 0.0, 0.0]
        rotation_deg = [0.0, 0.0, 0.0]
        use_global_transform = False
        if frame_cfg is not None:
            origin_cm = getattr(frame_cfg, "origin_cm", origin_cm)
            rotation_deg = getattr(frame_cfg, "rotation_deg", rotation_deg)
            if not is_identity_transform(origin_cm, rotation_deg):
                use_global_transform = True
                if diag_level >= 1:
                    print(
                        "[stage1] Using global detector→world transform: "
                        f"origin_cm={origin_cm}, rotation_deg={rotation_deg}"
                    )

        # Per-detector transforms: id → (origin_cm, rotation_deg)
        per_det_transforms: Dict[int, tuple[list[float], list[float]]] = {}
        if geom_cfg is not None:
            det_list = getattr(geom_cfg, "detectors", []) or []
            for det_entry in det_list:
                try:
                    det_id = int(det_entry.id)
                except Exception:
                    continue
                o = getattr(det_entry, "origin_cm", [0.0, 0.0, 0.0])
                r = getattr(det_entry, "rotation_deg", [0.0, 0.0, 0.0])
                # Skip entries that are effectively identity transforms
                if is_identity_transform(o, r):
                    continue
                per_det_transforms[det_id] = (o, r)

        use_per_det_transforms = bool(per_det_transforms)
        if use_per_det_transforms and diag_level >= 1:
            print(
                "[stage1] Per-detector transforms configured for "
                f"{len(per_det_transforms)} detector IDs"
            )


        adapter = make_adapter(adapter_cfg)

        raw_events_after_filters = []

        for ev in adapter.iter_raw_events(str(cfg.io.input_path)):
            hits = list(ev.get("hits", []))

            # Apply per-detector corrections first (local → detector frame)
            if use_per_det_transforms and hits:
                for h in hits:
                    t_cfg = per_det_transforms.get(h.det_id)
                    if t_cfg is None:
                        continue
                    o_det, r_det = t_cfg
                    # apply_rigid_transform accepts (..., 3); we pass a single row
                    h.r = apply_rigid_transform(h.r[None, :], o_det, r_det)[0]

            # Apply global detector-frame → world-frame transform
            if use_global_transform and hits:
                pts = np.stack([h.r for h in hits], axis=0)
                pts_world = apply_rigid_transform(pts, origin_cm, rotation_deg)
                for h, r_new in zip(hits, pts_world):
                    h.r = r_new

            # Normalize event_type to 'n' / 'g' where possible
            et_raw = str(ev.get("event_type", "")).lower()
            if et_raw.startswith("n"):
                et = "n"
            elif et_raw.startswith("g"):
                et = "g"
            else:
                et = None

            counters["raw_events_total"] = counters.get("raw_events_total", 0) + 1

            # ---- Stage 1.5: Apply Hit-level filters
            filtered_hits = apply_hit_filters(
                hits,
                cfg.filters.hits,
                counters,
                particle_type=et,
            )

            # Early reconstructability decision (also updates *_unreconstructable counters)
            if not is_reconstructable(filtered_hits, cfg.filters.events, counters, event_type=et):
                continue

            if not filtered_hits:
                # Should be caught by is_reconstructable, but guard anyway
                continue

            ev2 = dict(ev)
            ev2["hits"] = filtered_hits
            if et is not None:
                ev2["event_type"] = et
            raw_events_after_filters.append(ev2)

        if diag_level >= 1:
            print(
                "[stage1] raw_events_total={total} "
                "raw_events_after_filters={surv} "
                "raw_events_rejected_unreconstructable={rej}".format(
                    total=counters.get("raw_events_total", 0),
                    surv=len(raw_events_after_filters),
                    rej=counters.get("raw_events_rejected_unreconstructable", 0),
                )
            )


        # ---- Stage 2: Hits → shaped events → typed events ----
        if diag_level >= 1:
            print("\n[stage2] Hits → shaped events → typed events")

        # Shaper configuration:
        #   - PHITS: use default policies (time-ascending for both species).
        #   - ROOT NOVO DDAQ: keep neutrons time-ordered, but pick the
        #     brightest 3 gamma hits when multiplicity > 3.
        shape_cfg = ShapeConfig()
        if cfg.io.input_format == "root_novo_ddaq":
            # ROOT neutrons: enforce time-order like legacy
            shape_cfg.neutron_policy = "time_asc"
            # ROOT gammas: keep brightest-gamma rule
            shape_cfg.gamma_policy = "energy_desc"

        shaped_events, shape_diag = shape_events_for_cones(
            raw_events_after_filters,
            shape_cfg,
            counters=counters,
        )

        if diag_level >= 1:
            print(
                "[stage2] shaper: total_events_in={total} "
                "shaped_n={sn} shaped_g={sg}".format(
                    total=shape_diag.total_events,
                    sn=shape_diag.shaped_neutron,
                    sg=shape_diag.shaped_gamma,
                )
            )


        if diag_level >= 2 and shaped_events:
            print(f"[stage2] Example shaped events (up to first 3):")
            for se in shaped_events[:3]:
                ts = [h.t_ns for h in se.hits]
                Ls = [h.L for h in se.hits]
                types = [h.type for h in se.hits]
                print(
                    f"    species={se.species} "
                    f"n_hits={len(se.hits)} "
                    f"types={types} "
                    f"t_ns={ts} "
                    f"L={Ls}"
                )

        events = shaped_to_typed_events(
            shaped_events,
            order_time=True,
        )

        # ---- Typed events diagnostics (Stage: hits → shaped → typed) ----
        # Species breakdown based on the typed-event objects themselves.
        # This does not assume any particular event-level filters yet.
        from ngimager.physics.events import NeutronEvent, GammaEvent
        n_n = sum(isinstance(ev, NeutronEvent) for ev in events)
        n_g = sum(isinstance(ev, GammaEvent) for ev in events)
        counters["events_typed_total"] = n_n + n_g
        counters["events_typed_n"] = n_n
        counters["events_typed_g"] = n_g
        # Placeholder for future event-level rejections (event filters)
        # so that a later filter stage can do:
        #   counters["events_rejected_filters"] = ...
        events_rejected = counters.get("events_rejected_filters", 0)

        if diag_level >= 1:
            print(
                "[stage2] typed_total={total} "
                "typed_n={n} typed_g={g} "
                "events_rejected_filters={rej}".format(
                    total=len(events),
                    n=n_n,
                    g=n_g,
                    rej=events_rejected,
                )
            )

    else:
        # For non-PHITS sources, keep the existing direct typed-event path.
        events = list(_iter_source_events(cfg))


    # ---- Stage 2.5: Apply Event-level filters (e.g. ToF windows) ----
    events = apply_event_filters(
        events,
        cfg.filters.events,
        counters,
    )

    if diag_level >= 1:
        print(
            "[stage2] events_total_for_filters={etf} "
            "events_after_filters={eaf} "
            "events_rejected_filters={er}".format(
                etf=counters.get("events_total_for_filters", 0),
                eaf=counters.get("events_after_filters", 0),
                er=counters.get("events_rejected_filters", 0),
            )
        )

    # Existing diagnostics on typed events
    if diag_level >= 1:
        print(f"[stage2] Got {len(events)} events")
    if events:
        first = events[0]
        h1 = getattr(first, "h1", None)
        h2 = getattr(first, "h2", None)
        if diag_level >= 2:
            print(f"[stage2] First event type: {type(first).__name__}")
            print(f"[stage2] First event h1: {h1!r}")
            print(f"[stage2] First event h2: {h2!r}")
            if h1 is not None:
                print(f"[stage2] h1.r = {getattr(h1, 'r', None)}, t_ns={h1.t_ns}, L={h1.L}")
            if h2 is not None:
                print(f"[stage2] h2.r = {getattr(h2, 'r', None)}, t_ns={h2.t_ns}, L={h2.L}")
            for ev in events[:3]:
                species = "n" if isinstance(ev, NeutronEvent) else "g" if isinstance(ev, GammaEvent) else "?"
                hlist = [getattr(ev, name) for name in ("h1", "h2", "h3") if hasattr(ev, name)]
                ts = [h.t_ns for h in hlist]
                Ls = [h.L for h in hlist]
                types = [h.type for h in hlist]
                print(
                    f"    {species}-event "
                    f"n_hits={len(hlist)} "
                    f"types={types} "
                    f"t_ns={ts} "
                    f"L={Ls}"
                )

    # Write to output Per-event / per-hit physics (this links back via /lm/events dataset)
    write_events_hits(f, events)



    # ---- Stage 3: Typed events → candidate cones → selected cones ----
    if diag_level >= 1:
        print("\n[stage3] Events → candidate cones → selected cones")
    # Cones from events
    (
        cone_ids,
        apex_xyz_cm,
        axis_xyz,
        theta_rad,
        cone_species,
        recoil_code,
        incident_energy_MeV,
        cone_event_index,
        gamma_hit_order,
    ) = _build_cones_from_events(cfg, events, plane, counters)

    # --- Build per-event cone survival arrays (event → cone_id) ---
    n_events = len(events)
    event_cone_id = np.full(n_events, -1, dtype=np.int32)
    event_imaged_cone_id = np.full(n_events, -1, dtype=np.int32)  # will be filled in LM block if list-mode

    for cid, ev_idx in zip(cone_ids, cone_event_index):
        # Defensive check; ev_idx should be in [0, n_events)
        if 0 <= int(ev_idx) < n_events:
            # For now, each event yields at most one cone; keep the first if ever that changes.
            if event_cone_id[ev_idx] == -1:
                event_cone_id[ev_idx] = int(cid)

    # Counters and diagnostics for cones
    n_cones = int(len(cone_ids))
    n_n = int(np.count_nonzero(cone_species == 0))
    n_g = int(np.count_nonzero(cone_species == 1))
    # Neutron recoil breakdown
    n_p = int(np.count_nonzero((cone_species == 0) & (recoil_code == 1)))
    n_C = int(np.count_nonzero((cone_species == 0) & (recoil_code == 2)))
    n_unknown = max(0, n_n - n_p - n_C)
    _inc(counters, "cones_n_recoil_proton", n_p)
    _inc(counters, "cones_n_recoil_carbon", n_C)
    _inc(counters, "cones_n_recoil_unknown", n_unknown)

    if diag_level >= 1:
        print(
            "[stage3] Built {total} cones "
            "(neutron={n_n}, gamma={n_g})".format(
                total=n_cones,
                n_n=n_n,
                n_g=n_g,
            )
        )
        if n_cones and diag_level >= 2:
            print("[stage3] Example cone apex:", apex_xyz_cm[0])
            print("[stage3] Example cone dir:", axis_xyz[0])
            print("[stage3] Example cone theta[deg]:", np.degrees(theta_rad[0]))
            print(
                "[stage3] Neutron recoil breakdown: "
                f"proton={n_p}, carbon={n_C}, unknown={n_unknown}"
            )

    # Write to output Per-cone geometry + classification
    write_cones(
        f,
        cone_ids,
        apex_xyz_cm,
        axis_xyz,
        theta_rad,
        species=cone_species,
        recoil_code=recoil_code,
        incident_energy_MeV=incident_energy_MeV,
        event_index=cone_event_index,
        gamma_hit_order=gamma_hit_order,
    )

    # ---- Stage 4: Cones → imaging/reconstruction (SPB) ----
    if diag_level >= 1:
        print("\n[stage4] Cones → imaging / reconstruction (SBP)")
    # Choose SBP engine from config
    sbp_engine = getattr(cfg.run, "sbp_engine", "scan")

    # Build Cone objects from the geometry arrays
    cones_for_sbp: list[Cone] = [
        Cone(apex=apex_xyz_cm[i], direction=axis_xyz[i], theta=float(theta_rad[i]))
        for i in range(len(cone_ids))
    ]

    # Partition cones by species for separate images
    cones_n: list[Cone] = []
    cones_g: list[Cone] = []
    for c, s in zip(cones_for_sbp, cone_species):
        if s == 0:
            cones_n.append(c)
        elif s == 1:
            cones_g.append(c)

    img_n = None
    img_g = None

    # Projection / ROI configuration (for HDF5 + visualization)
    vis_cfg = getattr(cfg, "vis", None)
    proj_cfg = getattr(vis_cfg, "projections", None) if vis_cfg is not None else None

    projections_enabled = bool(
        getattr(proj_cfg, "enabled", False)
    ) if proj_cfg is not None else False

    roi: tuple[float, float, float, float] | None = None
    if projections_enabled and proj_cfg is not None:
        u0 = getattr(proj_cfg, "roi_u_min_cm", None)
        u1 = getattr(proj_cfg, "roi_u_max_cm", None)
        v0 = getattr(proj_cfg, "roi_v_min_cm", None)
        v1 = getattr(proj_cfg, "roi_v_max_cm", None)
        # Only use ROI if all four bounds are provided
        if None not in (u0, u1, v0, v1):
            roi = (float(u0), float(u1), float(v0), float(v1))

    # Projection-plotting configuration (metrics_source, curve_mode, etc.)
    metrics_source = "auto"
    curve_mode = "all+roi"
    annotate_summary = "compact"
    show_metrics_panel = False
    show_peak_markers = True
    show_edge_markers = True
    show_centroid_2d = False
    if proj_cfg is not None:
        plot_cfg = getattr(proj_cfg, "plot", None)
        if plot_cfg is not None:
            metrics_source = getattr(plot_cfg, "metrics_source", metrics_source)
            curve_mode = getattr(plot_cfg, "curve_mode", curve_mode)
            annotate_summary = getattr(plot_cfg, "annotate_summary", annotate_summary)
            show_metrics_panel = getattr(plot_cfg, "show_metrics_panel", show_metrics_panel)
            show_peak_markers = getattr(plot_cfg, "show_peak_markers", show_peak_markers)
            show_edge_markers = getattr(plot_cfg, "show_edge_markers", show_edge_markers)
            show_centroid_2d = getattr(plot_cfg, "show_centroid_2d", show_centroid_2d)

    # --- 4a. Neutron-only image ---
    if cones_n:
        if diag_level >= 1:
            print( "[stage4] Imaging neutrons...")

        recon_n = reconstruct_sbp(
            cones=cones_n,
            plane=plane,
            workers=cfg.run.workers,
            chunk_cones=cfg.run.chunk_cones,
            list_mode=False,  # LM handled separately below
            # uncertainty_mode stays at default "off" for now
            progress=cfg.run.progress,
            sbp_engine=sbp_engine,
            use_jit=cfg.run.jit,
        )

        img_n = recon_n.summed.astype(np.float32)
        write_summed(f, "n", img_n)

        if diag_level >= 1:
            print(
                "[stage4] Recon summed image stats (n):",
                "min=", float(img_n.min()),
                "max=", float(img_n.max()),
                "sum=", float(img_n.sum()),
                "shape=", img_n.shape,
            )

    # --- 4b. Gamma-only image ---
    if cones_g:
        if diag_level >= 1:
            print( "[stage4] Imaging gammas...")

        recon_g = reconstruct_sbp(
            cones=cones_g,
            plane=plane,
            workers=cfg.run.workers,
            chunk_cones=cfg.run.chunk_cones,
            list_mode=False,  # LM handled separately below
            progress=cfg.run.progress,
            sbp_engine=sbp_engine,
            use_jit=cfg.run.jit,
        )

        img_g = recon_g.summed.astype(np.float32)
        write_summed(f, "g", img_g)

        if diag_level >= 1:
            print(
                "[stage4] Recon summed image stats (g):",
                "min=", float(img_g.min()),
                "max=", float(img_g.max()),
                "sum=", float(img_g.sum()),
                "shape=", img_g.shape,
            )

    # --- 4c. "all" image = n + g (when both exist) ---
    img_all = None
    if img_n is not None and img_g is not None:
        # Both species present: sum them
        img_all = (img_n + img_g).astype(np.float32)
    elif img_n is not None:
        # Only neutrons present
        img_all = img_n
    elif img_g is not None:
        # Only gammas present
        img_all = img_g

    if img_all is not None:
        write_summed(f, "all", img_all)
        if diag_level >= 1:
            print(
                "[stage4] SUMMED Recon summed image stats (all):",
                "min=", float(img_all.min()),
                "max=", float(img_all.max()),
                "sum=", float(img_all.sum()),
                "shape=", img_all.shape,
            )

    # --- 4c.1. 1D projections + metrics (u / v, all + ROI) (optional) ---
    # Use [vis.projections] config, if present, to control whether projections
    # and metrics are written to the HDF5 file.
    if projections_enabled and proj_cfg is not None:
        metrics_cfg = getattr(proj_cfg, "metrics", None)

        def _maybe_write_projections(species_label: str, img):
            if img is None:
                return
            write_projections(
                f,
                species_label,
                img,
                roi_bounds_cm=roi,
                metrics_cfg=metrics_cfg,
            )

        # Per-species projections (u/v, all + ROI) + metrics
        _maybe_write_projections("n", img_n)
        _maybe_write_projections("g", img_g)
        _maybe_write_projections("all", img_all)


    # --- 4d. List-mode extras: explicit cone → pixel mapping + event survival ---
    if cfg.run.list and len(cones_for_sbp) > 0:
        lm_cone_pixel_lists: list[tuple[int, np.ndarray]] = []
        # For list-mode runs, we can now also record which cones actually
        # intersected the plane (non-empty pixel sets) on a per-event basis.
        for cid, c, ev_idx in zip(cone_ids, cones_for_sbp, cone_event_index):
            idx = cone_to_indices(c, plane, engine=sbp_engine, n_poly=360, use_jit=cfg.run.jit)  # match SBP default
            if idx is None or idx.size == 0:
                continue
            lm_cone_pixel_lists.append((int(cid), idx))
            # Mark this event as having an imaged cone, if not already set.
            if 0 <= int(ev_idx) < len(event_imaged_cone_id):
                if event_imaged_cone_id[ev_idx] == -1:
                    event_imaged_cone_id[ev_idx] = int(cid)
        write_lm_indices(f, lm_cone_pixel_lists)

    # Store per-event survival table (event → cone, event → imaged cone)
    write_event_cone_survival(f, event_cone_id, event_imaged_cone_id)

    # Store counters into the HDF5 file for later inspection
    write_counters(f, counters)

    # Optional diagnostics printout of counters (console only).
    if diag_level >= 1:
        print("\n[diagnostics] Counter summary:")
        for key in sorted(counters.keys()):
            print(f"  {key} = {counters[key]}")

    f.close()

    # ---- Stage 5: Visualization (optional) ----
    if getattr(cfg, "vis", None) and getattr(cfg.vis, "export_png_on_write", False):
        try:
            # Species to render (n/g/all)
            species = getattr(cfg.vis, "species", ["n", "g", "all"])

            # Always include PNG; add extra formats from config (e.g. ["pdf"])
            formats = ["png"]
            extra = getattr(cfg.vis, "extra_formats", [])
            for fmt in extra:
                fmt = str(fmt).lower()
                if fmt and fmt not in formats:
                    formats.append(fmt)

            image_paths = render_summed_images(
                str(out_path),
                species=species,
                filename_pattern=getattr(
                    cfg.vis,
                    "filename_pattern",
                    "{species}_{stem}.{ext}",
                ),
                center_on_plane_center=getattr(cfg.vis, "center_on_plane_center", True),
                flip_vertical=getattr(cfg.vis, "flip_vertical", True),
                axis_units=getattr(cfg.vis, "axis_units", "cm"),
                cmap=getattr(cfg.vis, "cmap", "cividis"),
                formats=formats,
                projections=projections_enabled,
                roi_u_min_cm=roi[0] if roi is not None else None,
                roi_u_max_cm=roi[1] if roi is not None else None,
                roi_v_min_cm=roi[2] if roi is not None else None,
                roi_v_max_cm=roi[3] if roi is not None else None,
                plot_label=getattr(cfg.run, "plot_label", None),
                metrics_source=metrics_source,
                curve_mode=curve_mode,
                annotate_summary=annotate_summary,
                show_metrics_panel=show_metrics_panel,
                show_peak_markers=show_peak_markers,
                show_edge_markers=show_edge_markers,
                show_centroid_2d=show_centroid_2d,
            )

            if cfg.run.diagnostics_level >= 1:
                if image_paths:
                    print("[stage4] Wrote images: " + ", ".join(str(p) for p in image_paths))
                else:
                    print("[stage4] No /images/summed/* datasets found to visualize")
        except Exception as e:
            if cfg.run.diagnostics_level >= 1:
                print(f"[stage4] Image export failed: {e!r}")



    return out_path

Physics: hits, events, kinematics, and cones

Building blocks that turn detector-level hits into reconstructed event cones.

ngimager.physics.hits

Hit-level representations (positions, times, deposited light/energy) and helpers.

Hit dataclass

Hit(det_id, r, t_ns, L=0.0, type='UNK', material='UNK', sigma_r_cm=None, sigma_t_ns=None, sigma_L=None, extras=dict())

Canonical detector hit (physics layer).

r: position [cm] t_ns: time [ns] L: light-like measure (e.g., Elong) (dimensionless or MeVee-scale per your LUT) type: particle tag for this hit (e.g., "n" for neutron, "g" for gamma, "UNK" if unknown) material: detector material tag (e.g., "M600") extras: arbitrary per-hit fields preserved from input (psd, dE_MeV, raw columns...)

ngimager.physics.events

Composite neutron and gamma events built from multiple hits.

GammaEvent dataclass

GammaEvent(h1, h2, h3, meta=dict())

Three-interaction gamma event.

As with NeutronEvent, hits can arrive unsequenced; use .ordered() to get a time-ordered copy and .validate() to assert ordering.

ordered

ordered(copy=True)

Return a GammaEvent with hits sorted by t_ns (h1 earliest).

If copy=False, reorders self in-place and returns self.

Source code in ngimager/physics/events.py
def ordered(self, copy: bool = True) -> "GammaEvent":
    """
    Return a GammaEvent with hits sorted by t_ns (h1 earliest).

    If copy=False, reorders self in-place and returns self.
    """
    hits = self._sorted_hits()
    if copy:
        return GammaEvent(h1=hits[0], h2=hits[1], h3=hits[2], meta=dict(self.meta))
    self.h1, self.h2, self.h3 = hits
    return self

validate

validate(strict=True)

Raise ValueError if hits are not in (weakly/strictly) increasing time.

Source code in ngimager/physics/events.py
def validate(self, strict: bool = True) -> None:
    """
    Raise ValueError if hits are not in (weakly/strictly) increasing time.
    """
    if not self.is_time_ordered(strict=strict):
        raise ValueError(
            f"GammaEvent time order violation: "
            f"[{self.h1.t_ns}, {self.h2.t_ns}, {self.h3.t_ns}]"
        )

NeutronEvent dataclass

NeutronEvent(h1, h2, meta=dict())

Two-scatter neutron event.

Hits can be unsequenced when first ingested (e.g. from ROOT/PHITS), so use .ordered() to get a time-ordered event and .validate() to assert the ordering.

ordered

ordered(copy=True)

Return a NeutronEvent with hits ordered by t_ns (h1 earliest).

If copy=False, reorders self in-place and returns self.

Source code in ngimager/physics/events.py
def ordered(self, copy: bool = True) -> "NeutronEvent":
    """
    Return a NeutronEvent with hits ordered by t_ns (h1 earliest).

    If copy=False, reorders self in-place and returns self.
    """
    hits = [self.h1, self.h2]
    hits.sort(key=lambda h: h.t_ns)
    if copy:
        return NeutronEvent(h1=hits[0], h2=hits[1], meta=dict(self.meta))
    self.h1, self.h2 = hits
    return self

validate

validate(strict=True)

Raise ValueError if the hits are not in time order.

Source code in ngimager/physics/events.py
def validate(self, strict: bool = True) -> None:
    """
    Raise ValueError if the hits are not in time order.
    """
    if not self.is_time_ordered(strict=strict):
        raise ValueError(
            f"NeutronEvent time order violation: "
            f"h1.t_ns={self.h1.t_ns}, h2.t_ns={self.h2.t_ns}"
        )

ngimager.physics.kinematics

Kinematic relationships for neutrons and gamma rays (e.g. scatter angles, ToF).

compton_incident_energy_from_second_scatter

compton_incident_energy_from_second_scatter(dE1_MeV, dE2_MeV, theta2_rad)

Incident gamma energy Eg [MeV] from:

  • dE1: energy deposited at 1st scatter [MeV]
  • dE2: energy deposited at 2nd scatter [MeV]
  • theta2: angle between 1->2 and 2->3 baselines [rad]

This mirrors the NOVO primer / legacy implementation:

Eg = dE1 + 0.5 * ( dE2 + sqrt( dE2^2 + 4*dE2*me / (1 - cos(theta2)) ) )

Raises:

Type Description
ValueError

If inputs are non-physical (negative energies, grazing angles, etc.).

Source code in ngimager/physics/kinematics.py
def compton_incident_energy_from_second_scatter(
    dE1_MeV: float,
    dE2_MeV: float,
    theta2_rad: float,
) -> float:
    """
    Incident gamma energy Eg [MeV] from:

      - dE1: energy deposited at 1st scatter [MeV]
      - dE2: energy deposited at 2nd scatter [MeV]
      - theta2: angle between 1->2 and 2->3 baselines [rad]

    This mirrors the NOVO primer / legacy implementation:

        Eg = dE1 + 0.5 * ( dE2 + sqrt( dE2^2 + 4*dE2*me / (1 - cos(theta2)) ) )

    Raises
    ------
    ValueError
        If inputs are non-physical (negative energies, grazing angles, etc.).
    """
    if dE1_MeV <= 0.0 or dE2_MeV <= 0.0:
        raise ValueError(f"Non-positive gamma deposits: dE1={dE1_MeV}, dE2={dE2_MeV}")

    cos_t2 = float(np.cos(theta2_rad))
    denom = 1.0 - cos_t2
    if denom <= 0.0:
        raise ValueError(f"Non-physical second-scatter angle theta2={theta2_rad} (denominator <= 0).")

    radicand = dE2_MeV**2 + (4.0 * dE2_MeV * M_E_MEV / denom)
    if radicand <= 0.0:
        raise ValueError(f"Non-physical Compton radicand={radicand} for gamma cone.")

    Eg = dE1_MeV + 0.5 * (dE2_MeV + float(np.sqrt(radicand)))
    if not np.isfinite(Eg) or Eg <= 0.0:
        raise ValueError(f"Non-physical incident gamma energy Eg={Eg}")

    return Eg

compton_theta_from_energies

compton_theta_from_energies(Eg_MeV, Egp_MeV)

First Compton scatter angle theta1 [rad] from:

Eg  : incident gamma energy [MeV]
Egp : post-first-scatter gamma energy [MeV]

Uses the standard Compton relation:

cos(theta) = 1 + me * (1/Eg - 1/Egp)

Raises:

Type Description
ValueError

If energies are non-physical or the argument to arccos is out of [-1, 1].

Source code in ngimager/physics/kinematics.py
def compton_theta_from_energies(Eg_MeV: float, Egp_MeV: float) -> float:
    """
    First Compton scatter angle theta1 [rad] from:

        Eg  : incident gamma energy [MeV]
        Egp : post-first-scatter gamma energy [MeV]

    Uses the standard Compton relation:

        cos(theta) = 1 + me * (1/Eg - 1/Egp)

    Raises
    ------
    ValueError
        If energies are non-physical or the argument to arccos is out of [-1, 1].
    """
    if Eg_MeV <= 0.0 or Egp_MeV <= 0.0 or Egp_MeV >= Eg_MeV:
        raise ValueError(f"Non-physical gamma energies Eg={Eg_MeV}, Eg'={Egp_MeV}")

    arg = 1.0 + M_E_MEV * ((1.0 / Eg_MeV) - (1.0 / Egp_MeV))

    # If we drift outside [-1, 1] more than a tiny epsilon, treat as non-physical
    if arg < -1.0 - 1e-6 or arg > 1.0 + 1e-6:
        raise ValueError(f"Compton argument out of domain: arg={arg}")

    arg = float(np.clip(arg, -1.0, 1.0))
    theta = float(np.arccos(arg))
    return theta

neutron_theta_from_hits

neutron_theta_from_hits(r1_cm, t1_ns, r2_cm, t2_ns, Edep1_MeV, scatter_nucleus='H', return_En=False)

Full calculation consistent with the NOVO primer: E' via ToF between hits 1->2 (relativistic), E_n = E' + Edep1, theta_lab from COM using A = m_recoil/m_n.

If return_En is False (default), returns theta [rad]. If return_En is True, returns (theta [rad], En [MeV]).

Source code in ngimager/physics/kinematics.py
def neutron_theta_from_hits(
    r1_cm: np.ndarray, t1_ns: float,
    r2_cm: np.ndarray, t2_ns: float,
    Edep1_MeV: float,
    scatter_nucleus: str = "H",
    return_En: bool = False,
) -> float:
    """
    Full calculation consistent with the NOVO primer:
      E' via ToF between hits 1->2 (relativistic),
      E_n = E' + Edep1,
      theta_lab from COM using A = m_recoil/m_n.

    If return_En is False (default), returns theta [rad].
    If return_En is True, returns (theta [rad], En [MeV]).
    """
    s = float(np.linalg.norm(r2_cm - r1_cm))
    dt = float(t2_ns - t1_ns)
    Eprime = tof_energy_relativistic(s, dt)      # MeV
    En = Eprime + Edep1_MeV                      # MeV
    A = mass_ratio_A(scatter_nucleus)
    theta = theta_lab_from_Erecoil_En(Edep1_MeV, En, A)
    if return_En:
        return float(theta), float(En)
    return float(theta)

theta_lab_from_Erecoil_En

theta_lab_from_Erecoil_En(E_recoil, E_n, A)

Compute neutron lab-frame scattering half-angle [rad] from E_recoil, E_n, and A = m_recoil/m_n. Follows primer equations for theta_CoM then lab mapping.

Source code in ngimager/physics/kinematics.py
def theta_lab_from_Erecoil_En(E_recoil: float, E_n: float, A: float) -> float:
    """
    Compute neutron lab-frame scattering half-angle [rad] from E_recoil, E_n, and A = m_recoil/m_n.
    Follows primer equations for theta_CoM then lab mapping.
    """
    if E_recoil <= 0 or E_n <= 0 or E_recoil > E_n:
        raise ValueError("Non-physical energies for theta.")
    # theta_CoM
    cos_arg = 1.0 - (E_recoil / E_n) * ((1.0 + A)**2) / (2.0 * A)
    # guard numerical drift
    cos_arg = np.clip(cos_arg, -1.0, 1.0)
    theta_CoM = np.arccos(cos_arg)

    # theta_lab
    num = np.sin(theta_CoM)
    den = np.cos(theta_CoM) + (1.0 / A)
    return np.arctan2(num, den)

tof_energy_relativistic

tof_energy_relativistic(s_cm, dt_ns)

Relativistic neutron KE E' [MeV] from flight distance s [cm] and time dt [ns].

Source code in ngimager/physics/kinematics.py
def tof_energy_relativistic(s_cm: float, dt_ns: float) -> float:
    """
    Relativistic neutron KE E' [MeV] from flight distance s [cm] and time dt [ns].
    """
    if dt_ns <= 0:
        raise ValueError("Non-positive ToF; cannot compute E'.")
    v = s_cm / dt_ns  # cm/ns
    beta2 = (v / C_CM_PER_NS)**2
    if beta2 <= 0 or beta2 >= 1:
        raise ValueError("Non-physical beta^2 from s/dt.")
    gamma = 1.0 / np.sqrt(1.0 - beta2)
    return (gamma - 1.0) * M_N_MEV

ngimager.physics.cones

Construction of event cones (vertex, axis, half-angle) from reconstructed events.

build_cone_from_gamma

build_cone_from_gamma(ev, energy_model, plane=None, prior=None, return_meta=False, return_perm=False)

Build a Compton gamma cone from a three-hit GammaEvent.

Behavior without plane/prior (backwards-compatible, PHITS-oriented): - Use ev.ordered() so that h1, h2, h3 are in increasing time, which is physically the true order in PHITS data. - Attempt to build a cone from this ordered triplet using _gamma_cone_from_ordered_hits. - If no physically valid cone exists for this ordering, raise ValueError.

Enhanced behavior when plane is provided: - Generate all 3! permutations of (h1, h2, h3). - For each ordering: * call _gamma_cone_from_ordered_hits(h1, h2, h3), * discard if it returns None (non-physical), * discard if the cone axis does not point toward the plane (t_int <= 0 via _axis_towards_plane), * compute Δ = |φ − θ| using the configured prior or, if prior is None, the plane center as an implicit prior. - Select the candidate with minimal Δ. - If no candidate survives, fall back to the ordered (time) triplet as in the simple behavior; if that also fails, raise ValueError.

Return value
  • If return_meta and return_perm are both False (default), returns only a Cone.
  • If return_meta is True and return_perm is False, returns (cone, Eg_MeV) where Eg_MeV is the incident gamma energy for the selected ordering.
  • If return_meta is False and return_perm is True, returns (cone, perm) where perm is a tuple (i0, i1, i2) with indices into the event's time-ordered hit list.
  • If both return_meta and return_perm are True, returns (cone, Eg_MeV, perm).
Notes
  • For now, we do not use energy_model for gammas: Hit.L is already the deposited energy in MeV (Edep) from the adapter.

  • This function is designed so that callers who do not yet pass a Plane or Prior still get the old, simple behavior.

Source code in ngimager/physics/cones.py
def build_cone_from_gamma(
    ev: GammaEvent,
    energy_model: EnergyStrategy,
    plane: Optional[Plane] = None,
    prior: Optional[Prior] = None,
    return_meta: bool = False,
    return_perm: bool = False,
) -> Cone:
    """
    Build a Compton gamma cone from a three-hit GammaEvent.

    Behavior without plane/prior (backwards-compatible, PHITS-oriented):
      - Use ev.ordered() so that h1, h2, h3 are in increasing time,
        which is physically the true order in PHITS data.
      - Attempt to build a cone from this ordered triplet using
        _gamma_cone_from_ordered_hits.
      - If no physically valid cone exists for this ordering, raise ValueError.

    Enhanced behavior when `plane` is provided:
      - Generate all 3! permutations of (h1, h2, h3).
      - For each ordering:
          * call _gamma_cone_from_ordered_hits(h1, h2, h3),
          * discard if it returns None (non-physical),
          * discard if the cone axis does not point toward the plane
            (t_int <= 0 via _axis_towards_plane),
          * compute Δ = |φ − θ| using the configured prior or, if prior is None,
            the plane center as an implicit prior.
      - Select the candidate with minimal Δ.
      - If no candidate survives, fall back to the ordered (time) triplet
        as in the simple behavior; if that also fails, raise ValueError.

    Return value
    ------------
    * If return_meta and return_perm are both False (default), returns only
      a Cone.
    * If return_meta is True and return_perm is False, returns (cone, Eg_MeV)
      where Eg_MeV is the incident gamma energy for the selected ordering.
    * If return_meta is False and return_perm is True, returns (cone, perm)
      where perm is a tuple (i0, i1, i2) with indices into the event's
      time-ordered hit list.
    * If both return_meta and return_perm are True, returns
      (cone, Eg_MeV, perm).

    Notes
    -----
    * For now, we do not use `energy_model` for gammas: Hit.L is already
      the deposited energy in MeV (Edep) from the adapter.

    * This function is designed so that callers who do not yet pass a Plane
      or Prior still get the old, simple behavior.
    """
    # Ensure we have a time-ordered GammaEvent (PHITS case)
    ev_ord = ev.ordered(copy=True)
    hits = [ev_ord.h1, ev_ord.h2, ev_ord.h3]

    def _pack_return(cone: Cone, Eg: float) -> Cone:
        """
        Helper to package return values according to return_meta/return_perm.
        """
        perm_default = (0, 0, 0)  # will be overridden when we really track a perm
        if not return_meta and not return_perm:
            return cone
        if return_meta and not return_perm:
            return cone, float(Eg)
        if not return_meta and return_perm:
            # For callers that only care about the permutation and not Eg,
            # the caller will override perm_default with the actual perm.
            return cone, perm_default
        # Both meta and permutation requested
        return cone, float(Eg), perm_default

    # Backwards-compatible path: no plane provided → use only the ordered triplet
    if plane is None:
        cone, Eg = _gamma_cone_from_ordered_hits(*hits, return_Eg=True)
        if cone is None:
            raise ValueError(
                "GammaEvent cannot produce a physical Compton cone from ordered hits."
            )

        # Simple case: the ordering is just (0, 1, 2) in time
        base_perm = (0, 1, 2)
        if not return_perm:
            if return_meta:
                return cone, float(Eg)
            return cone
        else:
            if return_meta:
                return cone, float(Eg), base_perm
            return cone, base_perm

    # Full permutation + prior-aware scoring path
    best_cone: Cone | None = None
    best_score: float | None = None
    best_Eg: float | None = None
    best_perm: tuple[int, int, int] | None = None

    for perm in permutations((0, 1, 2), 3):
        i0, i1, i2 = perm
        h1, h2, h3 = hits[i0], hits[i1], hits[i2]
        c, Eg = _gamma_cone_from_ordered_hits(h1, h2, h3, return_Eg=True)
        if c is None:
            continue

        # Reject cones whose axis does not point toward the imaging plane
        if not _axis_towards_plane(c.apex, c.dir, plane):
            continue

        # Δ = |φ − θ| using explicit prior or implicit plane-center prior
        score = _score_cone_against_prior(c, plane, prior)
        if score is None:
            # Degenerate prior geometry; treat as unusable candidate
            continue

        if best_cone is None or score < best_score:
            best_cone = c
            best_score = score
            best_Eg = float(Eg)
            best_perm = perm

    if best_cone is not None and best_perm is not None:
        Eg = float(best_Eg) if best_Eg is not None else float("nan")
        if not return_perm:
            if return_meta:
                return best_cone, Eg
            return best_cone
        else:
            if return_meta:
                return best_cone, Eg, best_perm
            return best_cone, best_perm

    # If no candidate survived, fall back to the time-ordered triplet as a last resort
    fallback_cone, fallback_Eg = _gamma_cone_from_ordered_hits(*hits, return_Eg=True)
    if fallback_cone is None or not _axis_towards_plane(
        fallback_cone.apex, fallback_cone.dir, plane
    ):
        raise ValueError(
            "GammaEvent cannot produce a physical Compton cone from any hit permutation."
        )

    base_perm = (0, 1, 2)
    if not return_perm:
        if return_meta:
            return fallback_cone, float(fallback_Eg)
        return fallback_cone
    else:
        if return_meta:
            return fallback_cone, float(fallback_Eg), base_perm
        return fallback_cone, base_perm

build_cone_from_neutron

build_cone_from_neutron(ev, energy_model, plane=None, prior=None, force_proton=False, return_meta=False)

Build a neutron cone using the NOVO imaging primer convention:

  • apex O = X1 (first hit position),
  • axis D = unit vector along the scattered neutron direction (X2 - X1),
  • half-angle θ from elastic n–N kinematics in the lab frame.
Behavior (high level)
  • The event is assumed to be time-ordered (h1 before h2); callers should use ev.ordered() upstream, as the pipeline already does.

  • We always use the full kinematic chain from kinematics.py:

    E' = E_n' from ToF between hits 1→2 (relativistic), En = E' + E_dep,1, θ = θ_lab(E_dep,1, En, A) with A = m_recoil / m_n,

where E_dep,1 is obtained from energy_model.first_scatter_energy(...) and A is set by the assumed recoil nucleus ("H" or "C").

  • If force_proton is True, or if plane is None, we build a single proton-recoil hypothesis and return it (backwards-compatible path).

  • Otherwise, we build both proton and carbon hypotheses, reject any that are non-physical, enforce that the cone axis points toward the imaging plane, and then score the survivors against the prior using the same Δ = |φ − θ| metric used for gammas. The winner is the hypothesis with the smallest Δ.

If both hypotheses fail scoring (e.g. degenerate prior geometry), we fall back to the proton-only construction.

Notes
  • This function does not mutate the event or record which hypothesis "won"; that bookkeeping is left to callers via the returned recoil_code and En.
Source code in ngimager/physics/cones.py
def build_cone_from_neutron(
    ev: NeutronEvent,
    energy_model: EnergyStrategy,
    plane: Optional[Plane] = None,
    prior: Optional[Prior] = None,
    force_proton: bool = False,
    return_meta: bool = False,
) -> Cone | tuple[Cone, int]:
    """
    Build a neutron cone using the NOVO imaging primer convention:

      - apex O = X1 (first hit position),
      - axis D = unit vector along the scattered neutron direction (X2 - X1),
      - half-angle θ from elastic n–N kinematics in the lab frame.

    Behavior (high level)
    ---------------------
    * The event is assumed to be time-ordered (h1 before h2); callers
      should use ev.ordered() upstream, as the pipeline already does.

    * We always use the full kinematic chain from kinematics.py:

          E'   = E_n' from ToF between hits 1→2 (relativistic),
          En   = E' + E_dep,1,
          θ    = θ_lab(E_dep,1, En, A) with A = m_recoil / m_n,

      where E_dep,1 is obtained from `energy_model.first_scatter_energy(...)`
      and A is set by the assumed recoil nucleus ("H" or "C").

    * If `force_proton` is True, or if `plane` is None, we build a single
      proton-recoil hypothesis and return it (backwards-compatible path).

    * Otherwise, we build both proton and carbon hypotheses, reject any
      that are non-physical, enforce that the cone axis points toward
      the imaging plane, and then score the survivors against the prior
      using the same Δ = |φ − θ| metric used for gammas. The winner is
      the hypothesis with the smallest Δ.

      If both hypotheses fail scoring (e.g. degenerate prior geometry),
      we fall back to the proton-only construction.

    Metadata / return value
    -----------------------
    * By default (return_meta=False), the function returns only a Cone.

    * If return_meta is True, it returns (cone, recoil_code, En_MeV) where:

          recoil_code = 1  → proton recoil hypothesis ("H")
          recoil_code = 2  → carbon recoil hypothesis ("C")
          En_MeV      = incident neutron energy for the selected hypothesis

      This is suitable for compact storage in HDF5 and for cone-level
      max-energy cuts. Callers that do not care about the recoil species
      or En can continue to use the old, cone-only behavior.

    Notes
    -----
    * This function does not mutate the event or record which hypothesis
      "won"; that bookkeeping is left to callers via the returned
      recoil_code and En.
    """
    # Basic sanity: caller should already have ordered() + validate(), but
    # doing a light check here keeps this function robust for standalone use.
    ev.validate(strict=False)
    h1, h2 = ev.h1, ev.h2

    r1 = np.asarray(h1.r, dtype=float)
    r2 = np.asarray(h2.r, dtype=float)
    t1 = float(h1.t_ns)
    t2 = float(h2.t_ns)

    # Axis: scattered neutron direction (h1 → h2), normalized
    D = r1 - r2
    L = np.linalg.norm(D)
    if not np.isfinite(L) or L <= 0.0:
        raise ValueError("Zero or non-physical baseline between hits in NeutronEvent.")

    Dhat = D / L
    apex = r1.copy()

    def _candidate_for_nucleus(
        recoil_nucleus: str,
    ) -> tuple[Optional[Cone], Optional[float], Optional[float]]:
        """
        Helper: construct a Cone for a given recoil nucleus label ("H" or "C")
        and return (cone, score, En) where:

          - cone  : Cone instance or None if non-physical
          - score : Δ = |φ − θ| if a prior is available, else None
          - En    : incident neutron energy [MeV] for this hypothesis, or None
        """
        # Decide which species key to use for the energy strategy.
        # For ELUT, we have distinct proton/carbon bands; for other
        # strategies (ToF, FixedEn, Edep) we keep proton-like behavior
        # for E_dep,1 to remain compatible with legacy usage.
        species_key = "proton"
        if getattr(energy_model, "name", None) == "ELUT":
            species_key = "proton" if recoil_nucleus == "H" else "carbon"

        # E_dep at first scatter from the energy model.
        try:
            Edep1_MeV, _ = energy_model.first_scatter_energy(
                h1,
                h2,
                h1.material,
                species=species_key,
            )
        except Exception:
            return None, None, None

        # Full COM → lab mapping using primer-consistent kinematics.
        try:
            theta, En = neutron_theta_from_hits(
                r1, t1,
                r2, t2,
                Edep1_MeV,
                scatter_nucleus=recoil_nucleus,
                return_En=True,
            )
        except Exception:
            return None, None, None

        # Reject clearly non-physical / degenerate angles
        if (not np.isfinite(theta)) or (theta <= 0.0) or (theta >= np.pi):
            return None, None, None

        c = Cone(apex=apex, direction=Dhat, theta=float(theta))

        # If we have a plane, enforce that the cone axis points toward it.
        if plane is not None and (not _axis_towards_plane(apex, Dhat, plane)):
            return None, None, None

        # If we don't have a plane, we can't do prior-based scoring.
        if plane is None:
            return c, None, float(En)

        score = _score_cone_against_prior(c, plane, prior)
        return c, score, float(En)

    # Proton-only path: either explicitly requested, or no plane available.
    if force_proton or plane is None:
        c_p, _, En_p = _candidate_for_nucleus("H")
        if c_p is None:
            raise ValueError("NeutronEvent cannot produce a physical proton-recoil cone.")

        if return_meta:
            # 1 = proton recoil
            return c_p, 1, float(En_p)
        return c_p

    # Full proton vs carbon hypothesis test with prior-aware scoring.
    # Store tuples of (score, recoil_nucleus, cone, En).
    candidates: list[tuple[float, str, Cone, float]] = []

    for nuc in ("H", "C"):
        c, score, En = _candidate_for_nucleus(nuc)
        if c is None:
            continue
        # If scoring failed (e.g. degenerate prior geometry), skip from
        # the prior-based comparison.
        if score is None:
            continue
        candidates.append((float(score), nuc, c, float(En)))

    if candidates:
        # Pick the cone with minimal Δ = |φ − θ|
        candidates.sort(key=lambda scn: scn[0])
        best_score, best_nuc, best_cone, best_En = candidates[0]

        if return_meta:
            recoil_code = 1 if best_nuc == "H" else 2  # 1=proton, 2=carbon
            return best_cone, recoil_code, float(best_En)
        return best_cone

    # If we got here, either both hypotheses were non-physical or prior
    # scoring failed for both. Fall back to proton-only construction
    # without using the prior.
    c_p, _, En_p = _candidate_for_nucleus("H")
    if c_p is None:
        raise ValueError("NeutronEvent cannot produce a physical cone (proton or carbon).")

    if return_meta:
        # In this fallback, we default to proton-like tagging.
        return c_p, 1, float(En_p)
    return c_p

enumerate_gamma_cone_candidates

enumerate_gamma_cone_candidates(ev)

Enumerate all physically valid Compton cones for the 3! permutations of a three-hit GammaEvent.

Parameters:

Name Type Description Default
ev GammaEvent

A GammaEvent with exactly three hits (h1, h2, h3). The event is assumed to be already validated for basic consistency.

required

Returns:

Name Type Description
candidates list[tuple[Cone, tuple[int, int, int]]]

List of (cone, perm) tuples where:

  • cone is a Cone instance produced by _gamma_cone_from_ordered_hits
  • perm is a tuple of indices (i0, i1, i2) into (h1, h2, h3), describing which hit played the role of first/second/third scatter in the kinematic construction.

Only permutations that yield a physically valid Compton cone (non-negative energies, sensible angles, non-degenerate geometry) are returned. If no permutation is viable, the list is empty.

Notes
  • This function is kinematics-only: it does NOT apply any priors or scoring; it simply reports all physically allowed cones.

  • Subsequent stages (e.g. in the pipeline) can:

    • apply event- or cone-level filters to the candidates, and
    • use spatial/energy priors to select a "best" cone for imaging.
Source code in ngimager/physics/cones.py
def enumerate_gamma_cone_candidates(
    ev: GammaEvent,
) -> list[tuple[Cone, tuple[int, int, int]]]:
    """
    Enumerate all physically valid Compton cones for the 3! permutations
    of a three-hit GammaEvent.

    Parameters
    ----------
    ev:
        A GammaEvent with exactly three hits (h1, h2, h3). The event is
        assumed to be already validated for basic consistency.

    Returns
    -------
    candidates:
        List of (cone, perm) tuples where:

          - cone is a Cone instance produced by _gamma_cone_from_ordered_hits
          - perm is a tuple of indices (i0, i1, i2) into (h1, h2, h3),
            describing which hit played the role of first/second/third
            scatter in the kinematic construction.

        Only permutations that yield a physically valid Compton cone
        (non-negative energies, sensible angles, non-degenerate geometry)
        are returned. If no permutation is viable, the list is empty.

    Notes
    -----
    * This function is kinematics-only: it does NOT apply any priors
      or scoring; it simply reports all physically allowed cones.

    * Subsequent stages (e.g. in the pipeline) can:

        - apply event- or cone-level filters to the candidates, and
        - use spatial/energy priors to select a "best" cone for imaging.
    """
    # Access hits in a stable order; for now GammaEvent always has h1..h3.
    hits = [ev.h1, ev.h2, ev.h3]
    candidates: list[tuple[Cone, tuple[int, int, int]]] = []

    # Enumerate all permutations of (0, 1, 2). For each permutation, treat
    # hits[i0] as the first scatter, hits[i1] as the second, and hits[i2]
    # as the "third" (used only for geometry).
    for perm in itertools.permutations((0, 1, 2), 3):
        i0, i1, i2 = perm
        cone = _gamma_cone_from_ordered_hits(hits[i0], hits[i1], hits[i2], return_Eg=False)
        if cone is None:
            continue
        candidates.append((cone, perm))

    return candidates

ngimager.physics.energy_strategies

Strategies for assigning energies to scatters (ELUT, ToF, fixed-energy, etc.).

EnergyFromDeposited

Bases: EnergyStrategy

Treat Hit.L as deposited energy (MeV) directly.

This is intended for synthetic/sim sources like PHITS where Hit.L has already been filled from Edep_MeV in the adapter, so no E(L) inversion or ToF logic is needed.

EnergyFromFixedIncident

EnergyFromFixedIncident(En_MeV=14.1)

Bases: EnergyStrategy

Monoenergetic incident neutron energy (e.g. DT source).

This strategy assumes a fixed incident neutron kinetic energy En. For a given 2-hit neutron event, we:

  1. Compute the post-scatter neutron energy E' from ToF between h1 and h2.
  2. Infer the first-scatter deposited energy as Edep1 = En - E'.
  3. Reject the event if E' >= En (non-physical upscatter).

The returned value is Edep1, which downstream kinematics combine with E' to reconstruct En again. This keeps the math consistent with neutron_theta_from_hits while enforcing monoenergetic DT semantics.

Source code in ngimager/physics/energy_strategies.py
def __init__(self, En_MeV: float = 14.1):
    self.En = En_MeV

EnergyFromToF

EnergyFromToF(timing_sigma_ns=0.5)

Bases: EnergyStrategy

Compute E' from ToF, then E_total = dE + E'.

Source code in ngimager/physics/energy_strategies.py
def __init__(self, timing_sigma_ns: float = 0.5):
    self.sigma_t = timing_sigma_ns

EnergyStrategy

Base protocol: compute first-scatter energy and optional σ.

first_scatter_energy

first_scatter_energy(h1, h2, material, species='proton')

Parameters:

Name Type Description Default
h1 Hit

First and optional second hits in the event.

required
h2 Hit

First and optional second hits in the event.

required
material str

Material key (e.g. "OGS", "M600") for LUT-based strategies.

required
species Literal['proton', 'carbon'] | None

Recoil species key when relevant (e.g. "proton", "carbon").

'proton'

Returns:

Name Type Description
Edep1_MeV float

First-scatter deposited energy [MeV] to feed into the kinematics.

sigma_MeV float or None

Optional uncertainty estimate on Edep1, or None if not provided.

Source code in ngimager/physics/energy_strategies.py
def first_scatter_energy(
    self,
    h1: Hit,
    h2: Hit | None,
    material: str,
    species: Literal["proton","carbon"] | None = "proton",
) -> tuple[float, float | None]:
    """
    Parameters
    ----------
    h1, h2
        First and optional second hits in the event.
    material
        Material key (e.g. "OGS", "M600") for LUT-based strategies.
    species
        Recoil species key when relevant (e.g. "proton", "carbon").

    Returns
    -------
    Edep1_MeV : float
        First-scatter deposited energy [MeV] to feed into the kinematics.
    sigma_MeV : float or None
        Optional uncertainty estimate on Edep1, or None if not provided.
    """
    raise NotImplementedError

ngimager.physics.priors

Source and geometry priors used to weight cones and regularize imaging.

Prior

Bases: Protocol

weight_field

weight_field(plane)

Return (nv, nu) float32 weights in [0,1].

Source code in ngimager/physics/priors.py
def weight_field(self, plane: Plane) -> np.ndarray:
    """Return (nv, nu) float32 weights in [0,1]."""

make_prior

make_prior(cfg_prior, plane)

Small factory used by pipelines.core; returns a Prior or None.

Expected cfg_prior schema (from TOML, after Pydantic):

[prior] type = "none" | "point" | "line" strength = 1.0

# Point prior: # either: # point = [x, y, z] # or (future) nested [prior.point] can be normalized upstream.

# Line prior (preferred, nested): # [prior.line] # p0 = [x0, y0, z0] # p1 = [x1, y1, z1] # or: # [prior.line] # r0 = [x0, y0, z0] # direction = [dx, dy, dz] # # For backward compatibility we also accept flat: # line_p0 = [x0, y0, z0] # line_p1 = [x1, y1, z1]

Source code in ngimager/physics/priors.py
def make_prior(cfg_prior: dict, plane: Plane) -> Optional[Prior]:
    """
    Small factory used by pipelines.core; returns a Prior or None.

    Expected cfg_prior schema (from TOML, after Pydantic):

      [prior]
      type = "none" | "point" | "line"
      strength = 1.0

      # Point prior:
      # either:
      #   point = [x, y, z]
      # or (future) nested [prior.point] can be normalized upstream.

      # Line prior (preferred, nested):
      #   [prior.line]
      #   p0 = [x0, y0, z0]
      #   p1 = [x1, y1, z1]
      # or:
      #   [prior.line]
      #   r0        = [x0, y0, z0]
      #   direction = [dx, dy, dz]
      #
      # For backward compatibility we also accept flat:
      #   line_p0 = [x0, y0, z0]
      #   line_p1 = [x1, y1, z1]
    """
    #typ = (cfg_prior.get("type") or "none").lower()
    typ = cfg_prior.get("type", "none")
    strength = float(cfg_prior.get("strength", 1.0))

    if typ == "none":
        return None

    if typ == "point":
        #return PointPrior(np.asarray(cfg_prior["point"], dtype=float), strength=strength)
        if "point" in cfg_prior:
            p = np.asarray(cfg_prior["point"], dtype=float)
        else:
            # default: center of imaging plane
            p = plane.center  # or plane.origin() if we add such a helper
        return PointPrior(p, strength=strength)

    if typ == "line":
        line_cfg = cfg_prior.get("line")
        p0 = p1 = None

        if line_cfg is not None:
            # Preferred nested forms
            if "p0" in line_cfg and "p1" in line_cfg:
                p0 = np.asarray(line_cfg["p0"], dtype=float)
                p1 = np.asarray(line_cfg["p1"], dtype=float)
            elif "r0" in line_cfg and "direction" in line_cfg:
                r0 = np.asarray(line_cfg["r0"], dtype=float)
                direction = np.asarray(line_cfg["direction"], dtype=float)
                p0 = r0
                p1 = r0 + direction
            else:
                raise KeyError(
                    "prior.line must define either p0/p1 or r0/direction "
                    "(e.g. [prior.line] p0=[...] p1=[...] or r0=[...] direction=[...])"
                )
        else:
            # Backwards-compatible flat keys (if someone ever used them)
            if "line_p0" in cfg_prior and "line_p1" in cfg_prior:
                p0 = np.asarray(cfg_prior["line_p0"], dtype=float)
                p1 = np.asarray(cfg_prior["line_p1"], dtype=float)
            else:
                raise KeyError(
                    "Line prior configured with type='line' but no usable "
                    "line geometry found (expected [prior.line] or line_p0/line_p1)."
                )

        return LinePrior(p0, p1, strength=strength)

    raise ValueError(f"Unknown prior.type={cfg_prior['type']!r}")

Geometry & Imaging

Geometry primitives and the simple back-projection (SBP) imager.

ngimager.geometry.plane

Imaging plane representation and coordinate transforms (u–v basis, etc.).

Plane dataclass

Plane(P0, n, eu, ev, u_min, u_max, du, v_min, v_max, dv)

center

center()

Return the world-space coordinates of the geometric center of the imaging plane grid.

This is defined as the point corresponding to the midpoint in (u, v) coordinates:

u_c = 0.5 * (u_min + u_max)
v_c = 0.5 * (v_min + v_max)

and mapped back to 3D via plane_to_world.

Source code in ngimager/geometry/plane.py
def center(self) -> np.ndarray:
    """
    Return the world-space coordinates of the geometric center of the
    imaging plane grid.

    This is defined as the point corresponding to the midpoint in (u, v)
    coordinates:

        u_c = 0.5 * (u_min + u_max)
        v_c = 0.5 * (v_min + v_max)

    and mapped back to 3D via plane_to_world.
    """
    u_c = 0.5 * (self.u_min + self.u_max)
    v_c = 0.5 * (self.v_min + self.v_max)
    return self.plane_to_world(u_c, v_c)

ngimager.imaging.sbp

Simple back-projection implementation that projects cones onto an image plane.

cone_to_indices

cone_to_indices(c, plane, engine='poly', n_poly=360, use_jit=False)

Unified entry point: cone → flat pixel indices.

engine = "scan": Use matrix-math scanning across rows/columns (continuous arcs). engine = "poly": Use ellipse parameterization when possible, falling back to general ray sampling for non-elliptic conics.

use_jit: When True and numba is available: - "scan" engine uses a JIT-compiled inner loop. - "poly" engine uses a JIT-compiled perimeter sampler. Otherwise, pure-Python paths are used.

Source code in ngimager/imaging/sbp.py
def cone_to_indices(
    c: Cone,
    plane: Plane,
    engine: SBPEngine = "poly",
    n_poly: int = 360,
    use_jit: bool = False,
) -> np.ndarray:
    """
    Unified entry point: cone → flat pixel indices.

    engine = "scan":
        Use matrix-math scanning across rows/columns (continuous arcs).
    engine = "poly":
        Use ellipse parameterization when possible, falling back to
        general ray sampling for non-elliptic conics.

    use_jit:
        When True and numba is available:
          - "scan" engine uses a JIT-compiled inner loop.
          - "poly" engine uses a JIT-compiled perimeter sampler.
        Otherwise, pure-Python paths are used.
    """
    M = _cone_matrix(c.dir, c.theta)
    Q = _conic_Q(M, c.apex, plane)

    if engine == "scan":
        if use_jit and _scan_conic_core_numba is not None:
            return _scan_conic_indices_numba(Q, plane)
        else:
            return _scan_conic_indices_python(Q, plane)

    # Default: "poly" perimeter sampling
    el = _ellipse_from_Q(Q)
    if el is None:
        # Fallback: general ray sampling around the cone axis
        return _ray_sample_indices(c.apex, c.dir, c.theta, plane, n_phi=720)

    uv0, a, b, R = el

    if use_jit and _ellipse_poly_numba is not None:
        # Ensure types are friendly to numba
        uv0_arr = np.asarray(uv0, dtype=np.float64)
        R_arr = np.asarray(R, dtype=np.float64)
        pts = _ellipse_poly_numba(float(a), float(b), R_arr, uv0_arr, int(n_poly))
    else:
        pts = _ellipse_poly(uv0, a, b, R, n=n_poly)

    return _pixels_from_poly(pts, plane)

reconstruct_sbp

reconstruct_sbp(cones, plane, list_mode=False, uncertainty_mode='off', workers='auto', chunk_cones='auto', progress=True, n_poly=360, sbp_engine='poly', use_jit=False)

Parallel SBP (analytic conic). If workers==0, runs single-process.

sbp_engine: "poly" – perimeter parametric ellipse (with ray fallback). "scan" – matrix-math scan across pixel-centered lines (continuous arcs).

use_jit: When True and numba is available, use a JIT-compiled inner loop for the "scan" engine to accelerate the row/column solving.

Source code in ngimager/imaging/sbp.py
def reconstruct_sbp(
    cones: Iterable[Cone],
    plane: Plane,
    list_mode: bool = False,
    uncertainty_mode: Literal["off", "thicken", "weighted"] = "off",
    workers: int | str = "auto",
    chunk_cones: int | str = "auto",
    progress: bool = True,
    n_poly: int = 360,
    sbp_engine: SBPEngine = "poly",
    use_jit: bool = False,
) -> ReconResult:
    """
    Parallel SBP (analytic conic). If workers==0, runs single-process.

    sbp_engine:
        "poly" – perimeter parametric ellipse (with ray fallback).
        "scan" – matrix-math scan across pixel-centered lines (continuous arcs).

    use_jit:
        When True and numba is available, use a JIT-compiled inner loop
        for the "scan" engine to accelerate the row/column solving.
    """
    # Normalize inputs
    cones_list = list(cones)
    N = len(cones_list)
    img = np.zeros((plane.nv, plane.nu), dtype=np.uint32)
    flat_len = plane.nv * plane.nu

    if N == 0:
        return ReconResult(img, [] if list_mode else None)

    if workers == "auto":
        workers = max(1, os.cpu_count() or 1)
    elif isinstance(workers, int):
        workers = max(0, workers)
    else:
        raise ValueError("workers must be int or 'auto'")

    # Single-process path (also good for debugging)
    if workers == 0 or N < 1500:
        hit_count = 0
        lm = [] if list_mode else None
        it = tqdm(cones_list, desc="SBP", unit="cone") if progress and tqdm else cones_list
        for c in it:
            idx = cone_to_indices(c, plane, engine=sbp_engine, n_poly=n_poly, use_jit=use_jit)
            if idx.size:
                hit_count += 1
                np.add.at(img.ravel(), idx, 1)
                if lm is not None:
                    lm.append(idx)
        print(f"SBP: {hit_count}/{N} cones intersected the plane")
        return ReconResult(img, lm)

    # Multi-process path
    if chunk_cones == "auto":
        chunk_cones = _auto_chunk_size(N, plane.nu, plane.nv, workers)
    else:
        chunk_cones = int(chunk_cones)

    # Chunk the work
    chunks: List[Sequence[Cone]] = [cones_list[i : i + chunk_cones] for i in range(0, N, chunk_cones)]

    # Progress bar over chunks
    pbar = tqdm(total=len(chunks), desc=f"SBP x{workers}", unit="chunk") if (progress and tqdm) else None

    flat_total = np.zeros(flat_len, dtype=np.uint32)
    lm_all: List[np.ndarray] | None = [] if list_mode else None

    # Use spawn-friendly ProcessPoolExecutor
    with ProcessPoolExecutor(max_workers=workers) as ex:
        futs = [
            ex.submit(
                _process_chunk,
                ch,
                plane,
                list_mode,
                plane.nu,
                n_poly,
                sbp_engine,
                use_jit,
            )
            for ch in chunks
        ]
        for fut in as_completed(futs):
            flat_counts, lm_list = fut.result()
            flat_total += flat_counts
            if lm_all is not None and lm_list:
                lm_all.extend(lm_list)
            if pbar:
                pbar.update(1)
    if pbar:
        pbar.close()

    img = flat_total.reshape(plane.nv, plane.nu)
    return ReconResult(img, lm_all)

I/O & Configuration

Adapters for raw data, list-mode storage, LUT loading, and config handling.

ngimager.io.adapters

Adapters that convert external event formats (e.g. ROOT, PHITS-like) into ng-imager hits/events.

ngimager.io.adapters

Modular readers that turn external NOVO data sources (PHITS dumps or experiment/MC ROOT trees) into normalized physics-layer events (ngimager.physics.hits.Hit; ngimager.physics.events.{NeutronEvent,GammaEvent}) for the cone builder.

Design goals
  • Keep I/O concerns isolated from physics/kinematics.
  • Normalize units on ingest:
  • distances -> cm
  • times -> ns
  • Be tolerant to schema variants by using small, explicit field maps.
  • Stream (iterate) large files without loading everything into RAM.
  • Remain side-effect free: yield Python objects; HDF5 is handled downstream.
Entry points
  • class ROOTAdapter: reads NOVO ROOT trees ("novo_ddaq" or "hvl_geant4" styles).
  • class PHITSAdapter: reads tabular PHITS lists (CSV/Parquet/HDF5).
  • function make_adapter(cfg): factory from the [io.adapter] TOML section.
Config (example)

[io] input = "data/run42.root"

[io.adapter] type = "root" # "root" | "phits" style = "novo_ddaq" # ROOT styles: "novo_ddaq" | "hvl_geant4" unit_pos_is_mm = true time_units = "ns" # "ns" | "ps" require_gamma_triples = false # keep filtering in pipeline by default default_material = "M600" # tag assigned to all hits unless mapped

BaseAdapter

Abstract adapter interface.

Yields physics-layer events normalized to cm/ns (and L if present).

iter_events

iter_events(path)

Yield fully-typed physics events (NeutronEvent / GammaEvent, etc.) ready for cone building.

Source code in ngimager/io/adapters.py
def iter_events(self, path: str):
    """
    Yield fully-typed physics events (NeutronEvent / GammaEvent, etc.)
    ready for cone building.
    """
    raise NotImplementedError

iter_raw_events

iter_raw_events(path)

Yield 'raw' events as collections of canonical Hit objects.

Semantics: - Each yielded item represents a single raw coincidence window. - For PHITS usrdef, this is a dict with at least: { "event_type": "n" | "g" | ..., "hits": [Hit, Hit, ...], ... (bookkeeping fields) } - Other adapters may choose a different raw representation, but must include a 'hits' field with a sequence of Hit objects.

Source code in ngimager/io/adapters.py
def iter_raw_events(self, path: str):
    """
    Yield 'raw' events as collections of canonical Hit objects.

    Semantics:
      - Each yielded item represents a single raw coincidence window.
      - For PHITS usrdef, this is a dict with at least:
            {
                "event_type": "n" | "g" | ...,
                "hits": [Hit, Hit, ...],
                ... (bookkeeping fields)
            }
      - Other adapters may choose a different raw representation, but
        must include a 'hits' field with a sequence of Hit objects.
    """
    raise NotImplementedError

PHITSAdapter

PHITSAdapter(unit_pos_is_mm=True, time_units='ns', default_material='M600', material_map=None)

Bases: BaseAdapter

Read tabular event lists exported from PHITS post-processing.

Supported inputs: CSV (.csv), Parquet (.parquet/.pq), HDF (.h5/.hdf5).

The adapter expects row-wise events. Each row is either a neutron double or a gamma triple.

Canonical field names (columns): - x1,y1,z1,t1 ; x2,y2,z2,t2 ; [x3,y3,z3,t3] - det1,det2,[det3] ; L1,L2,[L3] (or elong1,elong2,[elong3]) - type (optional) values: 'n'|'g' ; if absent we infer by presence of 3rd hit

Units are assumed mm (pos) and ns (time) unless overridden.

Source code in ngimager/io/adapters.py
def __init__(
    self,
    unit_pos_is_mm: bool = True,
    time_units: Literal["ns", "ps"] = "ns",
    default_material: str = "M600",
    material_map: Optional[Dict[int, str]] = None,
) -> None:
    self.unit_pos_is_mm = unit_pos_is_mm
    self.time_scale = 0.001 if time_units == "ps" else 1.0
    self.default_material = default_material
    #mat_map = kwargs.get("material_map", None)
    #default_mat = kwargs.get("default_material", "UNK")
    self._material_resolver = MaterialResolver.from_mapping(material_map, default=default_material)

iter_events

iter_events(path)

Unified iterator: - If 'path' ends with .out (PHITS usrdef, ragged): parse→Hit→shape→typed and yield typed events. - Otherwise (CSV/Parquet/HDF): fall back to the existing table-based row iterator.

Source code in ngimager/io/adapters.py
def iter_events(self, path: str) -> Iterable[NeutronEvent | GammaEvent]:
    """
    Unified iterator:
      - If 'path' ends with .out (PHITS usrdef, ragged): parse→Hit→shape→typed and yield typed events.
      - Otherwise (CSV/Parquet/HDF): fall back to the existing table-based row iterator.
    """
    p = Path(path)
    if p.suffix.lower() == ".out":
        # 1) parse usrdef → Hit objects (your current helper)
        raw_events = self.iter_raw_events(path)
        #events = from_phits_usrdef(p, resolver=self._material_resolver)
        # 2) shape variable multiplicity into pairs/triples (policy from config later; defaults okay now)
        shaped, _diag = shape_events_for_cones(raw_events, ShapeConfig())
        #shaped, _diag = shape_events_for_cones(events, ShapeConfig())
        # 3) convert shaped → typed NeutronEvent/GammaEvent
        typed = shaped_to_typed_events(shaped, default_material=self.default_material, order_time=True)
        # 4) yield typed events to the pipeline
        for ev in typed:
            yield ev
        return

    # Fallback: table-based path (unchanged behavior)
    df = self._read_table(path)
    for _, r in df.iterrows():
        # Your existing table-row → typed conversion logic stays as-is here.
        # Example (pseudocode placeholder; keep your real code):
        # ev = self._row_to_event(r)  # existing function
        # yield ev
        raise NotImplementedError("Table row→typed event conversion is unchanged; keep your existing code here.")

iter_raw_events

iter_raw_events(path)

Yield PHITS 'raw' events as dicts whose 'hits' entry is a list of canonical Hit objects.

For usrdef .out files this wraps from_phits_usrdef, which: - parses the ragged usrdef text, - canonicalizes hit fields to x_cm / y_cm / z_cm / t_ns / Edep_MeV / L, - and converts each hit dict into a physics.hits.Hit, resolving the material via this adapter's MaterialResolver.

For table-like PHITS exports (CSV/Parquet/HDF5) we currently don't have a native raw-event representation, so we conservatively reconstruct a minimal raw event around each typed event.

Source code in ngimager/io/adapters.py
def iter_raw_events(self, path: str):
    """
    Yield PHITS 'raw' events as dicts whose 'hits' entry is a list of
    canonical Hit objects.

    For usrdef .out files this wraps `from_phits_usrdef`, which:
      - parses the ragged usrdef text,
      - canonicalizes hit fields to x_cm / y_cm / z_cm / t_ns / Edep_MeV / L,
      - and converts each hit dict into a physics.hits.Hit, resolving the
        material via this adapter's MaterialResolver.

    For table-like PHITS exports (CSV/Parquet/HDF5) we currently don't have
    a native raw-event representation, so we conservatively reconstruct a
    minimal raw event around each typed event.
    """
    p = Path(path)
    suffix = p.suffix.lower()

    if suffix == ".out":
        # `from_phits_usrdef` already returns a List[Dict] where
        #   ev["hits"] : List[Hit]
        # and event-level bookkeeping fields from the usrdef line.
        events = from_phits_usrdef(p, resolver=self._material_resolver)
        for ev in events:
            yield ev
        return

    # Fallback: wrap typed events as single raw events (non-.out inputs).
    from ngimager.physics.events import NeutronEvent, GammaEvent  # local import to avoid cycles

    for ev in self.iter_events(path):
        if isinstance(ev, NeutronEvent):
            hits = [ev.h1, ev.h2]
            ev_type = "n"
        elif isinstance(ev, GammaEvent):
            hits = [ev.h1, ev.h2, ev.h3]
            ev_type = "g"
        else:
            # Unknown/unsupported event type; skip
            continue

        yield {
            "event_type": ev_type,
            "hits": hits,
            "meta": getattr(ev, "meta", {}),
        }

RootNovoDdaqAdapter dataclass

RootNovoDdaqAdapter(tree_key='image_tree', unit_pos_is_mm=True, time_units='ns', default_material='UNK', material_map=None, require_gamma_triples=False, meta_tree_key='meta')

Bases: BaseAdapter

Adapter for NOVO DDAQ ROOT files ("image_tree" + optional "meta" tree).

This adapter: - reads the main coincidence tree (image_tree) and yields raw events with canonicalized hits, and - can optionally read the run-level metadata tree (meta) via read_meta_tree for passthrough into HDF5.

Parameters:

Name Type Description Default
tree_key str

Name of the ROOT TTree containing the imaging events (default: "image_tree").

'image_tree'
unit_pos_is_mm bool

If True, hit positions are stored in mm and converted to cm.

True
time_units ('ns', 'ps')

Units of the time branches (converted to ns).

"ns"
default_material str

Material tag to use when no mapping is provided.

'UNK'
material_map dict[int, str] or None

Mapping from det_id to material name.

None
require_gamma_triples bool

If True, drop gamma events that do not have exactly 3 hits.

False
meta_tree_key str or None

Name of the metadata TTree (default "meta"). If None, metadata extraction is disabled.

'meta'

iter_events

iter_events(path)

Placeholder: higher-level event shaping for NOVO DDAQ ROOT data.

For now, the ng-imager pipeline should consume iter_raw_events and run the standard shaping / filtering stack on top. This method is defined only to satisfy the BaseAdapter interface.

Source code in ngimager/io/adapters.py
def iter_events(self, path: str):
    """
    Placeholder: higher-level event shaping for NOVO DDAQ ROOT data.

    For now, the ng-imager pipeline should consume `iter_raw_events`
    and run the standard shaping / filtering stack on top. This method
    is defined only to satisfy the BaseAdapter interface.
    """
    raise NotImplementedError(
        "RootNovoDdaqAdapter.iter_events is not implemented yet; "
        "use iter_raw_events() via a staged pipeline."
    )

iter_raw_events

iter_raw_events(path)

Yield raw coincidence windows as dicts:

{
  "hits": [Hit, ...],
  "multi": int,          # as stored in the ROOT tree, if present
  "entry": int,          # global entry index
  "source": "ROOT_NOVO_DDAQ",
}

This method is intentionally conservative and does not make any physics decisions about which hits belong to neutron vs gamma events; it simply exposes the coincidence window.

Source code in ngimager/io/adapters.py
def iter_raw_events(self, path: str):
    """
    Yield raw coincidence windows as dicts:

        {
          "hits": [Hit, ...],
          "multi": int,          # as stored in the ROOT tree, if present
          "entry": int,          # global entry index
          "source": "ROOT_NOVO_DDAQ",
        }

    This method is intentionally conservative and does **not** make
    any physics decisions about which hits belong to neutron vs
    gamma events; it simply exposes the coincidence window.
    """
    path = str(path)
    tree = self._find_tree(path)

    # Determine which hit indices (1,2,3,...) are present in this tree.
    prefixes = ("x", "y", "z", "t", "dE", "psd", "det", "particle", "clipped")
    hit_indices: set[int] = set()
    for name in tree.keys():
        name_str = str(name)
        for p in prefixes:
            if name_str.startswith(p):
                suffix = name_str[len(p):]
                if suffix.isdigit():
                    hit_indices.add(int(suffix))

    if not hit_indices:
        # No per-hit branches found; nothing to yield.
        return

    indices = sorted(hit_indices)

    # Restrict arrays to just the branches we actually need.
    existing = set(str(n) for n in tree.keys())
    branch_names: list[str] = []
    if "multi" in existing:
        branch_names.append("multi")
    for idx in indices:
        for p in prefixes:
            key = f"{p}{idx}"
            if key in existing:
                branch_names.append(key)

    pos_scale = _CM_PER_MM if self.unit_pos_is_mm else 1.0
    time_scale = self._time_scale

    entry_offset = 0
    # Stream in chunks to avoid loading the full file into RAM.
    for arrays in tree.iterate(filter_name=branch_names, step_size="100 MB", library="np"):
        # NOTE: uproot returns dict-like arrays with NumPy arrays as values.
        multi_arr = arrays.get("multi")
        # Determine the row count from any branch.
        some_arr = next(iter(arrays.values()))
        n_rows = len(some_arr)

        for i in range(n_rows):
            hits: list[Hit] = []

            for idx in indices:
                # Minimal required fields; if positions or time are missing, skip this hit.
                try:
                    x = arrays[f"x{idx}"][i]
                    y = arrays[f"y{idx}"][i]
                    z = arrays[f"z{idx}"][i]
                    t = arrays[f"t{idx}"][i]
                    det = arrays[f"det{idx}"][i]
                except KeyError:
                    continue

                # Some entries may be "empty" placeholders; guard against obvious sentinels.
                try:
                    det_id = int(det)
                except Exception:
                    continue
                if det_id < 0:
                    continue

                # Positions in cm
                x_cm = float(x) * pos_scale
                y_cm = float(y) * pos_scale
                z_cm = float(z) * pos_scale
                rvec = np.array([x_cm, y_cm, z_cm], dtype=float)

                # Times in ns
                t_ns = float(t) * time_scale

                # Use dE as light-like quantity (MeVee) for now.
                dE_arr = arrays.get(f"dE{idx}")
                L_mevee = float(dE_arr[i]) if dE_arr is not None else 0.0

                # Optional PSD / particle / clipped info goes into extras.
                extras: Dict[str, Any] = {}
                psd_arr = arrays.get(f"psd{idx}")
                if psd_arr is not None:
                    extras["psd"] = float(psd_arr[i])

                part_code = None
                part_arr = arrays.get(f"particle{idx}")
                if part_arr is not None:
                    try:
                        part_code = int(part_arr[i])
                    except Exception:
                        part_code = None
                # Only keep neutron (1) and gamma (2) hits for imaging.
                if part_code not in (1, 2):
                    # e.g. laser or other special hits: drop them
                    continue
                extras["particle_code"] = part_code
                h_type = "n" if part_code == 1 else "g"

                clipped_arr = arrays.get(f"clipped{idx}")
                if clipped_arr is not None:
                    is_clipped = bool(clipped_arr[i])
                    if is_clipped:
                        # Drop this hit entirely; legacy imaging does not use clipped hits
                        continue
                    extras["clipped"] = is_clipped

                # Map particle code (if present) to a simple hit type.
                h_type: Optional[str] = None
                if part_code == 1:
                    h_type = "n"
                elif part_code == 2:
                    h_type = "g"
                elif part_code == 3:
                    h_type = "laser"

                # Resolve material from detector ID if mapping is available.
                material = self._material_resolver.material_for(det_id)

                hits.append(
                    Hit(
                        det_id=det_id,
                        r=rvec,
                        t_ns=t_ns,
                        L=L_mevee,
                        material=material,
                        type=h_type,
                        extras=extras,
                    )
                )

            if not hits:
                # Nothing usable in this coincidence window entry.
                continue

            multi_val = int(multi_arr[i]) if multi_arr is not None else len(hits)
            yield {
                "hits": hits,
                "multi": multi_val,
                "entry": entry_offset + i,
                "source": "ROOT_NOVO_DDAQ",
            }

        entry_offset += n_rows

read_meta_tree

read_meta_tree(path)

Read the NOVO 'meta' TTree (if present) and return a flat dict mapping branch names → Python scalars/strings.

This is intended for run-level metadata passthrough into HDF5. Returns None if no compatible meta tree is found.

Source code in ngimager/io/adapters.py
def read_meta_tree(self, path: str) -> Optional[Dict[str, Any]]:
    """
    Read the NOVO 'meta' TTree (if present) and return a flat dict
    mapping branch names → Python scalars/strings.

    This is intended for run-level metadata passthrough into HDF5.
    Returns None if no compatible meta tree is found.
    """
    if uproot is None:  # pragma: no cover
        return None

    path = str(path)
    f = uproot.open(path)
    try:
        tree = self._find_meta_tree(f)
        if tree is None:
            return None

        arrays = tree.arrays(library="np")
    finally:
        try:
            f.close()
        except Exception:
            pass

    if not arrays:
        return {}

    meta: Dict[str, Any] = {}
    for key, vals in arrays.items():
        # Expect exactly one entry; take the first.
        try:
            v = vals[0]
        except Exception:
            v = vals

        if isinstance(v, np.generic):
            v = v.item()

        if isinstance(v, (bytes, bytearray)):
            try:
                v = v.decode("utf-8", "ignore")
            except Exception:
                v = repr(v)

        meta[str(key)] = v

    return meta

from_phits_usrdef

from_phits_usrdef(path, *, format_hint='auto', resolver=None)

Public convenience entry point for PHITS usrdef ingestion. Currently supports the 'short' format. 'auto' is reserved for future sniffing.

Source code in ngimager/io/adapters.py
def from_phits_usrdef(path: str | Path, *, format_hint: Literal["short","auto"]="auto", 
                      resolver: MaterialResolver | None = None) -> List[Dict[str, Any]]:
    """
    Public convenience entry point for PHITS usrdef ingestion.
    Currently supports the 'short' format. 'auto' is reserved for future sniffing.
    """
    # In the future: sniff tokens/columns to choose short vs full.
    events = parse_phits_usrdef_short(path)
    canonicalize_events_inplace(events)

    # Resolve material from detector/region id via config (optional)
    if resolver is None: 
        resolver = MaterialResolver.from_env_or_defaults()

    # Convert dict-hits → Hit objects (keep source fields in extras)
    for ev in events:
        hits_H: List[Hit] = []
        ev_type = ev.get("event_type", "UNK") # event-level particle type from PHITS ("n" | "g")
        # Normalize to our canonical one-letter codes
        if ev_type.startswith("n"):
            hit_type = "n"
        elif ev_type.startswith("g"):
            hit_type = "g"
        else:
            hit_type = "UNK"
        for h in ev["hits"]:
            det = int(h["det_id"]) if "det_id" in h else int(h.get("reg", 0))
            r = np.array([h["x_cm"], h["y_cm"], h["z_cm"]], dtype=float)
            extras = dict(h.get("__extras__", {}))
            # Keep Edep explicitly in extras if present
            if "Edep_MeV" in h:
                extras.setdefault("Edep_MeV", h["Edep_MeV"])
            material = resolver.material_for(det)
            hits_H.append(Hit(det_id=det, r=r, t_ns=float(h["t_ns"]), L=float(h.get("L", extras.get("Edep_MeV", 0.0))),
                              type=hit_type, material=material, extras=extras))
        ev["hits"] = hits_H
    return events

make_adapter

make_adapter(cfg)

Create an adapter from a config dict (from TOML/CLI).

Expected keys under [io.adapter]: type: "root" | "phits" style: "novo_ddaq" | "hvl_geant4" (ROOT-only) unit_pos_is_mm: bool time_units: "ns" | "ps" require_gamma_triples: bool (ROOT-only) default_material: str

Source code in ngimager/io/adapters.py
def make_adapter(cfg: Dict) -> BaseAdapter:
    """
    Create an adapter from a config dict (from TOML/CLI).

    Expected keys under [io.adapter]:
      type: "root" | "phits"
      style: "novo_ddaq" | "hvl_geant4"            (ROOT-only)
      unit_pos_is_mm: bool
      time_units: "ns" | "ps"
      require_gamma_triples: bool       (ROOT-only)
      default_material: str
    """
    typ = (cfg.get("type") or "root").lower()

    if typ == "root":
        return ROOTAdapter(
            unit_pos_is_mm=bool(cfg.get("unit_pos_is_mm", True)),
            time_units=cfg.get("time_units", "ns"),
            default_material=cfg.get("default_material", "UNK"),
            material_map=cfg.get("material_map"),
        )

    if typ == "phits":
        return PHITSAdapter(
            unit_pos_is_mm=bool(cfg.get("unit_pos_is_mm", True)),
            time_units=cfg.get("time_units", "ns"),
            default_material=cfg.get("default_material", "UNK"),
            material_map=cfg.get("material_map"),
        )

    raise ValueError(f"Unknown adapter type: {typ}")

parse_phits_usrdef_short

parse_phits_usrdef_short(path)

Parse PHITS 'usrdef.out' short format into variable-multiplicity events. The [T-Userdefined] source code for this tally and documentation can be found at: https://github.com/Lindt8/T-Userdefined/tree/main/multi-coincidence_ng

Input row format (tokens; delimiters ';' and ',' are cosmetic): event_type #iomp #batch #history #no #name ; reg Edep(MeV) x(cm) y(cm) z(cm) t(ns) , reg Edep x y z t , ...

Where: - event_type: 'ne' (neutron) or 'ge' (gamma) - #iomp, #batch, #history, #no, #name: integers (PHITS bookkeeping) - For each hit: reg (int), Edep_MeV (float), x_cm (float), y_cm (float), z_cm (float), t_ns (float) - 2 hits min for 'ne', 3 hits min for 'ge', but higher multiplicities may appear.

Returns a list of dicts, each with: { "event_type": "n" | "g", "iomp": int, "batch": int, "history": int, "no": int, "name": int, "hits": [ {"reg": int, "Edep_MeV": float, "x_cm": float, "y_cm": float, "z_cm": float, "t_ns": float}, ... ], "source": "PHITS", "format": "usrdef.short", }

NOTE: This function performs no physics decisions (pair/triple selection, species mixing, etc.). It preserves all hits in the order they appear. Shaping happens downstream.

Source code in ngimager/io/adapters.py
def parse_phits_usrdef_short(path: str | Path) -> List[Dict[str, Any]]:
    """
    Parse PHITS 'usrdef.out' short format into variable-multiplicity events.
    The [T-Userdefined] source code for this tally and documentation can be found at:
    https://github.com/Lindt8/T-Userdefined/tree/main/multi-coincidence_ng

    Input row format (tokens; delimiters ';' and ',' are cosmetic):
        event_type  #iomp  #batch  #history  #no  #name  ;  reg  Edep(MeV)  x(cm)  y(cm)  z(cm)  t(ns)  ,  reg  Edep  x  y  z  t  ,  ...

    Where:
      - event_type: 'ne' (neutron) or 'ge' (gamma)
      - #iomp, #batch, #history, #no, #name: integers (PHITS bookkeeping)
      - For each hit: reg (int), Edep_MeV (float), x_cm (float), y_cm (float), z_cm (float), t_ns (float)
      - 2 hits min for 'ne', 3 hits min for 'ge', but higher multiplicities may appear.

    Returns a list of dicts, each with:
      {
        "event_type": "n" | "g",
        "iomp": int, "batch": int, "history": int, "no": int, "name": int,
        "hits": [
           {"reg": int, "Edep_MeV": float, "x_cm": float, "y_cm": float, "z_cm": float, "t_ns": float},
           ...
        ],
        "source": "PHITS",
        "format": "usrdef.short",
      }

    NOTE: This function performs *no* physics decisions (pair/triple selection, species mixing, etc.).
          It preserves all hits in the order they appear. Shaping happens downstream.
    """
    p = Path(path)
    events: List[Dict[str, Any]] = []

    # Fast replacements: remove cosmetic delimiters; keep whitespace tokenization stable.
    delim_re = re.compile(r"[;,]")

    with open(p, "r", encoding="utf-8", errors="ignore") as f:
        for raw in f:
            line = raw.strip()
            if not line or line.startswith(("!", "#")):
                continue

            # Normalize delimiters to spaces and split.
            line = delim_re.sub(" ", line)
            parts = line.split()
            if not parts:
                continue

            # Header: event_type + five ints
            # Defensive checks: ensure we have at least 6 tokens before hits begin.
            if len(parts) < 6:
                continue

            ev_type_tok = parts[0].lower()
            if ev_type_tok not in ("ne", "ge"):
                # If PHITS writes other tags in the future, skip for now (could log)
                continue

            try:
                iomp   = int(parts[1])
                batch  = int(parts[2])
                hist   = int(parts[3])
                no     = int(parts[4])
                name   = int(parts[5])
            except ValueError:
                # Malformed header row; skip
                continue

            # Remaining tokens are in groups of 6 per hit
            toks = parts[6:]
            if len(toks) < 6:
                # No hits present; skip this row
                continue

            if len(toks) % 6 != 0:
                # Truncated or malformed line; drop trailing incomplete group
                toks = toks[: (len(toks)//6) * 6]

            hits: List[Dict[str, Any]] = []
            for i in range(0, len(toks), 6):
                try:
                    reg  = int(toks[i + 0])
                    edep = float(toks[i + 1])   # MeV
                    x    = float(toks[i + 2])   # cm
                    y    = float(toks[i + 3])   # cm
                    z    = float(toks[i + 4])   # cm
                    t    = float(toks[i + 5])   # ns
                except ValueError:
                    # Skip this hit if any conversion fails
                    continue
                hits.append({
                    "reg": reg,
                    "Edep_MeV": edep,
                    "x_cm": x, "y_cm": y, "z_cm": z,
                    "t_ns": t,
                })

            if not hits:
                continue

            events.append({
                "event_type": "n" if ev_type_tok == "ne" else "g",
                "iomp": iomp, "batch": batch, "history": hist, "no": no, "name": name,
                "hits": hits,
                "source": "PHITS",
                "format": "usrdef.short",
            })

    return events

ngimager.io.lm_store

List-mode HDF5 storage layout and helpers for reading/writing cone datasets.

write_cones

write_cones(f, cone_ids, apex_xyz_cm, axis_xyz, theta_rad, species, recoil_code, incident_energy_MeV, event_index, gamma_hit_order=None)

Store per-cone geometric and classification parameters under /cones.

Layout: /cones/cone_id : [N] uint32 /cones/apex_xyz_cm : [N,3] float32 /cones/axis_xyz : [N,3] float32 /cones/theta_rad : [N] float32 /cones/species : [N] uint8 (0=neutron, 1=gamma) /cones/recoil_code : [N] uint8 (0=NA, 1=proton, 2=carbon) /cones/incident_energy_MeV : [N] float32 (En for n, Eg for g) /cones/event_index : [N] int32 (row index into /lm/event_* arrays) /cones/gamma_hit_order : [N,3] int8 (optional; see below)

/cones/species_labels : ["0=neutron", "1=gamma"] /cones/recoil_code_labels : ["0=NA", "1=proton", "2=carbon"]

Notes
  • For gamma cones (species == 1), gamma_hit_order[i] = (i0, i1, i2) gives the indices into /lm/hit_*[event_index[i], :, :] that correspond to (first scatter, second scatter, third point) used to build that cone.
  • For neutron cones (species == 0), gamma_hit_order[i] is (-1, -1, -1) and should be ignored.
Source code in ngimager/io/lm_store.py
def write_cones(
    f: h5py.File,
    cone_ids: np.ndarray,
    apex_xyz_cm: np.ndarray,
    axis_xyz: np.ndarray,
    theta_rad: np.ndarray,
    species: np.ndarray,
    recoil_code: np.ndarray,
    incident_energy_MeV: np.ndarray,
    event_index: np.ndarray,
    gamma_hit_order: np.ndarray | None = None,
) -> None:
    """
    Store per-cone geometric and classification parameters under /cones.

    Layout:
      /cones/cone_id             : [N]   uint32 
      /cones/apex_xyz_cm         : [N,3] float32
      /cones/axis_xyz            : [N,3] float32
      /cones/theta_rad           : [N]   float32
      /cones/species             : [N]   uint8  (0=neutron, 1=gamma)
      /cones/recoil_code         : [N]   uint8  (0=NA, 1=proton, 2=carbon)
      /cones/incident_energy_MeV : [N]   float32 (En for n, Eg for g)
      /cones/event_index         : [N]   int32 (row index into /lm/event_* arrays)
      /cones/gamma_hit_order     : [N,3] int8  (optional; see below)

      /cones/species_labels      : ["0=neutron", "1=gamma"]
      /cones/recoil_code_labels  : ["0=NA", "1=proton", "2=carbon"]

    Notes
    -----
    * For gamma cones (species == 1), gamma_hit_order[i] = (i0, i1, i2) gives
      the indices into /lm/hit_*[event_index[i], :, :] that correspond to
      (first scatter, second scatter, third point) used to build that cone.
    * For neutron cones (species == 0), gamma_hit_order[i] is (-1, -1, -1)
      and should be ignored.
    """
    grp = f.require_group("cones")
    for name in (
            "cone_id",
            "apex_xyz_cm",
            "axis_xyz",
            "theta_rad",
            "species",
            "recoil_code",
            "incident_energy_MeV",
            "event_index",
            "gamma_hit_order",
            "species_labels",
            "recoil_code_labels",
    ):
        if name in grp:
            del grp[name]

    cone_ids = cone_ids.astype(np.uint32)
    apex_xyz_cm = apex_xyz_cm.astype(np.float32)
    axis_xyz = axis_xyz.astype(np.float32)
    theta_rad = theta_rad.astype(np.float32)

    grp.create_dataset(
        "cone_id",
        data=cone_ids,
        compression="gzip",
    )
    grp.create_dataset(
        "apex_xyz_cm",
        data=apex_xyz_cm,
        compression="gzip",
    )
    grp.create_dataset(
        "axis_xyz",
        data=axis_xyz,
        compression="gzip",
    )
    grp.create_dataset(
        "theta_rad",
        data=theta_rad,
        compression="gzip",
    )

    # Species: 0 = neutron, 1 = gamma.
    if species is None:
        species_arr = np.zeros_like(cone_ids, dtype=np.uint8)
    else:
        species_arr = np.asarray(species, dtype=np.uint8)
    d_species = grp.create_dataset(
        "species",
        data=species_arr,
        compression="gzip",
    )
    d_species.attrs["legend"] = np.array(
        ["0=neutron", "1=gamma"],
        dtype=h5py.string_dtype(),
    )

    # Recoil code: 0 = unknown / N/A, 1 = proton, 2 = carbon.
    if recoil_code is None:
        recoil_arr = np.zeros_like(cone_ids, dtype=np.uint8)
    else:
        recoil_arr = np.asarray(recoil_code, dtype=np.uint8)
    d_recoil = grp.create_dataset(
        "recoil_code",
        data=recoil_arr,
        compression="gzip",
    )
    d_recoil.attrs["legend"] = np.array(
        ["0=unknown_or_gamma", "1=proton", "2=carbon"],
        dtype=h5py.string_dtype(),
    )

    # Visible legends as datasets (similar to /lm/materials/labels)
    species_labels = np.array(
        ["0=neutron", "1=gamma"],
        dtype=h5py.string_dtype(),
    )
    recoil_labels = np.array(
        ["0=NA/gamma/unknown", "1=proton", "2=carbon"],
        dtype=h5py.string_dtype(),
    )

    grp.create_dataset("species_labels", data=species_labels)
    grp.create_dataset("recoil_code_labels", data=recoil_labels)

    grp.create_dataset(
        "incident_energy_MeV",
        data=incident_energy_MeV.astype(np.float32),
        compression="gzip",
    )

    grp.create_dataset(
        "event_index",
        data=event_index.astype(np.int32),
        compression="gzip",
    )

    if gamma_hit_order is not None:
        gamma_hit_order = np.asarray(gamma_hit_order, dtype=np.int8)
        if gamma_hit_order.ndim != 2 or gamma_hit_order.shape[1] != 3:
            raise ValueError(
                "gamma_hit_order must have shape (N_cones, 3). "
                f"Got {gamma_hit_order.shape!r}."
            )
        if gamma_hit_order.shape[0] != cone_ids.shape[0]:
            raise ValueError(
                "gamma_hit_order length must match number of cones: "
                f"{gamma_hit_order.shape[0]} vs {cone_ids.shape[0]}"
            )
        grp.create_dataset(
            "gamma_hit_order",
            data=gamma_hit_order,
            compression="gzip",
        )

write_counters

write_counters(f, counters)

Store scalar counters under /meta/counters as attributes.

Each key in counters becomes an attribute on the /meta/counters group, prefixed with a stage number:

S1_... → Stage 1 (raw events → hits)
S2_... → Stage 2 (hits → shaped/typed → event filters)
S3_... → Stage 3 (events → cones → cone filters)
S4_... → Stage 4 (cones → images)

This forces a "chronological" ordering when viewed in tools like HDFView (which sort attributes alphabetically).

Source code in ngimager/io/lm_store.py
def write_counters(f: h5py.File, counters: Dict[str, int]) -> None:
    """
    Store scalar counters under /meta/counters as attributes.

    Each key in `counters` becomes an attribute on the /meta/counters group,
    prefixed with a stage number:

        S1_... → Stage 1 (raw events → hits)
        S2_... → Stage 2 (hits → shaped/typed → event filters)
        S3_... → Stage 3 (events → cones → cone filters)
        S4_... → Stage 4 (cones → images)

    This forces a "chronological" ordering when viewed in tools like HDFView
    (which sort attributes alphabetically).
    """
    meta = f.require_group("meta")
    if "counters" in meta:
        del meta["counters"]
    grp = meta.create_group("counters")

    # Sort by (stage, original key) so that attributes appear grouped by stage,
    # then alphabetically within each stage.
    for key in sorted(counters.keys(), key=lambda k: (_counter_stage(k), k)):
        stage = _counter_stage(key)
        if stage > 0:
            out_key = f"S{stage}_{key}"
        else:
            out_key = key
        value = counters[key]
        try:
            grp.attrs[out_key] = int(value)
        except Exception:
            grp.attrs[out_key] = str(value)

write_event_cone_survival

write_event_cone_survival(f, event_cone_id, event_imaged_cone_id)

Store per-event survival information linking events → cones.

Layout (all under /lm):

/lm/event_cone_id : [N_events] int32 For each event row i (as in /lm/event_type, /lm/hit_*): - cone_id of the cone built from this event, or -1 if no cone.

/lm/event_imaged_cone_id : [N_events] int32 For each event row i: - cone_id of the cone that both exists AND hits the imaging plane (has non-empty pixel set), or -1 if none.

Notes
  • event index i is simply the row index into /lm/event_type, /lm/hit_*.
  • event_imaged_cone_id is only meaningfully populated when [run].list = true; for non-list runs it will typically be all -1.
Source code in ngimager/io/lm_store.py
def write_event_cone_survival(
    f: h5py.File,
    event_cone_id: np.ndarray,
    event_imaged_cone_id: np.ndarray,
) -> None:
    """
    Store per-event survival information linking events → cones.

    Layout (all under /lm):

      /lm/event_cone_id         : [N_events] int32
          For each event row i (as in /lm/event_type, /lm/hit_*):
              - cone_id of the cone built from this event, or -1 if no cone.

      /lm/event_imaged_cone_id  : [N_events] int32
          For each event row i:
              - cone_id of the cone that both exists AND hits the imaging plane
                (has non-empty pixel set), or -1 if none.

    Notes
    -----
    * event index i is simply the row index into /lm/event_type, /lm/hit_*.
    * event_imaged_cone_id is only meaningfully populated when [run].list = true;
      for non-list runs it will typically be all -1.
    """
    lm_grp = f.require_group("lm")

    if "event_cone_id" in lm_grp:
        del lm_grp["event_cone_id"]
    lm_grp.create_dataset(
        "event_cone_id",
        data=event_cone_id.astype(np.int32),
        compression="gzip",
    )

    if "event_imaged_cone_id" in lm_grp:
        del lm_grp["event_imaged_cone_id"]
    lm_grp.create_dataset(
        "event_imaged_cone_id",
        data=event_imaged_cone_id.astype(np.int32),
        compression="gzip",
    )

write_events_hits

write_events_hits(f, events)

Store per-event and per-hit data for list-mode analysis.

Layout (all under /lm):

/lm/materials/labels : [M] array of material strings /lm/event_type : [N] uint8, 0=n, 1=g /lm/event_meta_run_id : [N] int32 (optional meta) /lm/event_meta_file_ix : [N] int32 (optional meta) /lm/hit_pos_cm : [N,3,3] float32 (event, hit_index, xyz) /lm/hit_t_ns : [N,3] float64 /lm/hit_L_mevee : [N,3] float32 /lm/hit_det_id : [N,3] int32 /lm/hit_material_id : [N,3] int16

Convention: - Neutron events use hits [0,1] and leave slot 2 as NaN/-1. - Gamma events use hits [0,1,2].

Source code in ngimager/io/lm_store.py
def write_events_hits(
    f: h5py.File,
    events: list[NeutronEvent | GammaEvent],
) -> None:
    """
    Store per-event and per-hit data for list-mode analysis.

    Layout (all under /lm):

      /lm/materials/labels    : [M]  array of material strings
      /lm/event_type          : [N]  uint8, 0=n, 1=g
      /lm/event_meta_run_id   : [N]  int32 (optional meta)
      /lm/event_meta_file_ix  : [N]  int32 (optional meta)
      /lm/hit_pos_cm          : [N,3,3] float32 (event, hit_index, xyz)
      /lm/hit_t_ns            : [N,3]   float64
      /lm/hit_L_mevee         : [N,3]   float32
      /lm/hit_det_id          : [N,3]   int32
      /lm/hit_material_id     : [N,3]   int16

    Convention:
      - Neutron events use hits [0,1] and leave slot 2 as NaN/-1.
      - Gamma events use hits [0,1,2].
    """
    if not events:
        return

    N = len(events)

    # Helper: always return a list[Hit] in time order for any supported event
    def _ordered_hits(ev: NeutronEvent | GammaEvent):
        ev_ord = ev.ordered()
        if isinstance(ev_ord, NeutronEvent):
            return [ev_ord.h1, ev_ord.h2]
        elif isinstance(ev_ord, GammaEvent):
            return [ev_ord.h1, ev_ord.h2, ev_ord.h3]
        else:
            raise TypeError(f"Unsupported event type in write_events_hits: {type(ev_ord)!r}")

    # Gather materials to build a small vocabulary
    material_labels: set[str] = set()
    for ev in events:
        for h in _ordered_hits(ev):
            # Hit.material is a required field in our current design; we still
            # defensively allow None just in case.
            mat = getattr(h, "material", None)
            if mat is not None:
                material_labels.add(mat)

    material_list = sorted(material_labels)
    material_to_id = {m: i for i, m in enumerate(material_list)}

    def mat_id(mat: str | None) -> int:
        if mat is None:
            return -1
        return material_to_id.get(mat, -1)

    # Allocate arrays
    hit_pos = np.full((N, 3, 3), np.nan, dtype=np.float32)
    hit_t = np.full((N, 3), np.nan, dtype=np.float64)
    hit_L = np.full((N, 3), np.nan, dtype=np.float32)
    hit_det = np.full((N, 3), -1, dtype=np.int32)
    hit_mat = np.full((N, 3), -1, dtype=np.int16)
    ev_type = np.zeros(N, dtype=np.uint8)  # 0=n,1=g

    # very light meta placeholders
    ev_run = np.full(N, -1, dtype=np.int32)
    ev_file_ix = np.full(N, -1, dtype=np.int32)

    for i, ev in enumerate(events):
        hits = _ordered_hits(ev)
        is_gamma = isinstance(ev, GammaEvent)
        ev_type[i] = 1 if is_gamma else 0

        # very generic meta → two common keys, everything else stays in ev.meta
        if getattr(ev, "meta", None):
            if "run" in ev.meta:
                try:
                    ev_run[i] = int(ev.meta["run"])
                except Exception:
                    pass
            if "file_index" in ev.meta:
                try:
                    ev_file_ix[i] = int(ev.meta["file_index"])
                except Exception:
                    pass

        for j, h in enumerate(hits[:3]):
            r = np.asarray(h.r, dtype=float).reshape(3)
            hit_pos[i, j, :] = r
            hit_t[i, j] = np.float64(h.t_ns)
            hit_L[i, j] = float(h.L)
            hit_det[i, j] = int(h.det_id) if h.det_id is not None else -1
            hit_mat[i, j] = mat_id(getattr(h, "material", None))

    lm_grp = f.require_group("lm")

    # Store material vocabulary as a flat label list:
    # index into this with hit_material_id
    mat_labels = np.array(material_list, dtype=h5py.string_dtype())
    if "hit_material_id_labels" in lm_grp:
        del lm_grp["hit_material_id_labels"]
    lm_grp.create_dataset("hit_material_id_labels", data=mat_labels)

    def _replace_or_create(name: str, data: np.ndarray):
        if name in lm_grp:
            del lm_grp[name]
        lm_grp.create_dataset(name, data=data, compression="gzip")

    _replace_or_create("event_type", ev_type)
    # Add a legend for event_type: 0 = neutron, 1 = gamma.
    d_event_type = lm_grp["event_type"]
    d_event_type.attrs["legend"] = np.array(
        ["0=neutron", "1=gamma"],
        dtype=h5py.string_dtype(),
    )
    _replace_or_create("event_meta_run_id", ev_run)
    _replace_or_create("event_meta_file_ix", ev_file_ix)
    _replace_or_create("hit_pos_cm", hit_pos)
    _replace_or_create("hit_t_ns", hit_t)
    _replace_or_create("hit_L_mevee", hit_L)
    _replace_or_create("hit_det_id", hit_det)
    _replace_or_create("hit_material_id", hit_mat)

    # Legend for event_type (0=neutron, 1=gamma) as a visible dataset
    event_type_labels = np.array(
        ["0=neutron", "1=gamma"],
        dtype=h5py.string_dtype(),
    )
    if "event_type_labels" in lm_grp:
        del lm_grp["event_type_labels"]
    lm_grp.create_dataset("event_type_labels", data=event_type_labels)

write_lm_indices

write_lm_indices(f, lm_cone_pixel_lists)

Store list-mode indices mapping cones -> (u,v) pixels.

We store: /lm/cone_pixel_indices : ragged array of (cone_id, flat_index) pairs

where: - cone_id is the index into /cones/cone_id - flat_index is the flattened pixel index (row-major) on the imaging plane.

Source code in ngimager/io/lm_store.py
def write_lm_indices(
    f: h5py.File,
    lm_cone_pixel_lists: list[tuple[int, np.ndarray]],
) -> None:
    """
    Store list-mode indices mapping cones -> (u,v) pixels.

    We store:
      /lm/cone_pixel_indices : ragged array of (cone_id, flat_index) pairs

    where:
      - cone_id is the index into /cones/cone_id
      - flat_index is the flattened pixel index (row-major) on the imaging plane.
    """
    grp = f.require_group("lm")

    # Flatten all LM lists with cone_id
    all_rows: list[np.ndarray] = []

    for cone_id, arr in lm_cone_pixel_lists:
        if arr is None:
            continue
        flat = np.asarray(arr, dtype=np.uint32).ravel()
        if flat.size == 0:
            continue
        cone_ids = np.full_like(flat, int(cone_id), dtype=np.uint32)
        stacked = np.vstack([cone_ids, flat]).T  # (M,2)
        all_rows.append(stacked)

    if all_rows:
        all_rows_arr = np.concatenate(all_rows, axis=0)
    else:
        all_rows_arr = np.zeros((0, 2), dtype=np.uint32)

    # Single, clearly-named dataset for cone→pixel mapping
    if "cone_pixel_indices" in grp:
        del grp["cone_pixel_indices"]
    grp.create_dataset("cone_pixel_indices", data=all_rows_arr, compression="gzip")

    # Alias for convenience under /images/list_mode; this is a standard HDF5
    # soft link, so it does not duplicate data on disk.
    images_grp = f.require_group("images")
    list_mode_grp = images_grp.require_group("list_mode")
    if "cone_pixel_indices" in list_mode_grp:
        del list_mode_grp["cone_pixel_indices"]
    list_mode_grp["cone_pixel_indices"] = h5py.SoftLink("/lm/cone_pixel_indices")

    # The old /lm/indices and /lm/events datasets are intentionally no longer
    # written to avoid confusion about their semantics.
    if "indices" in grp:
        del grp["indices"]
    if "events" in grp:
        del grp["events"]

write_lm_ragged

write_lm_ragged(h5, phits_events, *, group='/lm')

Write variable-length list-mode (ragged) datasets for events with arbitrary hit multiplicity. This is ADDITIVE and does not modify existing fixed-shape datasets you already write elsewhere.

Source code in ngimager/io/lm_store.py
def write_lm_ragged(h5: h5py.File, phits_events: Sequence[Dict[str, Any]], *, group: str = "/lm") -> None:
    """
    Write variable-length list-mode (ragged) datasets for events with arbitrary hit multiplicity.
    This is ADDITIVE and does not modify existing fixed-shape datasets you already write elsewhere.
    """
    if group.endswith("/"):
        group = group[:-1]
    g_hits = h5.require_group(f"{group}/hits")
    g_ev   = h5.require_group(f"{group}/events")

    event_ptr, cols = _flatten_hits_for_ragged(phits_events)

    # Event pointer (CSR)
    if "event_ptr" in g_hits:
        del g_hits["event_ptr"]
    g_hits.create_dataset("event_ptr", data=event_ptr, dtype="i8")

    # Flat hit columns
    for key in ("x_cm", "y_cm", "z_cm", "t_ns", "Edep_MeV", "reg"):
        if key in g_hits:
            del g_hits[key]
        g_hits.create_dataset(key, data=cols[key])

    # Event-level arrays
    for key in ("event_type", "iomp", "batch", "history", "no", "name"):
        arr = cols[f"events/{key}"]
        if key in g_ev:
            del g_ev[key]
        g_ev.create_dataset(key, data=arr)

write_projections

write_projections(f, species, img, roi_bounds_cm=None, metrics_cfg=None)

Write 1D u/v projections (and optional ROI-limited projections) to HDF5, and optionally compute/write metrics.

Layout under /images/summed/projections/{species}:

u      : [nu] float32, sum over v (rows)
v      : [nv] float32, sum over u (cols)
u_roi  : [nu] float32, ROI-limited u projection (zeros outside ROI)
v_roi  : [nv] float32, ROI-limited v projection (zeros outside ROI)

Metrics layout (per species):

metrics/u      : scalar metrics for the "all" u-projection
metrics/v      : scalar metrics for the "all" v-projection
metrics/u_roi  : scalar metrics for the ROI u-projection (if ROI defined)
metrics/v_roi  : scalar metrics for the ROI v-projection (if ROI defined)

Each metrics group contains 0D datasets such as:

total_counts
mean_cm, median_cm, std_cm
peak_pos_cm, peak_value
edge_low_cm, edge_high_cm, edge_width_cm
summary_ok, peak_ok, edges_ok

The imaging plane grid (u_min/u_max/v_min/v_max/du/dv) is read from /meta.attrs as written by write_init().

Source code in ngimager/io/lm_store.py
def write_projections(
    f: h5py.File,
    species: str,
    img: np.ndarray,
    roi_bounds_cm: Optional[tuple[float, float, float, float]] = None,
    metrics_cfg: Optional[ProjectionMetricsCfg] = None,
) -> None:
    """
    Write 1D u/v projections (and optional ROI-limited projections) to HDF5,
    and optionally compute/write metrics.

    Layout under /images/summed/projections/{species}:

        u      : [nu] float32, sum over v (rows)
        v      : [nv] float32, sum over u (cols)
        u_roi  : [nu] float32, ROI-limited u projection (zeros outside ROI)
        v_roi  : [nv] float32, ROI-limited v projection (zeros outside ROI)

    Metrics layout (per species):

        metrics/u      : scalar metrics for the "all" u-projection
        metrics/v      : scalar metrics for the "all" v-projection
        metrics/u_roi  : scalar metrics for the ROI u-projection (if ROI defined)
        metrics/v_roi  : scalar metrics for the ROI v-projection (if ROI defined)

    Each metrics group contains 0D datasets such as:

        total_counts
        mean_cm, median_cm, std_cm
        peak_pos_cm, peak_value
        edge_low_cm, edge_high_cm, edge_width_cm
        summary_ok, peak_ok, edges_ok

    The imaging plane grid (u_min/u_max/v_min/v_max/du/dv) is read from
    /meta.attrs as written by write_init().
    """
    img = np.asarray(img, dtype=float)
    nv, nu = img.shape

    proj_root = _ensure_projections_group(f)
    grp = proj_root.require_group(species)

    # Global projections
    proj_u = img.sum(axis=0)  # over v
    proj_v = img.sum(axis=1)  # over u

    # Fetch grid info from meta
    meta_attrs = f["meta"].attrs
    u_min_cm = float(meta_attrs["grid.u_min"])
    v_min_cm = float(meta_attrs["grid.v_min"])
    du_cm = float(meta_attrs["grid.du"])
    dv_cm = float(meta_attrs["grid.dv"])

    # Pixel-center coordinates in cm
    u_centers_cm = u_min_cm + (np.arange(nu) + 0.5) * du_cm
    v_centers_cm = v_min_cm + (np.arange(nv) + 0.5) * dv_cm

    # ROI projections (same length as global; zeros outside ROI)
    proj_u_roi: Optional[np.ndarray] = None
    proj_v_roi: Optional[np.ndarray] = None

    if roi_bounds_cm is not None:
        ru_min, ru_max, rv_min, rv_max = roi_bounds_cm

        u_mask = (u_centers_cm >= ru_min) & (u_centers_cm <= ru_max)
        v_mask = (v_centers_cm >= rv_min) & (v_centers_cm <= rv_max)

        if np.any(u_mask) and np.any(v_mask):
            block = img[np.ix_(v_mask, u_mask)]

            proj_u_roi = np.zeros_like(proj_u)
            proj_v_roi = np.zeros_like(proj_v)

            proj_u_roi[u_mask] = block.sum(axis=0)
            proj_v_roi[v_mask] = block.sum(axis=1)

        # Store ROI bounds as attributes on the species group
        grp.attrs["roi_u_min_cm"] = float(ru_min)
        grp.attrs["roi_u_max_cm"] = float(ru_max)
        grp.attrs["roi_v_min_cm"] = float(rv_min)
        grp.attrs["roi_v_max_cm"] = float(rv_max)

    # Write projections themselves
    for name, data in (
        ("u", proj_u),
        ("v", proj_v),
    ):
        if name in grp:
            del grp[name]
        grp.create_dataset(name, data=data.astype(np.float32), compression="gzip")

    if proj_u_roi is not None:
        if "u_roi" in grp:
            del grp["u_roi"]
        grp.create_dataset("u_roi", data=proj_u_roi.astype(np.float32), compression="gzip")

    if proj_v_roi is not None:
        if "v_roi" in grp:
            del grp["v_roi"]
        grp.create_dataset("v_roi", data=proj_v_roi.astype(np.float32), compression="gzip")

    # ------------------------------------------------------------------
    # Optional metrics
    # ------------------------------------------------------------------
    if metrics_cfg is None or not metrics_cfg.enabled:
        # No metrics requested; nothing else to do here.
        # If you want to aggressively clean old metrics, you could:
        #   del grp["metrics"]
        # but for now we leave any existing metrics untouched.
        return

    metrics = compute_projection_metrics(
        u_centers_cm=u_centers_cm,
        v_centers_cm=v_centers_cm,
        proj_u=proj_u,
        proj_v=proj_v,
        proj_u_roi=proj_u_roi,
        proj_v_roi=proj_v_roi,
        cfg=metrics_cfg,
    )

    if not metrics:
        return

    stats_root = grp.require_group("metrics")

    # Axis-level groups: "u", "v" for all-pixel metrics; "u_roi", "v_roi" for ROI metrics.
    for axis_name, axis_metrics in metrics.items():
        axis_cfg: ProjectionAxisMetricsCfg = getattr(metrics_cfg, axis_name)

        # ---- "all" curve → metrics/u or metrics/v ----
        all_metrics = axis_metrics.get("all")
        axis_grp = stats_root.require_group(axis_name)

        # Axis-level attrs (apply to both all+ROI for this axis)
        axis_grp.attrs["edge_low_frac"] = float(axis_cfg.edge_low_frac)
        axis_grp.attrs["edge_high_frac"] = float(axis_cfg.edge_high_frac)
        axis_grp.attrs["min_counts"] = float(axis_cfg.min_counts)

        # Clear any stale datasets in the "all" group
        for ds_name in list(axis_grp.keys()):
            del axis_grp[ds_name]

        if all_metrics:
            for key, value in all_metrics.items():
                if value is None:
                    continue
                if key in axis_grp:
                    del axis_grp[key]
                axis_grp.create_dataset(key, data=value)

        # ---- ROI curve → metrics/u_roi or metrics/v_roi ----
        roi_metrics = axis_metrics.get("roi")
        if roi_metrics:
            roi_group_name = f"{axis_name}_roi"
            roi_grp = stats_root.require_group(roi_group_name)

            # Clear any stale datasets in the ROI group
            for ds_name in list(roi_grp.keys()):
                del roi_grp[ds_name]

            for key, value in roi_metrics.items():
                if value is None:
                    continue
                if key in roi_grp:
                    del roi_grp[key]
                roi_grp.create_dataset(key, data=value)

write_root_novo_meta

write_root_novo_meta(f, meta)

Persist NOVO DDAQ ROOT run-level metadata into HDF5 under /meta/root_novo_ddaq.

The input 'meta' is expected to be a flat mapping from branch names in the ROOT 'meta' TTree to Python scalars/strings, as returned by RootNovoDdaqAdapter.read_meta_tree().

Layout

/meta/root_novo_ddaq : group attrs: InputFileName, OutputFileName, CDFFileName, PSDCutsFileName SampleRate, NumDet, NumThreads, WriteHistograms, MergeMode, CardOffsetChannel, UsePositionVeto

/meta/root_novo_ddaq/detectors
  det_id            : [NumDet] int32
  pos               : [NumDet, 3] float32   (PosX, PosY, PosZ)   [mm]
  dim               : [NumDet, 3] float32   (DimX, DimY, DimZ)   [mm]
  rot_deg           : [NumDet, 3] float32   (RotX, RotY, RotZ)   [deg]
  local_time_offset : [NumDet] float32      [ns]
  global_time_offset: [NumDet] float32      [ns]
  pos_cal_file      : [NumDet] string
  energy_cal_file   : [NumDet] string
  is_start_det      : [NumDet] int8
  is_laser_det      : [NumDet] int8
Source code in ngimager/io/lm_store.py
def write_root_novo_meta(f: h5py.File, meta: Dict[str, Any]) -> None:
    """
    Persist NOVO DDAQ ROOT run-level metadata into HDF5 under /meta/root_novo_ddaq.

    The input 'meta' is expected to be a flat mapping from branch names
    in the ROOT 'meta' TTree to Python scalars/strings, as returned by
    RootNovoDdaqAdapter.read_meta_tree().

    Layout
    ------
      /meta/root_novo_ddaq           : group
        attrs:
          InputFileName, OutputFileName, CDFFileName, PSDCutsFileName
          SampleRate, NumDet, NumThreads, WriteHistograms,
          MergeMode, CardOffsetChannel, UsePositionVeto

        /meta/root_novo_ddaq/detectors
          det_id            : [NumDet] int32
          pos               : [NumDet, 3] float32   (PosX, PosY, PosZ)   [mm]
          dim               : [NumDet, 3] float32   (DimX, DimY, DimZ)   [mm]
          rot_deg           : [NumDet, 3] float32   (RotX, RotY, RotZ)   [deg]
          local_time_offset : [NumDet] float32      [ns]
          global_time_offset: [NumDet] float32      [ns]
          pos_cal_file      : [NumDet] string
          energy_cal_file   : [NumDet] string
          is_start_det      : [NumDet] int8
          is_laser_det      : [NumDet] int8
    """
    meta_root = f.require_group("meta").require_group("root_novo_ddaq")

    # ---- Run-level scalars as attributes ----
    attr_keys = [
        "InputFileName",
        "OutputFileName",
        "CDFFileName",
        "PSDCutsFileName",
        "SampleRate",
        "NumDet",
        "NumThreads",
        "WriteHistograms",
        "MergeMode",
        "CardOffsetChannel",
        "UsePositionVeto",
    ]
    for key in attr_keys:
        if key not in meta:
            continue
        val = meta[key]
        if isinstance(val, np.generic):
            val = val.item()
        meta_root.attrs[key] = val

    # ---- Attempt to extract run number from metadata / filenames ----
    #
    # Priority:
    #   1. Explicit numeric run key if present (e.g. "RunNumber" or "RunNo").
    #   2. Infer from InputFileName / OutputFileName by looking for the last
    #      block of digits before ".root" (e.g. "..._000041.root" → 41).
    run_number_int: int | None = None
    run_number_str: str | None = None

    # 1) If the meta dict already has an explicit run number, prefer that.
    for key in ("RunNumber", "RunNo", "runNumber", "run_no"):
        if key in meta:
            v = meta[key]
            if isinstance(v, np.generic):
                v = v.item()
            try:
                run_number_int = int(v)
                run_number_str = f"{run_number_int:d}"
            except Exception:
                # Fall back to string representation if not cleanly integer.
                run_number_str = str(v)
            break

    # 2) Otherwise, try to infer from the filename pattern.
    if run_number_int is None:
        fname = meta.get("InputFileName") or meta.get("OutputFileName")
        if isinstance(fname, np.generic):
            fname = fname.item()
        if fname:
            try:
                # Work with just the basename, e.g. "coinc_detector_..._000041.root"
                base = str(Path(fname).name)
                # Look for the last run of digits before ".root"
                m = re.search(r"(\d+)\.root$", base)
                if m:
                    run_number_str = m.group(1)
                    try:
                        run_number_int = int(run_number_str)
                    except Exception:
                        # Keep the string even if int conversion fails
                        pass
            except Exception:
                pass

    # If we found something, store it as attributes.
    if run_number_int is not None:
        meta_root.attrs["RunNumber"] = int(run_number_int)
    if run_number_str is not None:
        meta_root.attrs["RunNumber_str"] = str(run_number_str)



    # ---- Detector table ----
    num_det = int(meta.get("NumDet", 0) or 0)
    if num_det <= 0:
        return

    det_grp = meta_root.require_group("detectors")

    # Helper to (re)create a dataset
    def _create_or_replace(name: str, data: np.ndarray, **kwargs) -> h5py.Dataset:
        if name in det_grp:
            del det_grp[name]
        return det_grp.create_dataset(name, data=data, compression="gzip", **kwargs)

    det_ids = np.arange(num_det, dtype=np.int32)
    _create_or_replace("det_id", det_ids)

    # Numeric tables
    pos = np.zeros((num_det, 3), dtype=np.float32)
    dim = np.zeros((num_det, 3), dtype=np.float32)
    rot = np.zeros((num_det, 3), dtype=np.float32)
    local_off = np.zeros(num_det, dtype=np.float32)
    global_off = np.zeros(num_det, dtype=np.float32)
    is_start = np.zeros(num_det, dtype=np.int8)
    is_laser = np.zeros(num_det, dtype=np.int8)

    pos_cal: List[str] = []
    e_cal: List[str] = []

    for i in range(num_det):
        def _get(name: str, default=0.0):
            key = f"Det{i}_{name}"
            v = meta.get(key, default)
            if isinstance(v, np.generic):
                v = v.item()
            return v

        pos[i, 0] = float(_get("PosX", 0.0))
        pos[i, 1] = float(_get("PosY", 0.0))
        pos[i, 2] = float(_get("PosZ", 0.0))

        dim[i, 0] = float(_get("DimX", 0.0))
        dim[i, 1] = float(_get("DimY", 0.0))
        dim[i, 2] = float(_get("DimZ", 0.0))

        rot[i, 0] = float(_get("RotX", 0.0))
        rot[i, 1] = float(_get("RotY", 0.0))
        rot[i, 2] = float(_get("RotZ", 0.0))

        local_off[i] = float(_get("LocalTimeOffset", 0.0))
        global_off[i] = float(_get("GlobalTimeOffset", 0.0))

        # Filenames
        pc = meta.get(f"Det{i}_PosCalFileName", "")
        ec = meta.get(f"Det{i}_EnergyCalFileName", "")
        pos_cal.append(str(pc))
        e_cal.append(str(ec))

        # Flags (stored as ints)
        is_start[i] = int(_get("IsStartDet", 0))
        is_laser[i] = int(_get("IsLaserDet", 0))

    ds_pos = _create_or_replace("pos", pos)
    ds_pos.attrs["units"] = "mm"

    ds_dim = _create_or_replace("dim", dim)
    ds_dim.attrs["units"] = "mm"

    ds_rot = _create_or_replace("rot_deg", rot)
    ds_rot.attrs["units"] = "deg"

    ds_local = _create_or_replace("local_time_offset", local_off)
    ds_local.attrs["units"] = "ns"

    ds_global = _create_or_replace("global_time_offset", global_off)
    ds_global.attrs["units"] = "ns"

    _create_or_replace(
        "is_start_det",
        is_start,
    )
    _create_or_replace(
        "is_laser_det",
        is_laser,
    )

    # String datasets
    str_dt = h5py.string_dtype(encoding="utf-8")
    _create_or_replace("pos_cal_file", np.asarray(pos_cal, dtype=str_dt))
    _create_or_replace("energy_cal_file", np.asarray(e_cal, dtype=str_dt))

write_summed

write_summed(f, species, img)

Write summed image for a given species.

Parameters:

Name Type Description Default
f open h5py.File
required
species "n" | "g" | "all" (string key)
required
img 2D numpy array (nv, nu), float or int
required
Source code in ngimager/io/lm_store.py
def write_summed(
    f: h5py.File,
    species: str,
    img: np.ndarray,
) -> None:
    """
    Write summed image for a given species.

    Parameters
    ----------
    f : open h5py.File
    species : "n" | "g" | "all" (string key)
    img : 2D numpy array (nv, nu), float or int
    """
    grp = _ensure_summed_group(f)
    dset_name = species
    if dset_name in grp:
        del grp[dset_name]
    grp.create_dataset(dset_name, data=img.astype(np.float32), compression="gzip")

ngimager.io.lut

Loading and interpolating light-response lookup tables (LUTs) for scintillators.

build_lut_registry

build_lut_registry(lut_paths, base_dir=None)

Build a registry mapping material -> species -> LUT.

Parameters:

Name Type Description Default
lut_paths Dict[str, Dict[str, str]] | None

Configuration-style mapping, e.g.:

{
    "M600": {"proton": "data/lut/M600/lut_M600_proton_Birks.npz"},
    "OGS":  {"carbon": "custom/OGS_carbon.npz"},
}

Paths may be relative; they are resolved against base_dir when given. If a configured path does not exist on disk but a built-in LUT is available for that material/species (M600/OGS proton/carbon), the built-in is used as a fallback.

When a material/species is omitted entirely from lut_paths, this function will still inject built-in defaults for common NOVO scintillators (M600, OGS).

required
base_dir str | Path | None

Base directory for resolving relative paths (typically the directory containing the TOML config). If None, uses the current working directory.

None

Returns:

Type Description
dict

Nested dictionary: {material: {species: LUT, ...}, ...}

Source code in ngimager/io/lut.py
def build_lut_registry(
    lut_paths: Dict[str, Dict[str, str]] | None,
    base_dir: str | Path | None = None,
) -> Dict[str, Dict[str, LUT]]:
    """
    Build a registry mapping material -> species -> LUT.

    Parameters
    ----------
    lut_paths
        Configuration-style mapping, e.g.:

            {
                "M600": {"proton": "data/lut/M600/lut_M600_proton_Birks.npz"},
                "OGS":  {"carbon": "custom/OGS_carbon.npz"},
            }

        Paths may be relative; they are resolved against `base_dir` when given.
        If a configured path does not exist on disk but a built-in LUT is
        available for that material/species (M600/OGS proton/carbon), the
        built-in is used as a fallback.

        When a material/species is *omitted* entirely from `lut_paths`, this
        function will still inject built-in defaults for common NOVO
        scintillators (M600, OGS).

    base_dir
        Base directory for resolving relative paths (typically the directory
        containing the TOML config). If None, uses the current working
        directory.

    Returns
    -------
    dict
        Nested dictionary: {material: {species: LUT, ...}, ...}
    """
    if lut_paths is None:
        lut_paths = {}

    base = Path(base_dir) if base_dir is not None else Path(".")

    registry: Dict[str, Dict[str, LUT]] = {}

    # ------------------------------------------------------------------
    # 1) Explicit configuration entries
    # ------------------------------------------------------------------
    for material, species_map in lut_paths.items():
        if not species_map:
            continue

        mat_key = str(material)
        mat_reg = registry.setdefault(mat_key, {})

        for species, raw_path in species_map.items():
            sp_key = str(species)

            # Resolve path if provided
            path: Path
            if raw_path:
                p = Path(raw_path)
                if not p.is_absolute():
                    p = base / p
                if p.exists():
                    path = p
                else:
                    # Config path is missing; fall back to built-in if available
                    try:
                        path = builtin_lut_path(mat_key, sp_key)
                    except FileNotFoundError as exc:
                        raise FileNotFoundError(
                            f"LUT path '{raw_path}' for {mat_key}/{sp_key} "
                            f"does not exist and no built-in LUT is available."
                        ) from exc
            else:
                # Empty string / falsy path => force built-in for known materials
                try:
                    path = builtin_lut_path(mat_key, sp_key)
                except FileNotFoundError as exc:
                    raise FileNotFoundError(
                        f"No LUT path specified for {mat_key}/{sp_key} "
                        f"and no built-in LUT is available."
                    ) from exc

            mat_reg[sp_key] = load_npz_lut(path)

    # ------------------------------------------------------------------
    # 2) Built-in defaults for common NOVO scintillators
    #
    #    This is what lets a fresh 'pip install ng-imager' user write:
    #
    #        [energy]
    #        strategy = "ELUT"
    #
    #    and rely on packaged M600/OGS proton+carbon LUTs without any
    #    [energy.lut_paths.*] block.
    # ------------------------------------------------------------------
    builtin_defaults = {
        "M600": ("proton", "carbon"),
        "OGS": ("proton", "carbon"),
    }

    for material, species_list in builtin_defaults.items():
        mat_reg = registry.setdefault(material, {})
        for species in species_list:
            if species in mat_reg:
                # User already configured (or partially overrode) this species.
                continue
            try:
                path = builtin_lut_path(material, species)
            except FileNotFoundError:
                # If we somehow don't ship this LUT, just skip quietly.
                continue
            mat_reg[species] = load_npz_lut(path)

    return registry

builtin_lut_path

builtin_lut_path(material, species)

Return path to a built-in LUT .npz for given material/species.

Source code in ngimager/io/lut.py
def builtin_lut_path(material: str, species: str) -> Path:
    """Return path to a built-in LUT .npz for given material/species."""
    try:
        return res.files(f"ngimager.data.lut.{material}") / f"lut_{material}_{species}_Birks.npz"
    except ModuleNotFoundError:
        raise FileNotFoundError(f"No built-in LUT found for {material}/{species}")

ngimager.config.schemas

Pydantic schemas that define the TOML configuration structure.

ConeSpeciesOverrides

Bases: BaseModel

Species-specific overrides for cone-level filters.

ConesFiltersCfg

Bases: BaseModel

Cone-level filters with universal defaults plus neutron/gamma overrides.

TOML:

[filters.cones] max_delta_theta_deg = 5.0

[filters.cones.neutron] max_delta_theta_deg = 3.0

[filters.cones.gamma] max_delta_theta_deg = 8.0

Config

Bases: BaseModel

Top-level TOML configuration.

DetectorFrameGeometry

Bases: BaseModel

A simple rigid transform that maps detector-local coordinates to world coordinates:

p_world = R_xyz(rotation_deg) @ p_local + origin_cm

where rotation_deg = [rx, ry, rz] are Euler angles in degrees, applied in the fixed order Rx → Ry → Rz.

DetectorsCfg

Bases: BaseModel

Mapping from detector IDs/regions to materials and optional geometry.

TOML:

[detectors] default_material = "OGS"

[detectors.material_map] 200 = "OGS" 210 = "M600" ...

Optional global detector-frame → world-frame transform:

[detectors.geometry.frame] origin_cm = [0.0, 0.0, 0.0] rotation_deg = [0.0, 0.0, 0.0]

OPTIONAL (stub for future expansion): per-detector transforms

[[detectors.geometry.detectors]] id = 0 origin_cm = [0.0, 0.0, 0.0] rotation_deg = [0.0, 0.0, 0.0]

DetectorsGeometryCfg

Bases: BaseModel

Global detector-array geometry.

  • 'frame' describes the overall detector coordinate frame relative to world coordinates.
  • 'detectors' is an optional list for fine-grained per-detector transforms (currently unused, but reserved for future support).

EnergyCfg

Bases: BaseModel

Energy strategy configuration.

strategy: "ELUT" – invert light via E(L) LUTs (per-material, per-species) "ToF" – simple ToF-based estimate (placeholder) "FixedEn" – fixed incident neutron energy (e.g. 14.1 MeV source) "Edep" – direct deposited energy (PHITS-style adapters)

EventSpeciesOverrides

Bases: BaseModel

Species-specific overrides for event-level filters.

All fields are optional; when None, the universal [filters.events] value is used for ToF, and L-thresholds default to "no extra cut".

EventsFiltersCfg

Bases: BaseModel

Event-level filters with universal defaults plus neutron/gamma overrides.

TOML:

[filters.events] tof_window_ns = [0.0, 30.0]

[filters.events.neutron] tof_window_ns = [0.0, 30.0] min_L1_MeVee = 0.0 min_L2_MeVee = 0.0

[filters.events.gamma] tof_window_ns = [0.0, 30.0] min_L_any_MeVee = 0.0

FastCfg

Bases: BaseModel

Fast-mode override knobs.

Applied only when [run].fast = true; otherwise ignored.

FiltersCfg

Bases: BaseModel

Top-level filter configuration, split into hits / events / cones.

HitSpeciesOverrides

Bases: BaseModel

Species-specific overrides for hit-level filters.

All fields are optional; when None, the universal [filters.hits] value is used.

HitsFiltersCfg

Bases: BaseModel

Hit-level filters with universal defaults plus neutron/gamma overrides.

TOML:

[filters.hits] min_light_MeVee = 50.0 max_light_MeVee = 1.0e6 psd_min = 0.0 psd_max = 1.0 bars_include = [] bars_exclude = [] materials_include = [] materials_exclude = []

[filters.hits.neutron] min_light_MeVee = 100.0 # optional override; others fall back to [filters.hits] psd_min = 0.2 # optional override; if omitted, uses [filters.hits] psd_max = 0.6

[filters.hits.gamma] # optional overrides...

IOCfg

Bases: BaseModel

I/O paths and high-level source description.

TOML:

[io] input_path = "..." input_format = "phits_usrdef" # "phits_usrdef" | "root_novo_ddaq" | "hdf5_ngimager" output_path = "..."

PerDetectorGeometry

Bases: BaseModel

OPTIONAL (stub for future expansion).

Describes an individual detector tile or module's placement within the detector-frame coordinate system. For now, ng-imager does not apply per-detector transforms, but the schema entry is accepted and stored for future expansion.

PipelineCfg

Bases: BaseModel

Controls how far through the pipeline we run.

until = "hits" | "events" | "cones" | "image"

ProjectionAxisMetricsCfg

Bases: BaseModel

Per-axis projection metrics controls.

These are applied separately for the u and v axes.

ProjectionMetricsCfg

Bases: BaseModel

Controls whether projection metrics are computed and written to HDF5.

ProjectionPlotCfg

Bases: BaseModel

Controls how projection metrics are visualized in vis/hdf.py.

(Plotting code will read these; they do not affect HDF5 contents.)

TOML example:

[vis.projections.plot]
# Basic visual toggles
show_peak_markers = true   # vertical / horizontal lines at peak_pos_cm
show_edge_markers = true   # lines at edge_low_cm / edge_high_cm
show_centroid_2d  = false  # crosshair at 2D centroid (if computed)

# Which metrics to use when both "all" and ROI curves exist
metrics_source    = "auto"     # "auto" | "all" | "roi" | "both"
curve_mode        = "all+roi"  # "all+roi" | "all_only" | "roi_only"

# Numeric summaries on the figure
# - "off"     : no text annotations
# - "compact" : minimal one-line summary per axis
# - "full"    : include more fields (e.g. edges) when available
annotate_summary  = "compact"

# Optional extra panel with a table of metrics (future)
show_metrics_panel = false

RunCfg

Bases: BaseModel

Global run controls that apply to the entire pipeline.

source_type: "cf252" | "dt" | "proton_center" | "phits" fast: Enable fast-mode overrides (see FastCfg and [fast] section). list: Enable list-mode imaging output (/lm/cone_pixel_indices, etc.).

VisCfg

Bases: BaseModel

Visualization configuration.

These options control automatic image export from the pipeline and provide defaults for the standalone ng-viz CLI.

VisProjectionsCfg

Bases: BaseModel

Configuration for 1D projections and their analysis/visualization.

TOML:

[vis.projections]
enabled      = true
roi_u_min_cm = -5.0
roi_u_max_cm =  5.0
roi_v_min_cm = -5.0
roi_v_max_cm =  5.0

[vis.projections.metrics]
enabled = true

[vis.projections.metrics.u]
compute_summary = true
compute_peak    = true
compute_edges   = false
edge_low_frac   = 0.2
edge_high_frac  = 0.8
min_counts      = 100.0

[vis.projections.metrics.v]
compute_summary = true
compute_peak    = true
compute_edges   = true

[vis.projections.plot]
show_peak_markers = true
show_edge_markers = true
show_centroid_2d  = false

metrics_source    = "auto"     # "auto" | "all" | "roi" | "both"
curve_mode        = "all+roi"  # "all+roi" | "all_only" | "roi_only"

annotate_summary  = "compact"  # "off" | "compact" | "full"
show_metrics_panel = false

roi_bounds_cm

roi_bounds_cm()

Return (u_min, u_max, v_min, v_max) in cm if a full ROI is defined, otherwise None.

Source code in ngimager/config/schemas.py
def roi_bounds_cm(self) -> Optional[tuple[float, float, float, float]]:
    """
    Return (u_min, u_max, v_min, v_max) in cm if a full ROI is defined,
    otherwise None.
    """
    if (
        self.roi_u_min_cm is None
        or self.roi_u_max_cm is None
        or self.roi_v_min_cm is None
        or self.roi_v_max_cm is None
    ):
        return None
    return (
        float(self.roi_u_min_cm),
        float(self.roi_u_max_cm),
        float(self.roi_v_min_cm),
        float(self.roi_v_max_cm),
    )

ngimager.config.load

User-facing helpers for loading and validating configuration from TOML files.

load_config

load_config(path)

Load a TOML config file into a Config object.

All paths inside the [io] table are interpreted relative to the location of the TOML file (cfg_dir), except when they are absolute.

In particular: - [io].input_path - [io].output_path - [io].restart_path - [io.extra_text_files].*

CLI overrides (e.g. --input-path/--output-path in ng-run) are applied later and remain relative to the current working directory.

Source code in ngimager/config/load.py
def load_config(path: str | Path) -> Config:
    """
    Load a TOML config file into a Config object.

    All paths inside the [io] table are interpreted relative to the
    *location of the TOML file* (cfg_dir), except when they are absolute.

    In particular:
      - [io].input_path
      - [io].output_path
      - [io].restart_path
      - [io.extra_text_files].*

    CLI overrides (e.g. --input-path/--output-path in ng-run) are applied
    later and remain relative to the current working directory.
    """
    p = Path(path)
    cfg_dir = p.resolve().parent
    data = tomllib.loads(p.read_text())
    data = _resolve_io_paths(data, cfg_dir)
    return Config(**data)

snapshot_config_toml

snapshot_config_toml(path)

Return the raw TOML text for embedding in HDF5 metadata.

Source code in ngimager/config/load.py
def snapshot_config_toml(path: str | Path) -> str:
    """Return the raw TOML text for embedding in HDF5 metadata."""
    return Path(path).read_text()

CLI & Visualization

Command-line entry point and basic visualization utilities.

ngimager.cli.viz

The ng-viz CLI application: entry point for visualizing ng-imager HDF5 outputs.

summed

summed(h5_path=typer.Argument(..., help='Path to ng-imager HDF5 file (must contain /images/summed/*).'), species=typer.Option(['n', 'g', 'all'], '--species', '-s', help="Which images to render from /images/summed: any of 'n', 'g', 'all'."), center_on_plane_center=typer.Option(True, '--center-on-plane-center/--no-center-on-plane-center', help='Center axes on the imaging plane center.'), flip_vertical=typer.Option(True, '--flip-vertical/--no-flip-vertical', help='Flip the plotted image vertically (mainly for legacy comparison).'), axis_units=typer.Option('cm', '--axis-units', help="Axis units for plotting: 'cm' or 'mm'."), cmap=typer.Option('cividis', '--cmap', help="Matplotlib colormap to use (e.g. 'cividis', 'viridis')."), filename_pattern=typer.Option('{species}_{stem}.{ext}', '--filename-pattern', help='Python format string for output filenames; may use {stem}, {species}, {ext}.'), fmt=typer.Option(['png'], '--format', '-f', help="Output format(s) to write (e.g. 'png', 'pdf')."), plot_label=typer.Option(None, '--plot-label', help='Override run plot label annotation (defaults to any value stored under /meta.run_plot_label in the HDF5 file).'))

Render one or more /images/summed/{n,g,all} datasets to image files.

Source code in ngimager/cli/viz.py
@app.command("summed")
def summed(
    h5_path: str = typer.Argument(
        ...,
        help="Path to ng-imager HDF5 file (must contain /images/summed/*).",
    ),
    species: List[str] = typer.Option(
        ["n", "g", "all"],
        "--species",
        "-s",
        help="Which images to render from /images/summed: any of 'n', 'g', 'all'.",
    ),
    center_on_plane_center: bool = typer.Option(
        True,
        "--center-on-plane-center/--no-center-on-plane-center",
        help="Center axes on the imaging plane center.",
    ),
    flip_vertical: bool = typer.Option(
        True,
        "--flip-vertical/--no-flip-vertical",
        help="Flip the plotted image vertically (mainly for legacy comparison).",
    ),
    axis_units: str = typer.Option(
        "cm",
        "--axis-units",
        help="Axis units for plotting: 'cm' or 'mm'.",
    ),
    cmap: str = typer.Option(
        "cividis",
        "--cmap",
        help="Matplotlib colormap to use (e.g. 'cividis', 'viridis').",
    ),
    filename_pattern: str = typer.Option(
        "{species}_{stem}.{ext}",
        "--filename-pattern",
        help="Python format string for output filenames; may use {stem}, {species}, {ext}.",
    ),
    fmt: List[str] = typer.Option(
        ["png"],
        "--format",
        "-f",
        help="Output format(s) to write (e.g. 'png', 'pdf').",
    ),
    plot_label: Optional[str] = typer.Option(
        None,
        "--plot-label",
        help=(
            "Override run plot label annotation (defaults to any value stored "
            "under /meta.run_plot_label in the HDF5 file)."
        ),
    ),
):
    """
    Render one or more /images/summed/{n,g,all} datasets to image files.
    """
    out_paths = render_summed_images(
        h5_path,
        species=species,
        filename_pattern=filename_pattern,
        center_on_plane_center=center_on_plane_center,
        flip_vertical=flip_vertical,
        axis_units=axis_units,
        cmap=cmap,
        formats=fmt,
        plot_label=plot_label,
    )
    if not out_paths:
        typer.echo("No images were written (check that /images/summed/* exist).")
    else:
        for p in out_paths:
            typer.echo(str(p))

ngimager.vis.hdf

Convenience functions for visualizing images stored in HDF5 output files.

render_summed_images

render_summed_images(h5_path, species=('n', 'g', 'all'), filename_pattern='{species}_{stem}.{ext}', center_on_plane_center=True, flip_vertical=True, axis_units='cm', cmap='cividis', formats=('png',), projections=False, roi_u_min_cm=None, roi_u_max_cm=None, roi_v_min_cm=None, roi_v_max_cm=None, plot_label=None, metrics_source='auto', curve_mode='all+roi', annotate_summary='compact', show_metrics_panel=False, show_peak_markers=True, show_edge_markers=True, show_centroid_2d=False)

Render /images/summed/* datasets from an ng-imager HDF5 file to image files.

When projections=True, each figure shows: - the main 2D image (u vs v), - a 1D projection along u above the image, - a 1D projection along v to the left of the image, - an optional ROI rectangle (if roi_*_cm are provided), - an annotation of the number of cones contributing to that species. - and (when available) a run-level plot label drawn from [run].plot_label or from the plot_label argument.

Source code in ngimager/vis/hdf.py
 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
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
def render_summed_images(
    h5_path: str | Path,
    species: Sequence[str] = ("n", "g", "all"),
    filename_pattern: str = "{species}_{stem}.{ext}",
    center_on_plane_center: bool = True,
    flip_vertical: bool = True,
    axis_units: Literal["cm", "mm"] = "cm",
    cmap: str = "cividis",
    formats: Sequence[str] = ("png",),
    projections: bool = False,
    roi_u_min_cm: float | None = None,
    roi_u_max_cm: float | None = None,
    roi_v_min_cm: float | None = None,
    roi_v_max_cm: float | None = None,
    plot_label: str | None = None,
    metrics_source: str = "auto",
    curve_mode: str = "all+roi",
    annotate_summary: str = "compact",
    show_metrics_panel: bool = False,
    show_peak_markers: bool = True,
    show_edge_markers: bool = True,
    show_centroid_2d: bool = False,
) -> list[Path]:
    """
    Render `/images/summed/*` datasets from an ng-imager HDF5 file to image files.

    When `projections=True`, each figure shows:
      - the main 2D image (u vs v),
      - a 1D projection along u **above** the image,
      - a 1D projection along v **to the left** of the image,
      - an optional ROI rectangle (if roi_*_cm are provided),
      - an annotation of the number of cones contributing to that species.
      - and (when available) a run-level plot label drawn from [run].plot_label
        or from the `plot_label` argument.
    """

    h5_path = Path(h5_path)
    stem = h5_path.stem

    # Normalize species and formats
    species_list: list[str] = []
    for s in species:
        s = str(s).lower()
        if s in ("n", "g", "all") and s not in species_list:
            species_list.append(s)

    fmt_list: list[str] = []
    for fmt in formats:
        fmt = str(fmt).lower().lstrip(".")
        if fmt and fmt not in fmt_list:
            fmt_list.append(fmt)
    if not fmt_list:
        fmt_list = ["png"]

    # ROI rectangle in cm
    roi_cm: tuple[float, float, float, float] | None = None
    if (
        roi_u_min_cm is not None
        and roi_u_max_cm is not None
        and roi_v_min_cm is not None
        and roi_v_max_cm is not None
    ):
        roi_cm = (
            float(roi_u_min_cm),
            float(roi_u_max_cm),
            float(roi_v_min_cm),
            float(roi_v_max_cm),
        )

    out_paths: list[Path] = []

    with h5py.File(h5_path, "r") as f:
        if "images" not in f or "summed" not in f["images"]:
            return []

        summed_grp = f["images"]["summed"]
        meta_attrs = f["meta"].attrs

        # Default plot label: prefer explicit argument, then /meta attribute.
        stored_plot_label: str | None = None
        if "run_plot_label" in meta_attrs:
            try:
                stored_plot_label = str(meta_attrs["run_plot_label"])
            except Exception:
                stored_plot_label = None

        has_n = "n" in summed_grp
        has_g = "g" in summed_grp

        for sp in species_list:
            # Skip "all" if we don't have both species present.
            if sp == "all" and not (has_n and has_g):
                continue
            if sp not in summed_grp:
                continue

            img = np.array(summed_grp[sp], dtype=np.float32)
            nv, nu = img.shape

            # Axes / extent / pixel sizes from metadata
            extent, axis_labels, (du_plot, dv_plot), (
                u_min_cm,
                u_max_cm,
                v_min_cm,
                v_max_cm,
            ) = _axes_from_meta(meta_attrs, center_on_plane_center, axis_units)

            # Pixel centers in cm
            du_cm = float(meta_attrs["grid.du"])
            dv_cm = float(meta_attrs["grid.dv"])
            u_centers_cm = u_min_cm + (np.arange(nu) + 0.5) * du_cm
            v_centers_cm = v_min_cm + (np.arange(nv) + 0.5) * dv_cm

            # Global projections
            proj_u = img.sum(axis=0)  # shape (nu,)
            proj_v = img.sum(axis=1)  # shape (nv,)

            proj_u_roi = None
            proj_v_roi = None

            if roi_cm is not None:
                ru0, ru1, rv0, rv1 = roi_cm
                u_mask = (u_centers_cm >= ru0) & (u_centers_cm <= ru1)
                v_mask = (v_centers_cm >= rv0) & (v_centers_cm <= rv1)

                proj_u_roi = np.zeros_like(proj_u)
                proj_v_roi = np.zeros_like(proj_v)

                if np.any(u_mask) and np.any(v_mask):
                    block = img[np.ix_(v_mask, u_mask)]
                    proj_u_roi[u_mask] = block.sum(axis=0)
                    proj_v_roi[v_mask] = block.sum(axis=1)

            # Flags describing ROI / curves / metrics
            has_roi_projections = (proj_u_roi is not None) and (proj_v_roi is not None)
            draw_all_curve = True
            draw_roi_curve = False
            prefer_roi_metrics = False

            if projections:
                draw_all_curve, draw_roi_curve, prefer_roi_metrics = _resolve_projection_plot_prefs(
                    has_roi_projections,
                    metrics_source,
                    curve_mode,
                )

            # ------------------------------------------------------------------
            # Optional: read precomputed projection metrics (positions in cm)
            # ------------------------------------------------------------------
            peak_u_cm = peak_v_cm = None
            edge_low_u_cm = edge_high_u_cm = None
            edge_low_v_cm = edge_high_v_cm = None

            def _format_axis_summary(
                axis_label: str,
                metrics: Mapping[str, Any],
                mode: str,
            ) -> Optional[str]:
                """
                Build a small annotation string for one axis from its metrics.

                axis_label: "u" or "v"
                mode      : "off" | "compact" | "full"
                """
                mode = (mode or "off").lower()
                if mode == "off":
                    return None

                def _get(name: str) -> Optional[float]:
                    val = metrics.get(name)
                    try:
                        val = float(val)
                    except (TypeError, ValueError):
                        return None
                    if not np.isfinite(val):
                        return None
                    return val

                peak = _get("peak_pos_cm")
                width = _get("edge_width_cm")
                mean = _get("mean_cm")
                std = _get("std_cm")
                edge_low = _get("edge_low_cm")
                edge_high = _get("edge_high_cm")

                # Convert from cm to the chosen axis_units (cm or mm)
                scale = 10.0 if axis_units == "mm" else 1.0
                unit_label = axis_units

                def _conv(x: Optional[float]) -> Optional[float]:
                    if x is None:
                        return None
                    return x * scale

                peak = _conv(peak)
                width = _conv(width)
                mean = _conv(mean)
                std = _conv(std)
                edge_low = _conv(edge_low)
                edge_high = _conv(edge_high)

                parts: list[str] = []

                if mode == "compact":
                    # Prefer peak + width when available
                    if peak is not None:
                        parts.append(f"peak={peak:.2f} {unit_label}")
                    if width is not None:
                        parts.append(f"width={width:.2f} {unit_label}")
                    if not parts and mean is not None and std is not None:
                        parts.append(f"mean={mean:.2f}±{std:.2f} {unit_label}")
                else:  # "full"
                    if peak is not None:
                        parts.append(f"peak={peak:.2f} {unit_label}")
                    if mean is not None:
                        parts.append(f"mean={mean:.2f} {unit_label}")
                    if std is not None:
                        parts.append(f"std={std:.2f} {unit_label}")
                    if edge_low is not None and edge_high is not None:
                        parts.append(
                            f"edges=[{edge_low:.2f}, {edge_high:.2f}] {unit_label}"
                        )
                    elif width is not None:
                        parts.append(f"width={width:.2f} {unit_label}")

                if not parts:
                    return None

                lines = [f"{axis_label}:"]
                for p in parts:
                    lines.append(f"  {p}")
                return "\n".join(lines)

            def _render_metrics_panel(
                    ax_panel,
                    metrics: Mapping[str, Mapping[str, Mapping[str, Any]]],
            ) -> None:
                """
                Render a table of metrics into ax_panel.

                metrics[axis][source] -> dict of scalar values
                axis   in {"u", "v"}
                source in {"all", "roi"}
                """
                # Collect rows in a stable order
                rows: list[tuple[str, str, Mapping[str, Any]]] = []
                for axis_name in ("u", "v"):
                    axis_metrics = metrics.get(axis_name, {})
                    for src_name in ("all", "roi"):
                        m = axis_metrics.get(src_name)
                        if m:
                            rows.append((axis_name, src_name, m))

                ax_panel.set_axis_off()

                if not rows:
                    ax_panel.text(
                        0.0,
                        1.0,
                        "No projection metrics available",
                        transform=ax_panel.transAxes,
                        ha="left",
                        va="top",
                        fontsize=7,
                    )
                    return

                # Helper to fetch and convert from cm to chosen axis_units
                scale = 10.0 if axis_units == "mm" else 1.0
                unit_label = axis_units

                def _get(m: Mapping[str, Any], name: str) -> Optional[float]:
                    val = m.get(name)
                    try:
                        val = float(val)
                    except (TypeError, ValueError):
                        return None
                    if not np.isfinite(val):
                        return None
                    return val * scale

                # Build table rows:
                # [axis/source, peak, mean, median, std, low, high, width]
                table_rows: list[list[str]] = []
                for axis_name, src_name, m in rows:
                    peak = _get(m, "peak_pos_cm")
                    mean = _get(m, "mean_cm")
                    median = _get(m, "median_cm")
                    std = _get(m, "std_cm")
                    edge_low = _get(m, "edge_low_cm")
                    edge_high = _get(m, "edge_high_cm")
                    width = _get(m, "edge_width_cm")

                    def _fmt(x: Optional[float]) -> str:
                        return f"{x:.2f}" if x is not None else ""

                    row = [
                        f"{axis_name}/{src_name}",
                        _fmt(peak),
                        _fmt(mean),
                        _fmt(median),
                        _fmt(std),
                        _fmt(edge_low),
                        _fmt(edge_high),
                        _fmt(width),
                    ]
                    table_rows.append(row)

                col_labels = [
                    "axis/src",
                    f"peak [{unit_label}]",
                    f"mean [{unit_label}]",
                    f"median [{unit_label}]",
                    f"std [{unit_label}]",
                    f"low [{unit_label}]",
                    f"high [{unit_label}]",
                    f"width [{unit_label}]",
                ]

                table = ax_panel.table(
                    cellText=table_rows,
                    colLabels=col_labels,
                    loc="upper center",
                    cellLoc="center",
                    bbox=[0.0, -0.15, 1.0, 0.9],
                )
                table.auto_set_font_size(False)
                table.set_fontsize(7)
                table.scale(1.0, 1.2)

            metrics_sel: dict[str, dict[str, dict[str, Any]]] = {}

            if projections:
                # Load all available metrics for this species
                metrics_raw = _load_projection_metrics(summed_grp, sp)
                metrics_sel = _resolve_projection_metrics(metrics_raw, metrics_source)

                def _pick_axis_metrics(
                    axis_name: str,
                ) -> tuple[dict[str, Any] | None, Optional[str]]:
                    """
                    Return (metrics, source) for the given axis.

                    source ∈ {"all", "roi"} or None if no metrics available.
                    """
                    axis_metrics = metrics_sel.get(axis_name, {})
                    if not axis_metrics:
                        return None, None

                    has_all = "all" in axis_metrics and axis_metrics["all"]
                    has_roi = "roi" in axis_metrics and axis_metrics["roi"]

                    msrc = (metrics_source or "auto").lower()
                    if msrc == "all" and has_all:
                        return axis_metrics["all"], "all"
                    if msrc == "roi" and has_roi:
                        return axis_metrics["roi"], "roi"
                    if msrc == "both":
                        # For overlays, prefer ROI when both exist
                        if has_roi:
                            return axis_metrics["roi"], "roi"
                        if has_all:
                            return axis_metrics["all"], "all"
                        return None, None

                    # "auto" or unknown: prefer ROI, else all
                    if has_roi:
                        return axis_metrics["roi"], "roi"
                    if has_all:
                        return axis_metrics["all"], "all"
                    return None, None

                u_metrics, u_metrics_source = _pick_axis_metrics("u")
                v_metrics, v_metrics_source = _pick_axis_metrics("v")


                if u_metrics is not None:
                    peak_u_cm = u_metrics.get("peak_pos_cm")
                    edge_low_u_cm = u_metrics.get("edge_low_cm")
                    edge_high_u_cm = u_metrics.get("edge_high_cm")

                if v_metrics is not None:
                    peak_v_cm = v_metrics.get("peak_pos_cm")
                    edge_low_v_cm = v_metrics.get("edge_low_cm")
                    edge_high_v_cm = v_metrics.get("edge_high_cm")

                # -------------------------
                # Centroid metrics (cm)
                # Prefer ROI centroid if available, else ALL
                # -------------------------
                u_centroid_cm = None
                v_centroid_cm = None

                # ROI has priority
                if u_metrics_source == "roi":
                    u_centroid_cm = u_metrics.get("mean_cm")
                elif u_metrics_source == "all":
                    u_centroid_cm = u_metrics.get("mean_cm")
                else:
                    # "auto" case handled via u_metrics_source
                    if u_metrics is not None:
                        u_centroid_cm = u_metrics.get("mean_cm")

                if v_metrics_source == "roi":
                    v_centroid_cm = v_metrics.get("mean_cm")
                elif v_metrics_source == "all":
                    v_centroid_cm = v_metrics.get("mean_cm")
                else:
                    if v_metrics is not None:
                        v_centroid_cm = v_metrics.get("mean_cm")


                # Edge fractions (0–1) from metrics/u and metrics/v attributes
                edge_fracs: dict[str, tuple[Optional[float], Optional[float]]] = {}

                try:
                    proj_root = summed_grp.get("projections")
                    if isinstance(proj_root, h5py.Group):
                        sp_root = proj_root.get(sp)
                    else:
                        sp_root = None
                    if isinstance(sp_root, h5py.Group):
                        metrics_root = sp_root.get("metrics")
                    else:
                        metrics_root = None

                    if isinstance(metrics_root, h5py.Group):
                        for axis_name in ("u", "v"):
                            axis_grp = metrics_root.get(axis_name)
                            if not isinstance(axis_grp, h5py.Group):
                                continue
                            low = axis_grp.attrs.get("edge_low_frac", None)
                            high = axis_grp.attrs.get("edge_high_frac", None)
                            try:
                                low_f = float(low) if low is not None else None
                            except (TypeError, ValueError):
                                low_f = None
                            try:
                                high_f = float(high) if high is not None else None
                            except (TypeError, ValueError):
                                high_f = None
                            if low_f is not None or high_f is not None:
                                edge_fracs[axis_name] = (low_f, high_f)
                except Exception:
                    edge_fracs = {}



            # Convert centers to plot units (cm or mm) and apply centering
            u_mid_cm = 0.5 * (u_min_cm + u_max_cm)
            v_mid_cm = 0.5 * (v_min_cm + v_max_cm)
            unit_scale = 10.0 if axis_units == "mm" else 1.0

            if center_on_plane_center:
                u_centers_plot = (u_centers_cm - u_mid_cm) * unit_scale
                v_centers_plot = (v_centers_cm - v_mid_cm) * unit_scale
            else:
                u_centers_plot = u_centers_cm * unit_scale
                v_centers_plot = v_centers_cm * unit_scale

            # Convert metric positions (if any) into plot units
            peak_u_plot = edge_low_u_plot = edge_high_u_plot = None
            peak_v_plot = edge_low_v_plot = edge_high_v_plot = None
            u_centroid_plot = None
            v_centroid_plot = None

            def _cm_to_plot_u(x_cm: Optional[float]) -> Optional[float]:
                if x_cm is None:
                    return None
                if center_on_plane_center:
                    return (x_cm - u_mid_cm) * unit_scale
                return x_cm * unit_scale

            def _cm_to_plot_v(y_cm: Optional[float]) -> Optional[float]:
                if y_cm is None:
                    return None
                if center_on_plane_center:
                    return (y_cm - v_mid_cm) * unit_scale
                return y_cm * unit_scale

            if peak_u_cm is not None:
                peak_u_plot = _cm_to_plot_u(peak_u_cm)
            if edge_low_u_cm is not None:
                edge_low_u_plot = _cm_to_plot_u(edge_low_u_cm)
            if edge_high_u_cm is not None:
                edge_high_u_plot = _cm_to_plot_u(edge_high_u_cm)

            if peak_v_cm is not None:
                peak_v_plot = _cm_to_plot_v(peak_v_cm)
            if edge_low_v_cm is not None:
                edge_low_v_plot = _cm_to_plot_v(edge_low_v_cm)
            if edge_high_v_cm is not None:
                edge_high_v_plot = _cm_to_plot_v(edge_high_v_cm)

            # Convert centroids
            if u_centroid_cm is not None:
                u_centroid_plot = _cm_to_plot_u(u_centroid_cm)
            if v_centroid_cm is not None:
                v_centroid_plot = _cm_to_plot_v(v_centroid_cm)


            # ----------------- Figure layout -----------------
            if projections and (nu > 1 or nv > 1):
                # Decide whether a metrics panel makes sense for this species
                has_any_metrics = bool(
                    metrics_sel
                    and any(metrics_sel.get(ax) for ax in ("u", "v"))
                )
                want_metrics_panel = show_metrics_panel and has_any_metrics

                if want_metrics_panel:
                    # Layout with metrics panel as a wide bottom row:
                    #   row 0: [ legend | top u-proj | empty ]
                    #   row 1: [ left v-proj | image | colorbar ]
                    #   row 2: [       metrics table (spans all 3 columns)      ]
                    fig = plt.figure(figsize=(8.0, 8.5))
                    gs = GridSpec(
                        3,
                        3,
                        width_ratios=[1.3, 4.0, 0.4],
                        height_ratios=[1.3, 4.0, 0.9],  # shrink bottom row
                        wspace=0.08,
                        hspace=0.12,  # slightly reduced vertical spacing
                        figure=fig,
                    )

                    ax_legend = fig.add_subplot(gs[0, 0])
                    ax_top = fig.add_subplot(gs[0, 1])
                    ax_img = fig.add_subplot(gs[1, 1], sharex=ax_top)
                    ax_left = fig.add_subplot(gs[1, 0], sharey=ax_img)
                    ax_cb = fig.add_subplot(gs[1, 2])
                    ax_metrics = fig.add_subplot(gs[2, :])
                    ax_metrics.set_axis_off()
                else:
                    # Layout (no metrics panel):
                    #   row 0: [ legend | top u-proj | empty ]
                    #   row 1: [ left v-proj | image | colorbar ]
                    fig = plt.figure(figsize=(8.0, 8.0))
                    gs = GridSpec(
                        2,
                        3,
                        width_ratios=[1.3, 4.0, 0.4],
                        height_ratios=[1.3, 4.0],
                        wspace=0.08,
                        hspace=0.08,
                        figure=fig,
                    )

                    ax_legend = fig.add_subplot(gs[0, 0])
                    ax_top = fig.add_subplot(gs[0, 1])
                    ax_img = fig.add_subplot(gs[1, 1], sharex=ax_top)
                    ax_left = fig.add_subplot(gs[1, 0], sharey=ax_img)
                    ax_cb = fig.add_subplot(gs[1, 2])
                    ax_metrics = None


                # Global legend in the legend panel
                ax_legend.axis("off")
                legend_handles = [
                    Line2D([0], [0], label="all", **_PROJ_STYLE_ALL),
                    Line2D([0], [0], label="ROI", **_PROJ_STYLE_ROI),
                    Line2D([0], [0], label="peak", **_METRIC_STYLE_PEAK),
                    Line2D([0], [0], label="edges", **_METRIC_STYLE_EDGE),
                ]
                ax_legend.legend(
                    handles=legend_handles,
                    loc="center left",
                    bbox_to_anchor=(-0.1, 0.5),
                    borderaxespad=0.0,
                    frameon=False,
                    fontsize=8,
                )

                # Hide redundant tick labels
                plt.setp(ax_top.get_xticklabels(), visible=False)
                # We'll show v-ticks and the v-label on the LEFT projection,
                # and hide y tick labels on the main image to avoid overlap.
                plt.setp(ax_img.get_yticklabels(), visible=False)


            else:
                # Simple image + colorbar layout (no projections)
                fig = plt.figure(figsize=(6.0, 5.0))
                gs = GridSpec(1, 2, width_ratios=[12.0, 0.6], figure=fig)
                ax_img = fig.add_subplot(gs[0, 0])
                ax_cb = fig.add_subplot(gs[0, 1])
                ax_top = None
                ax_left = None

            # ----------------- Main image -----------------
            im = ax_img.imshow(
                img,
                origin="lower",
                extent=extent,
                cmap=cmap,
                aspect="auto",
            )
            if flip_vertical:
                ax_img.invert_yaxis()

            # Lock limits to image bounds (no white gaps)
            ax_img.set_xlim(extent[0], extent[1])
            ax_img.set_ylim(extent[2], extent[3])

            ax_img.set_xlabel(axis_labels[0])
            # In projections mode, the left panel owns the v-axis label.
            if projections and ax_left is not None:
                ax_img.set_ylabel("")
            else:
                ax_img.set_ylabel(axis_labels[1])

            # Colorbar
            cbar = fig.colorbar(im, cax=ax_cb)
            px_unit = axis_units
            cbar.set_label(
                f"counts per {du_plot:g} × {dv_plot:g} {px_unit}² pixel"
            )

            # ROI rectangle
            if projections and roi_cm is not None:
                ru0, ru1, rv0, rv1 = roi_cm
                if center_on_plane_center:
                    ru0_plot = (ru0 - u_mid_cm) * unit_scale
                    ru1_plot = (ru1 - u_mid_cm) * unit_scale
                    rv0_plot = (rv0 - v_mid_cm) * unit_scale
                    rv1_plot = (rv1 - v_mid_cm) * unit_scale
                else:
                    ru0_plot = ru0 * unit_scale
                    ru1_plot = ru1 * unit_scale
                    rv0_plot = rv0 * unit_scale
                    rv1_plot = rv1 * unit_scale

                rect = Rectangle(
                    (ru0_plot, rv0_plot),
                    ru1_plot - ru0_plot,
                    rv1_plot - rv0_plot,
                    fill=False,
                    linestyle="--",
                    linewidth=1.3,
                    edgecolor="white",
                    alpha=0.9,
                )
                ax_img.add_patch(rect)

            # 2D centroid crosshair overlay
            if projections and show_centroid_2d:
                if (u_centroid_plot is not None) and (v_centroid_plot is not None):

                    # Short crosshair arms (5% of image span)
                    u_span = extent[1] - extent[0]
                    v_span = extent[3] - extent[2]
                    hu = 0.05 * u_span
                    hv = 0.05 * v_span

                    # Horizontal line
                    ax_img.hlines(
                        v_centroid_plot,
                        u_centroid_plot - hu,
                        u_centroid_plot + hu,
                        colors="white",
                        linewidth=1.2,
                        alpha=0.9,
                    )
                    # Vertical line
                    ax_img.vlines(
                        u_centroid_plot,
                        v_centroid_plot - hv,
                        v_centroid_plot + hv,
                        colors="white",
                        linewidth=1.2,
                        alpha=0.9,
                    )

                    # Annotation (top-left inside image)
                    ax_img.text(
                        0.02, 0.98,
                        f"+  centroid = ({u_centroid_plot:.2f}, {v_centroid_plot:.2f}) {axis_units}",
                        transform=ax_img.transAxes,
                        ha="left",
                        va="top",
                        fontsize=8,
                        color="white",
                        bbox=dict(
                            boxstyle="round,pad=0.2",
                            facecolor="black",
                            edgecolor="none",
                            alpha=0.4,
                        ),
                    )


            # Cone-count annotation (above image, inside its axes coordinates)
            n_cones = _count_cones_for_species(f, sp)
            if n_cones is not None:
                if sp == "all":
                    txt = f"{n_cones} n+g event cones"
                elif sp == "n":
                    txt = f"{n_cones} neutron event cones"
                elif sp == "g":
                    txt = f"{n_cones} gamma event cones"
                else:
                    txt = f"{n_cones} event cones"
                ax_img.text(
                    0.5,
                    1.01,
                    txt,
                    transform=ax_img.transAxes,
                    ha="center",
                    va="bottom",
                    fontsize=10,
                )

            # ----------------- Projections -----------------
            if projections and ax_top is not None and ax_left is not None:
                # -------------------------
                # U-projection (top panel)
                # -------------------------
                ax_top.set_ylabel("Σ counts (over v)")
                ax_top.grid(alpha=0.2)

                line_all_u = None
                line_roi_u = None
                ax_top2 = None

                # Primary axis: draw "all" if enabled
                if draw_all_curve:
                    (line_all_u,) = ax_top.plot(
                        u_centers_plot,
                        proj_u,
                        label="all",
                        **_PROJ_STYLE_ALL,
                    )

                # ROI curve (if available and enabled)
                if draw_roi_curve and proj_u_roi is not None:
                    if draw_all_curve:
                        # Secondary y-axis: scaled to ROI only
                        ax_top2 = ax_top.twinx()
                        (line_roi_u,) = ax_top2.plot(
                            u_centers_plot,
                            proj_u_roi,
                            label="ROI",
                            **_PROJ_STYLE_ROI,
                        )

                        # Tight y-limits based on nonzero ROI values
                        nz = proj_u_roi[proj_u_roi > 0]
                        if nz.size > 0:
                            ymin, ymax = float(nz.min()), float(nz.max())
                            pad = 0.05 * (ymax - ymin) if ymax > ymin else max(
                                ymax * 0.1,
                                1.0,
                            )
                            ax_top2.set_ylim(ymin - pad, ymax + pad)
                        else:
                            ax_top2.set_ylim(0.0, 1.0)

                        # Inside ticks on the right, colored to match ROI curve
                        ax_top2.yaxis.set_label_position("right")
                        ax_top2.yaxis.tick_right()
                        ax_top2.tick_params(
                            axis="y",
                            direction="out",
                            pad=3,  # small positive: just inside the frame
                            colors=_PROJ_STYLE_ROI["color"],
                            labelcolor=_PROJ_STYLE_ROI["color"],
                        )
                        ax_top2.set_ylabel("")
                    else:
                        # ROI-only mode: single axis
                        (line_roi_u,) = ax_top.plot(
                            u_centers_plot,
                            proj_u_roi,
                            label="ROI",
                            **_PROJ_STYLE_ROI,
                        )

                        # Tight y-limits from ROI curve on the primary axis
                        nz = proj_u_roi[proj_u_roi > 0]
                        if nz.size > 0:
                            ymin, ymax = float(nz.min()), float(nz.max())
                            pad = 0.05 * (ymax - ymin) if ymax > ymin else max(
                                ymax * 0.1,
                                1.0,
                            )
                            ax_top.set_ylim(ymin - pad, ymax + pad)
                        else:
                            ax_top.set_ylim(0.0, 1.0)

                # Overlay u-axis metrics if available
                if peak_u_plot is not None and show_peak_markers:
                    ax_top.axvline(
                        peak_u_plot,
                        linewidth=1.0,
                        **_METRIC_STYLE_PEAK,
                    )
                if edge_low_u_plot is not None and show_edge_markers:
                    ax_top.axvline(
                        edge_low_u_plot,
                        linewidth=1.0,
                        **_METRIC_STYLE_EDGE,
                    )
                if edge_high_u_plot is not None and show_edge_markers:
                    ax_top.axvline(
                        edge_high_u_plot,
                        linewidth=1.0,
                        **_METRIC_STYLE_EDGE,
                    )

                # Tiny annotations showing edge_low_frac / edge_high_frac (in %)
                u_low_frac, u_high_frac = edge_fracs.get("u", (None, None))
                if edge_low_u_plot is not None and u_low_frac is not None and show_edge_markers:
                    ax_top.text(
                        edge_low_u_plot,
                        1.01,
                        f"{u_low_frac * 100:.0f}%",
                        transform=ax_top.get_xaxis_transform(),
                        ha="center",
                        va="bottom",
                        fontsize=5,
                        color=_METRIC_STYLE_EDGE["color"],
                    )
                if edge_high_u_plot is not None and u_high_frac is not None and show_edge_markers:
                    ax_top.text(
                        edge_high_u_plot,
                        1.01,
                        f"{u_high_frac * 100:.0f}%",
                        transform=ax_top.get_xaxis_transform(),
                        ha="center",
                        va="bottom",
                        fontsize=5,
                        color=_METRIC_STYLE_EDGE["color"],
                    )


                # Optional numeric summary annotation for u
                mode_summary = (annotate_summary or "off").lower()
                if mode_summary != "off" and u_metrics is not None:
                    label_u = "u"
                    if u_metrics_source in ("all", "roi"):
                        label_u = f"u/{u_metrics_source}"
                    txt_u = _format_axis_summary(label_u, u_metrics, mode_summary)
                    if txt_u:
                        ax_top.text(
                            0.02,
                            0.98,
                            txt_u,
                            transform=ax_top.transAxes,
                            ha="left",
                            va="top",
                            fontsize=7,
                            color="black",
                            bbox=dict(
                                boxstyle="round,pad=0.2",
                                facecolor="white",
                                edgecolor="none",
                                alpha=0.7,
                            ),
                        )

                # -------------------------
                # V-projection (left panel)
                # -------------------------
                ax_left.set_xlabel("Σ counts (over u)")
                ax_left.set_ylabel(axis_labels[1])  # v-label lives here
                ax_left.grid(alpha=0.2)

                line_all_v = None
                line_roi_v = None
                ax_left2 = None

                # Primary axis: draw "all" if enabled
                if draw_all_curve:
                    (line_all_v,) = ax_left.plot(
                        proj_v,
                        v_centers_plot,
                        label="all",
                        **_PROJ_STYLE_ALL,
                    )

                # ROI curve (if available and enabled)
                if draw_roi_curve and proj_v_roi is not None:
                    if draw_all_curve:
                        # Secondary x-axis (top): scaled to ROI only
                        ax_left2 = ax_left.twiny()
                        (line_roi_v,) = ax_left2.plot(
                            proj_v_roi,
                            v_centers_plot,
                            label="ROI",
                            **_PROJ_STYLE_ROI,
                        )

                        # Tight x-limits from nonzero ROI
                        nz = proj_v_roi[proj_v_roi > 0]
                        if nz.size > 0:
                            xmin, xmax = float(nz.min()), float(nz.max())
                            pad = 0.05 * (xmax - xmin) if xmax > xmin else max(
                                xmax * 0.1,
                                1.0,
                            )
                            ax_left2.set_xlim(xmin - pad, xmax + pad)
                        else:
                            ax_left2.set_xlim(0.0, 1.0)

                        # Keep "zero on the right" for secondary axis as well
                        ax_left2.invert_xaxis()

                        # Inside ticks at the top edge, in ROI color
                        ax_left2.xaxis.set_label_position("top")
                        ax_left2.xaxis.tick_top()
                        ax_left2.tick_params(
                            axis="x",
                            direction="out",
                            pad=3,  # small positive: just inside
                            colors=_PROJ_STYLE_ROI["color"],
                            labelcolor=_PROJ_STYLE_ROI["color"],
                        )
                        ax_left2.set_xlabel("")
                        ax_left2.set_ylabel("")
                    else:
                        # ROI-only mode: single axis
                        (line_roi_v,) = ax_left.plot(
                            proj_v_roi,
                            v_centers_plot,
                            label="ROI",
                            **_PROJ_STYLE_ROI,
                        )

                        # Tight x-limits from ROI curve on the primary axis
                        nz = proj_v_roi[proj_v_roi > 0]
                        if nz.size > 0:
                            xmin, xmax = float(nz.min()), float(nz.max())
                            pad = 0.05 * (xmax - xmin) if xmax > xmin else max(
                                xmax * 0.1,
                                1.0,
                            )
                            ax_left.set_xlim(xmin - pad, xmax + pad)
                        else:
                            ax_left.set_xlim(0.0, 1.0)

                # Overlay v-axis metrics if available
                if peak_v_plot is not None and show_peak_markers:
                    ax_left.axhline(
                        peak_v_plot,
                        linewidth=1.0,
                        **_METRIC_STYLE_PEAK,
                    )
                if edge_low_v_plot is not None and show_edge_markers:
                    ax_left.axhline(
                        edge_low_v_plot,
                        linewidth=1.0,
                        **_METRIC_STYLE_EDGE,
                    )
                if edge_high_v_plot is not None and show_edge_markers:
                    ax_left.axhline(
                        edge_high_v_plot,
                        linewidth=1.0,
                        **_METRIC_STYLE_EDGE,
                    )

                # Tiny annotations showing edge_low_frac / edge_high_frac (in %)
                v_low_frac, v_high_frac = edge_fracs.get("v", (None, None))
                if edge_low_v_plot is not None and v_low_frac is not None and show_edge_markers:
                    ax_left.text(
                        1.002,  # just to the right of the axis frame
                        edge_low_v_plot,
                        f"{v_low_frac * 100:.0f}%",
                        transform=ax_left.get_yaxis_transform(),
                        ha="left",
                        va="center",
                        fontsize=5,
                        color=_METRIC_STYLE_EDGE["color"],
                    )
                if edge_high_v_plot is not None and v_high_frac is not None and show_edge_markers:
                    ax_left.text(
                        1.002,
                        edge_high_v_plot,
                        f"{v_high_frac * 100:.0f}%",
                        transform=ax_left.get_yaxis_transform(),
                        ha="left",
                        va="center",
                        fontsize=5,
                        color=_METRIC_STYLE_EDGE["color"],
                    )

                # Optional numeric summary annotation for v
                mode_summary = (annotate_summary or "off").lower()
                if mode_summary != "off" and v_metrics is not None:
                    label_v = "v"
                    if v_metrics_source in ("all", "roi"):
                        label_v = f"v/{v_metrics_source}"
                    txt_v = _format_axis_summary(label_v, v_metrics, mode_summary)
                    if txt_v:
                        ax_left.text(
                            0.02,
                            0.98,
                            txt_v,
                            transform=ax_left.transAxes,
                            ha="left",
                            va="top",
                            fontsize=7,
                            color="black",
                            bbox=dict(
                                boxstyle="round,pad=0.2",
                                facecolor="white",
                                edgecolor="none",
                                alpha=0.7,
                            ),
                        )


                # Keep "zero on the right" for the primary v-projection axis as well
                ax_left.invert_xaxis()

                # Render the metrics panel, if present
                if projections and (nu > 1 or nv > 1) and ax_metrics is not None:
                    _render_metrics_panel(ax_metrics, metrics_sel)


            # Suptitle for the whole figure
            species_label = {
                "n": "n",
                "g": "g",
                "all": "n+g",
            }.get(sp, sp)

            effective_label = plot_label or stored_plot_label
            if effective_label:
                title = f"{effective_label}\n{stem} : {species_label} cones"
            else:
                title = f"{stem} : {species_label} cones"

            fig.suptitle(title, y=0.98)

            # Leave room for suptitle. When the metrics panel is present, we
            # pull the whole subplot region down a bit (smaller `top`) and
            # also extend it further toward the bottom (smaller `bottom`) so
            # that:
            #   - the u-annotation and % labels have more headroom under the title
            #   - the metrics table drops below the u-axis label instead of
            #     overlapping it, filling the remaining white space.
            if not show_metrics_panel:
                fig.subplots_adjust(top=0.93)
            else:
                fig.subplots_adjust(top=0.92, bottom=0.05)

            # ----------------------------------------------------------------------
            # Add ngimager footer annotation (version + hyperlink)
            # ----------------------------------------------------------------------

            # HDF5 attributes already loaded earlier in the function
            software = f.attrs.get("software", "")
            docs_url = f.attrs.get("docs_url", "")

            # Text formatting (subtle, italic, gray)
            footer_font = {
                'color': '#666666',
                'weight': 'normal',
                'size': 8,
                'style': 'italic'
            }

            # Build footer string (only include URL/version if present)
            footer_parts = ["Figure generated by ng-imager"]
            if docs_url:
                footer_parts.append(f{docs_url}")
            if software:
                footer_parts.append(f{software}")

            footer_text = " ".join(footer_parts)

            # Place text at bottom-left of figure
            # url=docs_url makes this a working hyperlink in PDF output
            fig.text(
                0.005, 0.005,
                footer_text,
                fontdict=footer_font,
                ha='left',
                va='bottom',
                url=docs_url if docs_url else None
            )

            # Save in all requested formats
            for fmt in fmt_list:
                out_name = filename_pattern.format(
                    stem=stem,
                    species=sp,
                    ext=fmt,
                )
                out_path = h5_path.with_name(out_name)
                dpi = 150 if fmt in ("png", "jpg", "jpeg") else None
                fig.savefig(out_path, dpi=dpi)
                out_paths.append(out_path)

            plt.close(fig)

    return out_paths

Simulation & Tools

Developer utilities and LUT tools.

ngimager.tools.bundle_repo

Utility for snapshotting the repository (e.g. for embedding into an HDF5 file).

bundle_repo.py — produce a single-file text bundle of your repo.

Usage: python src/ngimager/tools/bundle_repo.py . -o repo_bundle.txt

What it does: - Writes a directory tree. - For each text file, writes a header with path/size/sha256 and the content. - Skips binaries and large files (configurable). - Skips typical junk dirs (.git, pycache, build artifacts).

build_tree

build_tree(root)

Return a simple text tree of the repo.

Source code in ngimager/tools/bundle_repo.py
def build_tree(root: Path) -> str:
    """Return a simple text tree of the repo."""
    lines = []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in DEFAULT_EXCLUDE_DIRS]
        rel = Path(dirpath).relative_to(root)
        indent = "  " * (0 if str(rel) == "." else len(rel.parts))
        if str(rel) != ".":
            lines.append(f"{indent}{rel}/")
        for fn in sorted(filenames):
            lines.append(f"{indent}  {fn}")
    return "\n".join(lines)

get_git_commit

get_git_commit(root)

Best-effort retrieval of the current Git commit SHA for the repo root.

Returns a full 40-character SHA string if available, otherwise None. This is intentionally non-fatal: if the repo is not a Git checkout, or git is not installed, or anything else goes wrong, we just return None.

Source code in ngimager/tools/bundle_repo.py
def get_git_commit(root: Path) -> Optional[str]:
    """
    Best-effort retrieval of the current Git commit SHA for the repo root.

    Returns a full 40-character SHA string if available, otherwise None.
    This is intentionally non-fatal: if the repo is not a Git checkout,
    or git is not installed, or anything else goes wrong, we just return None.
    """
    git_dir = root / ".git"
    if not git_dir.exists():
        return None
    try:
        result = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=str(root),
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            text=True,
            check=False,
        )
        sha = (result.stdout or "").strip()
        return sha or None
    except Exception:
        return None

is_textlike

is_textlike(p)

Heuristic to decide if a file is text-like.

Source code in ngimager/tools/bundle_repo.py
def is_textlike(p: Path) -> bool:
    """Heuristic to decide if a file is text-like."""
    if p.suffix.lower() in DEFAULT_INCLUDE_EXT:
        return True
    # Heuristic: small files without NUL bytes
    try:
        with open(p, "rb") as f:
            chunk = f.read(4096)
        return b"\x00" not in chunk
    except Exception:
        return False

walk_repo

walk_repo(root)

Yield all files under root, skipping DEFAULT_EXCLUDE_DIRS.

Source code in ngimager/tools/bundle_repo.py
def walk_repo(root: Path):
    """Yield all files under root, skipping DEFAULT_EXCLUDE_DIRS."""
    for dirpath, dirnames, filenames in os.walk(root):
        # prune excluded dirs in-place
        dirnames[:] = [d for d in dirnames if d not in DEFAULT_EXCLUDE_DIRS]
        for fn in sorted(filenames):
            yield Path(dirpath) / fn

Light-response LUT tools

The ngimager.tools.generate_lut module contains functions for building, fitting, and using light-response lookup tables (LUTs) for NOVO scintillators.

NOVO_light_response_functions

Created by Hunter N. Ratliff, 2025-10-17 This code generates light response functions/lookup-tables (LUTs), forward and inverse, for NOVO's M600 and OGS scintillators using my SRIM calculations as a basis for a Birks function fit whose parameters are optimized for proton light response data collected in NOVO's March 2024 PTB experiments.

==============================================================================

Light-Response Fitting and LUT Generation — Explainer

==============================================================================

This script builds physics-based light-response models for plastic scintillators and exports fast lookup tables (LUTs) to convert measured light output (MeVee) into recoil energy (MeV). It supports both proton and carbon recoils and produces figures for fit quality and inverse-response uncertainty bands.

What the script does (high level)

1) Loads stopping power data (SRIM) for protons and carbon and converts to linear stopping power using the scintillator density. 2) Loads experimental calibration data from PTB that map proton recoil energy to measured light output. 3) Fits a Birks-type light-yield model (Birks or Birks–Chou) to the calibration data. 4) Optionally constrains S to 1 using gamma Compton-edge calibration (MeVee scale). 5) Builds dense forward and inverse LUTs: - Forward: E -> L(E) - Inverse: L -> E(L), uniform grid in L for fast np.interp 6) Computes 68 percent confidence bands on E(L) by sampling the fitted parameters and propagating to inverse LUTs. 7) Exports portable artifacts (NPZ, CSV, JSON metadata) and generates plots.

Inputs

  • SRIM stopping power files for H and C ions for each scintillator:
  • Must include energy (MeV) and mass stopping power (MeV cm^2 / g).
  • Energy range ideally covers 1 keV to at least 100–250 MeV.
  • Scintillator density rho (g/cm^3) for each material.
  • Experimental proton light-response calibration:
  • Arrays of Ep_MeV (proton recoil energies) and L_MeVee (measured light output).
  • Optional grouping labels for different neutron energies (En_indices, En_strs) to visualize subsets.
  • Gamma Compton calibration (performed upstream):
  • Data acquisition already outputs MeVee. This allows S to be fixed to 1 or softly constrained near 1.

Outputs

For each scintillator and species (proton, carbon):

  • NPZ file: basepath.npz
  • Arrays: L_inv (MeVee), E_inv (MeV)
  • Optional arrays: E_inv_lo, E_inv_hi (16th and 84th percentile inverse bands)
  • Metadata object with model, parameters, density, fit stats, grid sizes, timestamp
  • CSV file: basepath.csv
  • Two columns: L_inv_MeVee, E_inv_MeV (plaintext for sharing and longevity)
  • JSON metadata: basepath.meta.json
  • Human-readable metadata mirror of the NPZ meta
  • Plots (if enabled):
  • Birks fit and residuals (stacked) per scintillator
  • Inverse response E(L) with 68 percent bands for proton and carbon
  • Zoomed carbon inverse plot

Methods and process

1) Units and data prep - Convert mass stopping power to linear: dE/dx [MeV/cm] = rho * (dE/dx)_mass [MeV cm^2 / g]. - Interpolate dE/dx(E) with a monotone, nonnegative interpolant (shape-preserving cubic, or safe wrapper).

2) Light-response model - Birks: dL/dx = S * (dE/dx) / (1 + kB * dE/dx) - Birks–Chou (optional): dL/dx = S * (dE/dx) / (1 + kB * dE/dx + C * (dE/dx)^2) - Total light for a recoil of energy E is the integral of dL/dE over energy. Numerically integrate over a dense E grid.

3) Parameter fitting - Nonlinear least squares (scipy.optimize.least_squares) on residuals L_model(Ei) - L_data,i. - Residual variance scaling: covariance = sigma^2 * (J^T J)^-1 with sigma^2 = SSE / (N - p). - Report best-fit parameters, 1 sigma uncertainties, R^2, adjusted R^2, RMSE.

4) Handling S (electron-equivalent scale) - If data are already in MeVee via Compton-edge calibration, fix S = 1 (hard) or apply a soft Gaussian prior on S near 1 (e.g., sigma 0.01–0.02). - This removes S–kB degeneracy and stabilizes extrapolation.

5) Building LUTs - Forward: compute L(E) on a dense E grid (e.g., up to 250 MeV). - Inverse: create a uniformly spaced L grid and tabulate E(L) with np.interp. - Save proton and carbon inverse LUTs separately. Use float32 for compact storage and fast lookup.

6) Uncertainty bands (optional) - Draw samples of [S, kB, C] from the multivariate normal defined by the fitted covariance. - For each sample, compute inverse E(L) onto the fixed L grid. - Take the 16th and 84th percentiles across samples at each L to form a 68 percent confidence band. - Store E_inv_lo and E_inv_hi alongside the central inverse LUT.

7) Plotting (optional) - Fit-quality figure: top panel shows data and model L(E); bottom panel shows percent residuals. - Inverse figure: E(L) central curve with 68 percent band for proton and carbon. - Carbon often appears highly quenched; use a zoomed L range (e.g., L < 8 MeVee) or annotate unreachable regions using elastic kinematic caps.

How to use the LUTs downstream

  • Load NPZ: L_inv, E_inv. Convert MeVee to recoil energy with Ep = np.interp(L_meas, L_inv, E_inv). Fast example drop-in code:
    pack = np.load("lut_M600_proton.npz", allow_pickle=True)
    L_inv = pack["L_inv"]; E_inv = pack["E_inv"]
    def Ep_from_L(L): return np.interp(L, L_inv, E_inv)
    
  • If uncertainty bands were exported: compute Ep_lo and Ep_hi via the same interpolation on E_inv_lo and E_inv_hi.
  • Use proton and carbon LUTs in parallel and let imaging logic choose between hypotheses or carry both with weights.
  • Optional: clip carbon solutions using an elastic kinematic ceiling given a neutron energy bound.

Configuration knobs

  • Model selection: use_Chou_C_term boolean to include the C term.
  • S handling: lock S exactly to 1 via lock_S_to_1 = True or via bounds, or set a soft prior on S with prior_sigma.
  • Grids: E_max, nE for forward integration; nL for inverse grid density.
  • Band sampling: number of parameter draws, sample filtering for monotonicity and stability.

Assumptions and caveats

  • Electron-equivalent calibration is already applied upstream; therefore S should be 1 or tightly constrained near 1.
  • The carbon LUT is more uncertain in practice without carbon-tagged calibration; use it as a conservative branch and apply kinematic caps where appropriate.
  • Extrapolation beyond the calibration Ep range is supported but rely on the band to communicate model uncertainty.
  • Monotonicity is required for E(L) inversion; pathological parameter draws are rejected.

Troubleshooting

  • Inverse interpolation error (requires at least two unique L points): occurs if a sampled parameter set produces nearly flat L(E). The sampler filters such draws; increase sample count or tighten priors if too many draws are rejected.
  • Large parameter uncertainties under Birks–Chou: usually indicates kB and C are highly correlated and C is weakly identifiable; prefer simple Birks unless low-energy data demand C.
  • Odd high-L divergence between Birks and Chou: typically due to unconstrained S; fix S via Compton calibration.

Dependencies

Files written (per scintillator and species)

  • basepath.npz: L_inv, E_inv, and optional E_inv_lo, E_inv_hi, plus metadata.
  • basepath.csv: plaintext columns L_inv_MeVee, E_inv_MeV.
  • basepath.meta.json: metadata (scintillator, species, model, parameters, density, fit stats, grid sizes, timestamp).
  • Figures: fit and inverse-band plots if saving is enabled.

This design yields a transparent, physics-backed model with fast and portable inverse LUTs for experimental imaging.

Birks_params module-attribute

Birks_params = {'M600': {'S': 1.0, 'kB': 14.4, 'kB_linear': 14.4 * 0.001 / density['M600'], 'C': 0, 'C_linear': 0}, 'OGS': {'S': 0.83, 'kB': 5.5, 'kB_linear': 5.5 * 0.001 / density['OGS'], 'C': 0, 'C_linear': 0}}

As a note, these files are those directly produced by SRIM. The "SRIM_*.dat" files Joey used have column 1 units of MeV and column 2 units of keV / (mg/cm^2) (or, equivalently, MeV / (g/cm^2)).

read_SRIM_output

read_SRIM_output(path_to_SRIM_output)

Parses a SRIM output file, returning a dictionary object with particle energies in MeV and mass stopping powers in MeV / (g/cm^2).

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def read_SRIM_output(path_to_SRIM_output):
    '''
    Parses a SRIM output file, returning a dictionary object with particle energies in MeV and
    mass stopping powers in MeV / (g/cm^2).
    '''
    E_MeV = []
    dEdx_ele = []
    dEdx_nuc = []
    dEdx_tot = []
    E_to_MeV_mults = {'meV':1e-9, 'eV':1e-6, 'keV':1e-3, 'MeV':1, 'GeV':1e+3, 'TeV':1e+6}
    stopping_units = ''
    with open(path_to_SRIM_output, 'r') as f:
        lines = f.readlines()
        in_data_table_section = False
        for line in lines:
            if '  --------------  ---------- ---------- ----------  ----------  ----------' in line:
                in_data_table_section = True
                continue
            if '-----------------------------------------------------------' in line:
                in_data_table_section = False
                continue
            if "Stopping Units =" in line:
                stopping_units = line.split('=')[-1].strip() # should be 'MeV / (mg/cm2)', but good to check anyways
                if stopping_units != 'MeV / (mg/cm2)':
                    print(f'WARNING: Stopping units are {stopping_units}, not the expected "MeV / (mg/cm2)".')
            if not in_data_table_section: continue
            parts = line.split()
            E_unit = parts[1].strip()
            E_MeV.append(float(parts[0])*E_to_MeV_mults[E_unit])
            dEdx_ele.append(float(parts[2]))
            dEdx_nuc.append(float(parts[3]))
            dEdx_tot.append(dEdx_ele[-1]+dEdx_nuc[-1])
    # scale up mass topping powers to MeV / (g/cm^2)
    stopping_units_mult = 1000
    SRIM_output = {
        'E_MeV':np.array(E_MeV),
        'dEdx_units':'MeV / (g/cm^2)',
        'dEdx_units_TeX':r'MeV/(g/cm$^2$)',
        'dEdx_electronic':np.array(dEdx_ele)*stopping_units_mult,
        'dEdx_nuclear':np.array(dEdx_nuc)*stopping_units_mult,
        'dEdx_total':np.array(dEdx_tot)*stopping_units_mult,
    }
    return SRIM_output

read_light_response_file

read_light_response_file(path_to_light_output_data)

This function is for reading Joey's two-column light response data points files "LightOutput_*.dat". Column 1 is the recoil proton energy Ep / neutron energy lost dEn in MeV, and Column 2 is the light response in MeVee It returns a dictionary object where the Ep and L pairs are proerly ordered, ascending by Ep Blank lines delimit values taken from different source neutron energies

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def read_light_response_file(path_to_light_output_data):
    '''
    This function is for reading Joey's two-column light response data points files "LightOutput_*.dat".
    Column 1 is the recoil proton energy Ep / neutron energy lost dEn in MeV, and
    Column 2 is the light response in MeVee
    It returns a dictionary object where the Ep and L pairs are proerly ordered, ascending by Ep
    Blank lines delimit values taken from different source neutron energies
    '''
    PTB_En_vals = ['14.8 MeV', '6.5 MeV', '2.5 MeV']
    En_index = 0
    PTB_En_indices = []
    Ep_MeV = []
    L_MeVee = []
    with open(path_to_light_output_data, 'r') as f:
        lines = f.readlines()
        for line in lines:
            if len(line.strip())==0:
                En_index += 1
                continue
            parts = line.split()
            Ep_MeV.append(float(parts[0]))
            L_MeVee.append(float(parts[1]))
            PTB_En_indices.append(En_index)
    # Now reorder by Ep
    #Ep_MeV, L_MeVee = zip(*sorted(zip(Ep_MeV, L_MeVee)))
    return {'Ep_MeV':np.array(Ep_MeV), 'L_MeVee':np.array(L_MeVee), 'En_strs':PTB_En_vals, 'En_indices':np.array(PTB_En_indices)}

pm_fmt

pm_fmt(val, err, unit=None, sig_figs=2)

Format 'val ± err' preserving significant digits, handling very small numbers (e.g., 0.00301 ± 0.00012).

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def pm_fmt(val, err, unit=None, sig_figs=2):
    """
    Format 'val ± err' preserving significant digits,
    handling very small numbers (e.g., 0.00301 ± 0.00012).
    """
    # Safety checks
    if err is None or not np.isfinite(err) or err <= 0:
        s = f"{val:.{sig_figs}g}"
        return f"{s} {unit}" if unit else s

    # Determine the order of magnitude of the uncertainty
    exp_err = int(np.floor(np.log10(err)))
    # Round to 'sig_figs' significant digits in the uncertainty
    rounded_err = round(err, -exp_err + (sig_figs - 1))
    # Match value rounding to same decimal place
    decimals = max(0, -(exp_err - (sig_figs - 1)))
    fmt = f"{{0:.{decimals}f}} ± {{1:.{decimals}f}}"
    s = fmt.format(val, rounded_err)

    # Handle very small values nicely (avoid scientific when not needed)
    if abs(val) < 1e-3 or abs(rounded_err) < 1e-3:
        s = f"{val:.{sig_figs}e} ± {err:.{sig_figs}e}"

    return f"{s} {unit}" if unit else s

light_integral_grid

light_integral_grid(E_grid, dedx_func, S, kB, C=0.0)

Returns L(E) tabulated on E_grid using cumulative trapezoid integration of dL/dE. dL/dE = S / (1 + kBdEdx + CdEdx^2)

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def light_integral_grid(E_grid, dedx_func, S, kB, C=0.0):
    """
    Returns L(E) tabulated on E_grid using cumulative trapezoid integration of dL/dE.
    dL/dE = S / (1 + kB*dEdx + C*dEdx^2)
    """
    dEdx_vals = dedx_func(E_grid)
    denom = 1.0 + kB * dEdx_vals + C * dEdx_vals**2
    integrand = S / denom
    L_grid = cumulative_trapezoid(integrand, E_grid, initial=0.0)
    return L_grid

make_forward_inverse_LUT

make_forward_inverse_LUT(dedx_func, S, kB, C=0.0, E_max=250.0, nE=125001)

Build dense forward LUT (E->L) and inverse (L->E) interpolants. nE large => smooth & accurate integrals + inversion.

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def make_forward_inverse_LUT(dedx_func, S, kB, C=0.0, E_max=250.0, nE=125001):
    """
    Build dense forward LUT (E->L) and inverse (L->E) interpolants.
    nE large => smooth & accurate integrals + inversion.
    """
    E_grid = np.linspace(0.0, E_max, nE)
    L_grid = light_integral_grid(E_grid, dedx_func, S, kB, C)
    # Ensure strict monotonicity for inversion (should be true physically)
    L_grid_mon, E_grid_mon = ensure_monotone_increasing(L_grid, E_grid)
    # Forward: E->L (fast linear or PCHIP)
    L_of_E = PchipInterpolator(E_grid, L_grid, extrapolate=False)
    # Inverse: L->E via PCHIP on swapped axes
    E_of_L = PchipInterpolator(L_grid_mon, E_grid_mon, extrapolate=False)
    return (E_grid, L_grid, L_of_E, E_of_L)

fit_birks_params

fit_birks_params(E_data, L_data, dedx_func, init=(1.0, 0.01, 0.0), use_C=False, bounds=((0, 0, 0), (np.inf, np.inf, np.inf)), prior_S=None, prior_sigma=None)

Fit (S, kB [, C]) by minimizing residuals on L(E). E_data in MeV (proton energy), L_data in MeVee. init: (S, kB[, C]) - use Joey's numbers as initial values bounds: ((Smin, kBmin, Cmin), (Smax, kBmax, Cmax))

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def fit_birks_params(E_data, L_data, dedx_func, init=(1.0, 0.01, 0.0), use_C=False, bounds=((0, 0, 0), (np.inf, np.inf, np.inf)), prior_S=None, prior_sigma=None):
    """
    Fit (S, kB [, C]) by minimizing residuals on L(E).
    E_data in MeV (proton energy), L_data in MeVee.
    init: (S, kB[, C]) - use Joey's numbers as initial values
    bounds: ((Smin, kBmin, Cmin), (Smax, kBmax, Cmax))
    """
    E_data = np.asarray(E_data, dtype=float)
    L_data = np.asarray(L_data, dtype=float)
    E_grid = np.linspace(0.0, max(1.05*np.max(E_data), 20.0), 20001)  # ensure dense coverage

    def residuals(p):
        S, kB = p[0], p[1]
        C = p[2] if use_C else 0.0
        L_grid = light_integral_grid(E_grid, dedx_func, S, kB, C)
        L_model = np.interp(E_data, E_grid, L_grid)
        r = (L_model - L_data)
        if (prior_S is not None) and (prior_sigma is not None) and (prior_sigma > 0):
            r = np.concatenate([r, np.array([(S - prior_S)/prior_sigma])])
        return r

    p0 = np.array(init[:(3 if use_C else 2)], dtype=float)
    lower = np.array(bounds[0][:len(p0)], dtype=float)
    upper = np.array(bounds[1][:len(p0)], dtype=float)

    opt = least_squares(residuals, p0, bounds=(lower, upper), jac='2-point')
    # Covariance estimate from the Jacobian
    theta = opt.x
    r = opt.fun
    J = opt.jac            # (N x p) numerical Jacobian
    N = len(r)
    p = len(theta)
    dof = max(1, N - p)

    # SSE and residual variance
    SSE = float(np.dot(r, r))
    sigma2_hat = SSE / dof

    # Covariance via (J^T J)^(-1) scaled by sigma^2 (with SVD fallback)
    # Note: least_squares returns J at the solution for residuals, so J^T J is the GN Hessian approx.
    JTJ = J.T @ J
    try:
        cov_unscaled = np.linalg.inv(JTJ)
    except np.linalg.LinAlgError:
        # robust SVD-based pseudo-inverse if nearly singular
        U, s, VT = np.linalg.svd(J, full_matrices=False)
        cov_unscaled = VT.T @ np.diag(1.0 / (s**2)) @ VT
    cov = sigma2_hat * cov_unscaled

    # Standard errors
    se = np.sqrt(np.diag(cov))
    # 95% CIs using normal approx (or use t-dist if you prefer)
    ci95 = np.column_stack([theta - 1.96*se, theta + 1.96*se])

    # Simple goodness-of-fit metrics (unweighted)
    L_bar = float(np.mean(L_data))
    SST = float(np.sum((L_data - L_bar)**2))
    R2 = 1.0 - (SSE / SST) if SST > 0 else np.nan
    R2_adj = 1.0 - (1.0 - R2) * (N - 1) / dof
    RMSE = np.sqrt(sigma2_hat)
    '''
    # (Assumes residual variance ~ 1; rescale by sigma^2 if known)
    J = opt.jac
    _, s, VT = np.linalg.svd(J, full_matrices=False)
    threshold = np.finfo(float).eps * max(J.shape) * s[0]
    s = s[s > threshold]
    VT = VT[:len(s)]
    cov = VT.T @ np.diag(1/s**2) @ VT
    '''
    # Pack results
    if use_C:
        S_hat, kB_hat, C_hat = opt.x
        seS, sekB, seC = se
        cov_full = cov
    else:
        S_hat, kB_hat = opt.x
        C_hat = 0.0
        #cov = np.pad(cov, ((0,1),(0,1)), mode='constant')
        seS, sekB = se
        seC = 0.0
        # pad covariance to 3x3 for uniform handling elsewhere
        cov_full = np.zeros((3,3), float); cov_full[:2,:2] = cov
        ciC = (0.0, 0.0)
        ci95 = np.vstack([ci95, [0.0, 0.0]])
    return dict(x=np.array([S_hat, kB_hat, C_hat]),
                success=opt.success,
                cost=opt.cost,
                message=opt.message,
                nfev=opt.nfev,
                se=np.array([seS, sekB, seC]),
                cov=cov_full,
                ci95=ci95,
                stats=dict(
                    N=N, p=p, dof=dof, SSE=SSE, RMSE=RMSE, R2=R2, R2_adj=R2_adj, sigma2=sigma2_hat
                ),
                )

make_inverse_sampler

make_inverse_sampler(dedx_func, params_samples, E_max=250.0, nE=125001)

Build many inverse interpolants E(L) for uncertainty bands. Returns a list of callables E_of_L_samplers.

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def make_inverse_sampler(dedx_func, params_samples, E_max=250.0, nE=125001):
    """
    Build many inverse interpolants E(L) for uncertainty bands.
    Returns a list of callables E_of_L_samplers.
    """
    samplers = []
    for (S, kB, C) in params_samples:
        if not is_valid_params(S, kB, C, dedx_func, E_max=E_max, nE=min(40001, nE)):
            continue  # skip pathological draws
        _, _, _, E_of_L = make_forward_inverse_LUT(dedx_func, S, kB, C, E_max, nE)
        samplers.append(E_of_L)
    return samplers

inverse_with_bands

inverse_with_bands(L_vals, E_of_L_central, E_of_L_samplers)

For each L, return median and central 68% interval of E across samplers.

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def inverse_with_bands(L_vals, E_of_L_central, E_of_L_samplers):
    """
    For each L, return median and central 68% interval of E across samplers.
    """
    L_vals = np.atleast_1d(L_vals)
    E_med = E_of_L_central(L_vals)
    if len(E_of_L_samplers) == 0:
        return E_med, None, None
    Es = np.vstack([s(L_vals) for s in E_of_L_samplers])
    lo, hi = np.nanpercentile(Es, [16, 84], axis=0)
    return E_med, lo, hi

compute_inverse_band

compute_inverse_band(dedx_fun, params_samples, L_inv_ref, *, E_max=250.0, nE=125001)

Build 68% (16/84) percentile inverse E(L) on a fixed L grid (L_inv_ref) by sampling Birks params. Returns (E_inv_lo, E_inv_hi, E_inv_med). Any pathological samples (non-monotone L(E)) are skipped.

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def compute_inverse_band(dedx_fun, params_samples, L_inv_ref, *, E_max=250.0, nE=125001):
    """
    Build 68% (16/84) percentile inverse E(L) on a *fixed* L grid (L_inv_ref)
    by sampling Birks params. Returns (E_inv_lo, E_inv_hi, E_inv_med).
    Any pathological samples (non-monotone L(E)) are skipped.
    """
    E_inv_samples = []
    E_grid = np.linspace(0.0, E_max, int(nE))
    for (S_s, kB_s, C_s) in params_samples:
        # Forward L(E) for this draw
        L_grid = light_integral_grid(E_grid, dedx_fun, S_s, kB_s, C_s)
        # Must be monotone to invert
        dL = np.diff(L_grid)
        if not np.all(np.isfinite(L_grid)):
            continue
        if np.ptp(L_grid) < 1e-9:
            continue
        # allow tiny plateaus; reject if too many
        if np.count_nonzero(dL <= 1e-12) > 0.01 * len(dL):
            continue
        # Inverse onto fixed L grid
        E_inv_s = np.interp(L_inv_ref, L_grid, E_grid)
        E_inv_samples.append(E_inv_s)

    if len(E_inv_samples) == 0:
        return None, None, None

    E_inv_samples = np.asarray(E_inv_samples, dtype=np.float64)
    E_inv_lo  = np.percentile(E_inv_samples, 16, axis=0).astype(np.float32)
    E_inv_hi  = np.percentile(E_inv_samples, 84, axis=0).astype(np.float32)
    E_inv_med = np.percentile(E_inv_samples, 50, axis=0).astype(np.float32)
    return E_inv_lo, E_inv_hi, E_inv_med

accumulate_light_from_steps

accumulate_light_from_steps(steps, dedx_fun, S, kB, C=0.0)

steps: iterable of dicts with keys {'dE', 'E_mid'} for a given recoil track returns total light for that track

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def accumulate_light_from_steps(steps, dedx_fun, S, kB, C=0.0):
    """
    steps: iterable of dicts with keys {'dE', 'E_mid'} for a given recoil track
    returns total light for that track
    """
    return sum(dL_from_step(st['dE'], st['E_mid'], dedx_fun, S, kB, C) for st in steps)

build_inverse_L_grid

build_inverse_L_grid(E_fine, L_fine, nL=60001)

Make a uniformly spaced grid in L (monotone), then tabulate E(L) with np.interp.

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def build_inverse_L_grid(E_fine, L_fine, nL=60001):
    """
    Make a uniformly spaced grid in L (monotone), then tabulate E(L) with np.interp.
    """
    L_min, L_max = float(L_fine[0]), float(L_fine[-1])
    L_inv = np.linspace(L_min, L_max, int(nL), dtype=np.float32)
    E_inv = np.interp(L_inv, L_fine, E_fine).astype(np.float32)
    return L_inv, E_inv

save_lut_npz_csv

save_lut_npz_csv(basepath, L_inv, E_inv, meta)

Saves: - basepath + ".npz" (binary, fast) - basepath + ".csv" (plaintext, two columns: L_inv,E_inv) - basepath + ".meta.json" (small JSON metadata, human-readable)

Parameters:

Name Type Description Default
basepath str | Path

Path without extension (e.g., Path("results/lut_M600_proton")). The function appends .npz, .csv, and .meta.json automatically.

required
L_inv array - like

Inverse lookup arrays: L_inv (MeVee) and E_inv (MeV).

required
E_inv array - like

Inverse lookup arrays: L_inv (MeVee) and E_inv (MeV).

required
meta dict

Metadata dictionary describing the LUT contents.

required
Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def save_lut_npz_csv(basepath, L_inv, E_inv, meta):
    """
    Saves:
      - basepath + ".npz"  (binary, fast)
      - basepath + ".csv"  (plaintext, two columns: L_inv,E_inv)
      - basepath + ".meta.json" (small JSON metadata, human-readable)

    Parameters
    ----------
    basepath : str | Path
        Path *without* extension (e.g., Path("results/lut_M600_proton")).
        The function appends .npz, .csv, and .meta.json automatically.
    L_inv, E_inv : array-like
        Inverse lookup arrays: L_inv (MeVee) and E_inv (MeV).
    meta : dict
        Metadata dictionary describing the LUT contents.
    """
    basepath = Path(basepath)
    basepath.parent.mkdir(parents=True, exist_ok=True)
    npz_path  = basepath.with_suffix(".npz")
    csv_path  = basepath.with_suffix(".csv")
    json_path = basepath.with_suffix(".meta.json")
    # NPZ
    np.savez(npz_path, L_inv=L_inv, E_inv=E_inv, meta=np.array([meta], dtype=object))
    # CSV (plaintext)
    arr = np.column_stack([L_inv, E_inv])
    header = "L_inv_MeVee,E_inv_MeV"
    np.savetxt(csv_path, arr, delimiter=",", header=header, comments="", fmt="%.8g")
    # JSON meta (plaintext)
    with open(json_path, "w", encoding="utf-8") as f:
        json.dump(meta, f, indent=2)

Appendix: Full API Surface (Auto-Generated)

The following recursively lists all modules and submodules under the ngimager package.

Top-level package for ng-imager (ngimager).

The public user-facing entry points are:

  • ngimager.pipelines.core.run_pipeline → used by the ng-run CLI
  • ngimager.cli.viz.app → used by the ng-viz CLI

Internally, the package is organized into:

  • physics – hits, events, kinematics, cones, energy strategies
  • geometry – imaging plane and coordinate transforms
  • imaging – SBP and future imaging algorithms
  • io – adapters, list-mode HDF5 storage, LUT loading
  • config – Pydantic config schemas and loading helpers
  • pipelines – high-level orchestration / CLI pipeline entry points
  • vis – visualization utilities
  • tools – helper scripts and LUT generators

cli

Command-line interfaces (CLI) for ng-imager.

Currently exposed via:

  • ng-vizngimager.cli.viz:app
  • ng-runngimager.pipelines.core:app (defined in the pipelines package)

viz

summed
summed(h5_path=typer.Argument(..., help='Path to ng-imager HDF5 file (must contain /images/summed/*).'), species=typer.Option(['n', 'g', 'all'], '--species', '-s', help="Which images to render from /images/summed: any of 'n', 'g', 'all'."), center_on_plane_center=typer.Option(True, '--center-on-plane-center/--no-center-on-plane-center', help='Center axes on the imaging plane center.'), flip_vertical=typer.Option(True, '--flip-vertical/--no-flip-vertical', help='Flip the plotted image vertically (mainly for legacy comparison).'), axis_units=typer.Option('cm', '--axis-units', help="Axis units for plotting: 'cm' or 'mm'."), cmap=typer.Option('cividis', '--cmap', help="Matplotlib colormap to use (e.g. 'cividis', 'viridis')."), filename_pattern=typer.Option('{species}_{stem}.{ext}', '--filename-pattern', help='Python format string for output filenames; may use {stem}, {species}, {ext}.'), fmt=typer.Option(['png'], '--format', '-f', help="Output format(s) to write (e.g. 'png', 'pdf')."), plot_label=typer.Option(None, '--plot-label', help='Override run plot label annotation (defaults to any value stored under /meta.run_plot_label in the HDF5 file).'))

Render one or more /images/summed/{n,g,all} datasets to image files.

Source code in ngimager/cli/viz.py
@app.command("summed")
def summed(
    h5_path: str = typer.Argument(
        ...,
        help="Path to ng-imager HDF5 file (must contain /images/summed/*).",
    ),
    species: List[str] = typer.Option(
        ["n", "g", "all"],
        "--species",
        "-s",
        help="Which images to render from /images/summed: any of 'n', 'g', 'all'.",
    ),
    center_on_plane_center: bool = typer.Option(
        True,
        "--center-on-plane-center/--no-center-on-plane-center",
        help="Center axes on the imaging plane center.",
    ),
    flip_vertical: bool = typer.Option(
        True,
        "--flip-vertical/--no-flip-vertical",
        help="Flip the plotted image vertically (mainly for legacy comparison).",
    ),
    axis_units: str = typer.Option(
        "cm",
        "--axis-units",
        help="Axis units for plotting: 'cm' or 'mm'.",
    ),
    cmap: str = typer.Option(
        "cividis",
        "--cmap",
        help="Matplotlib colormap to use (e.g. 'cividis', 'viridis').",
    ),
    filename_pattern: str = typer.Option(
        "{species}_{stem}.{ext}",
        "--filename-pattern",
        help="Python format string for output filenames; may use {stem}, {species}, {ext}.",
    ),
    fmt: List[str] = typer.Option(
        ["png"],
        "--format",
        "-f",
        help="Output format(s) to write (e.g. 'png', 'pdf').",
    ),
    plot_label: Optional[str] = typer.Option(
        None,
        "--plot-label",
        help=(
            "Override run plot label annotation (defaults to any value stored "
            "under /meta.run_plot_label in the HDF5 file)."
        ),
    ),
):
    """
    Render one or more /images/summed/{n,g,all} datasets to image files.
    """
    out_paths = render_summed_images(
        h5_path,
        species=species,
        filename_pattern=filename_pattern,
        center_on_plane_center=center_on_plane_center,
        flip_vertical=flip_vertical,
        axis_units=axis_units,
        cmap=cmap,
        formats=fmt,
        plot_label=plot_label,
    )
    if not out_paths:
        typer.echo("No images were written (check that /images/summed/* exist).")
    else:
        for p in out_paths:
            typer.echo(str(p))

config

load

load_config
load_config(path)

Load a TOML config file into a Config object.

All paths inside the [io] table are interpreted relative to the location of the TOML file (cfg_dir), except when they are absolute.

In particular: - [io].input_path - [io].output_path - [io].restart_path - [io.extra_text_files].*

CLI overrides (e.g. --input-path/--output-path in ng-run) are applied later and remain relative to the current working directory.

Source code in ngimager/config/load.py
def load_config(path: str | Path) -> Config:
    """
    Load a TOML config file into a Config object.

    All paths inside the [io] table are interpreted relative to the
    *location of the TOML file* (cfg_dir), except when they are absolute.

    In particular:
      - [io].input_path
      - [io].output_path
      - [io].restart_path
      - [io.extra_text_files].*

    CLI overrides (e.g. --input-path/--output-path in ng-run) are applied
    later and remain relative to the current working directory.
    """
    p = Path(path)
    cfg_dir = p.resolve().parent
    data = tomllib.loads(p.read_text())
    data = _resolve_io_paths(data, cfg_dir)
    return Config(**data)
snapshot_config_toml
snapshot_config_toml(path)

Return the raw TOML text for embedding in HDF5 metadata.

Source code in ngimager/config/load.py
def snapshot_config_toml(path: str | Path) -> str:
    """Return the raw TOML text for embedding in HDF5 metadata."""
    return Path(path).read_text()

materials

MaterialResolver dataclass
MaterialResolver(det_to_material, default_material='UNK')
from_env_or_defaults classmethod
from_env_or_defaults()

Placeholder: later load from RunCfg/IOCfg (TOML) if available. For now returns empty mapping → 'UNK'.

Source code in ngimager/config/materials.py
@classmethod
def from_env_or_defaults(cls):
    """
    Placeholder: later load from RunCfg/IOCfg (TOML) if available.
    For now returns empty mapping → 'UNK'.
    """
    return cls.from_mapping({})

schemas

ConeSpeciesOverrides

Bases: BaseModel

Species-specific overrides for cone-level filters.

ConesFiltersCfg

Bases: BaseModel

Cone-level filters with universal defaults plus neutron/gamma overrides.

TOML:

[filters.cones] max_delta_theta_deg = 5.0

[filters.cones.neutron] max_delta_theta_deg = 3.0

[filters.cones.gamma] max_delta_theta_deg = 8.0

Config

Bases: BaseModel

Top-level TOML configuration.

DetectorFrameGeometry

Bases: BaseModel

A simple rigid transform that maps detector-local coordinates to world coordinates:

p_world = R_xyz(rotation_deg) @ p_local + origin_cm

where rotation_deg = [rx, ry, rz] are Euler angles in degrees, applied in the fixed order Rx → Ry → Rz.

DetectorsCfg

Bases: BaseModel

Mapping from detector IDs/regions to materials and optional geometry.

TOML:

[detectors] default_material = "OGS"

[detectors.material_map] 200 = "OGS" 210 = "M600" ...

Optional global detector-frame → world-frame transform:

[detectors.geometry.frame] origin_cm = [0.0, 0.0, 0.0] rotation_deg = [0.0, 0.0, 0.0]

OPTIONAL (stub for future expansion): per-detector transforms

[[detectors.geometry.detectors]] id = 0 origin_cm = [0.0, 0.0, 0.0] rotation_deg = [0.0, 0.0, 0.0]

DetectorsGeometryCfg

Bases: BaseModel

Global detector-array geometry.

  • 'frame' describes the overall detector coordinate frame relative to world coordinates.
  • 'detectors' is an optional list for fine-grained per-detector transforms (currently unused, but reserved for future support).
EnergyCfg

Bases: BaseModel

Energy strategy configuration.

strategy: "ELUT" – invert light via E(L) LUTs (per-material, per-species) "ToF" – simple ToF-based estimate (placeholder) "FixedEn" – fixed incident neutron energy (e.g. 14.1 MeV source) "Edep" – direct deposited energy (PHITS-style adapters)

EventSpeciesOverrides

Bases: BaseModel

Species-specific overrides for event-level filters.

All fields are optional; when None, the universal [filters.events] value is used for ToF, and L-thresholds default to "no extra cut".

EventsFiltersCfg

Bases: BaseModel

Event-level filters with universal defaults plus neutron/gamma overrides.

TOML:

[filters.events] tof_window_ns = [0.0, 30.0]

[filters.events.neutron] tof_window_ns = [0.0, 30.0] min_L1_MeVee = 0.0 min_L2_MeVee = 0.0

[filters.events.gamma] tof_window_ns = [0.0, 30.0] min_L_any_MeVee = 0.0

FastCfg

Bases: BaseModel

Fast-mode override knobs.

Applied only when [run].fast = true; otherwise ignored.

FiltersCfg

Bases: BaseModel

Top-level filter configuration, split into hits / events / cones.

HitSpeciesOverrides

Bases: BaseModel

Species-specific overrides for hit-level filters.

All fields are optional; when None, the universal [filters.hits] value is used.

HitsFiltersCfg

Bases: BaseModel

Hit-level filters with universal defaults plus neutron/gamma overrides.

TOML:

[filters.hits] min_light_MeVee = 50.0 max_light_MeVee = 1.0e6 psd_min = 0.0 psd_max = 1.0 bars_include = [] bars_exclude = [] materials_include = [] materials_exclude = []

[filters.hits.neutron] min_light_MeVee = 100.0 # optional override; others fall back to [filters.hits] psd_min = 0.2 # optional override; if omitted, uses [filters.hits] psd_max = 0.6

[filters.hits.gamma] # optional overrides...

IOCfg

Bases: BaseModel

I/O paths and high-level source description.

TOML:

[io] input_path = "..." input_format = "phits_usrdef" # "phits_usrdef" | "root_novo_ddaq" | "hdf5_ngimager" output_path = "..."

PerDetectorGeometry

Bases: BaseModel

OPTIONAL (stub for future expansion).

Describes an individual detector tile or module's placement within the detector-frame coordinate system. For now, ng-imager does not apply per-detector transforms, but the schema entry is accepted and stored for future expansion.

PipelineCfg

Bases: BaseModel

Controls how far through the pipeline we run.

until = "hits" | "events" | "cones" | "image"

ProjectionAxisMetricsCfg

Bases: BaseModel

Per-axis projection metrics controls.

These are applied separately for the u and v axes.

ProjectionMetricsCfg

Bases: BaseModel

Controls whether projection metrics are computed and written to HDF5.

ProjectionPlotCfg

Bases: BaseModel

Controls how projection metrics are visualized in vis/hdf.py.

(Plotting code will read these; they do not affect HDF5 contents.)

TOML example:

[vis.projections.plot]
# Basic visual toggles
show_peak_markers = true   # vertical / horizontal lines at peak_pos_cm
show_edge_markers = true   # lines at edge_low_cm / edge_high_cm
show_centroid_2d  = false  # crosshair at 2D centroid (if computed)

# Which metrics to use when both "all" and ROI curves exist
metrics_source    = "auto"     # "auto" | "all" | "roi" | "both"
curve_mode        = "all+roi"  # "all+roi" | "all_only" | "roi_only"

# Numeric summaries on the figure
# - "off"     : no text annotations
# - "compact" : minimal one-line summary per axis
# - "full"    : include more fields (e.g. edges) when available
annotate_summary  = "compact"

# Optional extra panel with a table of metrics (future)
show_metrics_panel = false
RunCfg

Bases: BaseModel

Global run controls that apply to the entire pipeline.

source_type: "cf252" | "dt" | "proton_center" | "phits" fast: Enable fast-mode overrides (see FastCfg and [fast] section). list: Enable list-mode imaging output (/lm/cone_pixel_indices, etc.).

VisCfg

Bases: BaseModel

Visualization configuration.

These options control automatic image export from the pipeline and provide defaults for the standalone ng-viz CLI.

VisProjectionsCfg

Bases: BaseModel

Configuration for 1D projections and their analysis/visualization.

TOML:

[vis.projections]
enabled      = true
roi_u_min_cm = -5.0
roi_u_max_cm =  5.0
roi_v_min_cm = -5.0
roi_v_max_cm =  5.0

[vis.projections.metrics]
enabled = true

[vis.projections.metrics.u]
compute_summary = true
compute_peak    = true
compute_edges   = false
edge_low_frac   = 0.2
edge_high_frac  = 0.8
min_counts      = 100.0

[vis.projections.metrics.v]
compute_summary = true
compute_peak    = true
compute_edges   = true

[vis.projections.plot]
show_peak_markers = true
show_edge_markers = true
show_centroid_2d  = false

metrics_source    = "auto"     # "auto" | "all" | "roi" | "both"
curve_mode        = "all+roi"  # "all+roi" | "all_only" | "roi_only"

annotate_summary  = "compact"  # "off" | "compact" | "full"
show_metrics_panel = false
roi_bounds_cm
roi_bounds_cm()

Return (u_min, u_max, v_min, v_max) in cm if a full ROI is defined, otherwise None.

Source code in ngimager/config/schemas.py
def roi_bounds_cm(self) -> Optional[tuple[float, float, float, float]]:
    """
    Return (u_min, u_max, v_min, v_max) in cm if a full ROI is defined,
    otherwise None.
    """
    if (
        self.roi_u_min_cm is None
        or self.roi_u_max_cm is None
        or self.roi_v_min_cm is None
        or self.roi_v_max_cm is None
    ):
        return None
    return (
        float(self.roi_u_min_cm),
        float(self.roi_u_max_cm),
        float(self.roi_v_min_cm),
        float(self.roi_v_max_cm),
    )

filters

cone_filters

passes_delta_theta_cut
passes_delta_theta_cut(cone, species, plane, prior, filters, counters, *, incident_energy_MeV=None)

Cone-level filters:

  1. Δθ = |φ − θ|, where:

    • φ is the angle between the cone axis and the direction from apex to the prior target (or plane center if prior is None),
    • θ is the cone opening half-angle.

    Uses _score_cone_against_prior (shared with neutron p/C selection and gamma permutation selection).

  2. max_incident_energy_MeV:

    • For neutrons, En is computed once in physics/kinematics and passed in from the cone builder.
    • For gammas, Eg is likewise computed in the gamma cone builder.

Both cuts are optional; if a limit is not configured (or we don't have an incident_energy_MeV), that cut is skipped.

Returns True if the cone is accepted, False if rejected.

Counters (all optional, keyed by species when applicable)
  • "cones_checked_delta_theta"
  • "cones_checked_delta_theta_n"
  • "cones_checked_delta_theta_g"
  • "cones_rejected_delta_theta"
  • "cones_rejected_delta_theta_n"
  • "cones_rejected_delta_theta_g"

  • "cones_checked_incident_energy"

  • "cones_checked_incident_energy_n"
  • "cones_checked_incident_energy_g"
  • "cones_rejected_incident_energy"
  • "cones_rejected_incident_energy_n"
  • "cones_rejected_incident_energy_g"
Source code in ngimager/filters/cone_filters.py
def passes_delta_theta_cut(
    cone: Cone,
    species: str,
    plane: Plane,
    prior: Optional[Prior],
    filters: ConesFiltersCfg,
    counters: Dict[str, int],
    *,
    incident_energy_MeV: Optional[float] = None,
) -> bool:
    """
    Cone-level filters:

      1. Δθ = |φ − θ|, where:
         - φ is the angle between the cone axis and the direction from apex to
           the prior target (or plane center if prior is None),
         - θ is the cone opening half-angle.

         Uses _score_cone_against_prior (shared with neutron p/C selection
         and gamma permutation selection).

      2. max_incident_energy_MeV:
         - For neutrons, En is computed once in physics/kinematics and
           passed in from the cone builder.
         - For gammas, Eg is likewise computed in the gamma cone builder.

    Both cuts are optional; if a limit is not configured (or we don't have
    an incident_energy_MeV), that cut is skipped.

    Returns True if the cone is accepted, False if rejected.

    Counters (all optional, keyed by species when applicable)
    ---------------------------------------------------------
      - "cones_checked_delta_theta"
      - "cones_checked_delta_theta_n"
      - "cones_checked_delta_theta_g"
      - "cones_rejected_delta_theta"
      - "cones_rejected_delta_theta_n"
      - "cones_rejected_delta_theta_g"

      - "cones_checked_incident_energy"
      - "cones_checked_incident_energy_n"
      - "cones_checked_incident_energy_g"
      - "cones_rejected_incident_energy"
      - "cones_rejected_incident_energy_n"
      - "cones_rejected_incident_energy_g"
    """
    s = (species or "").lower()

    # ---- Δθ cut ---------------------------------------------------------
    limit_dtheta = _delta_theta_limit_rad(filters, species)

    # We always count that Δθ was "checked", even if no limit is set.
    _inc(counters, "cones_checked_delta_theta")
    if s.startswith("n"):
        _inc(counters, "cones_checked_delta_theta_n")
    elif s.startswith("g"):
        _inc(counters, "cones_checked_delta_theta_g")

    if limit_dtheta is not None:
        delta = _score_cone_against_prior(cone, plane, prior)
        if delta is not None and float(delta) > float(limit_dtheta):
            _inc(counters, "cones_rejected_delta_theta")
            if s.startswith("n"):
                _inc(counters, "cones_rejected_delta_theta_n")
            elif s.startswith("g"):
                _inc(counters, "cones_rejected_delta_theta_g")
            return False
        # If delta is None (degenerate prior geometry), we treat it as
        # "no Δθ cut applied" and continue.

    # ---- max incident energy cut ---------------------------------------
    limit_En = _incident_energy_limit_MeV(filters, species)

    # Count that we evaluated the incident-energy filter.
    _inc(counters, "cones_checked_incident_energy")
    if s.startswith("n"):
        _inc(counters, "cones_checked_incident_energy_n")
    elif s.startswith("g"):
        _inc(counters, "cones_checked_incident_energy_g")

    # No configured energy limit or no value to compare → accept w.r.t. this cut.
    if limit_En is None or incident_energy_MeV is None:
        return True

    if float(incident_energy_MeV) <= float(limit_En):
        return True

    # Rejected by incident-energy cut
    _inc(counters, "cones_rejected_incident_energy")
    if s.startswith("n"):
        _inc(counters, "cones_rejected_incident_energy_n")
    elif s.startswith("g"):
        _inc(counters, "cones_rejected_incident_energy_g")
    return False

event_filters

apply_event_filters
apply_event_filters(events, cfg, counters)

Apply event-level filters, currently:

  • species-dependent ToF windows (Δt12 between first two hits)
  • neutron-specific min L thresholds for first and second scatters
  • gamma-specific min L threshold applied to all three scatters

Events that fail are removed and counted in events_rejected_filters and more specific counters:

  • events_rejected_tof_window{,_n,_g}
  • events_rejected_L1_min_n
  • events_rejected_L2_min_n
  • events_rejected_L_any_min_g
Source code in ngimager/filters/event_filters.py
def apply_event_filters(
    events: Sequence[Event],
    cfg: EventsFiltersCfg,
    counters: Dict[str, int],
) -> list[Event]:
    """
    Apply event-level filters, currently:

      - species-dependent ToF windows (Δt12 between first two hits)
      - neutron-specific min L thresholds for first and second scatters
      - gamma-specific min L threshold applied to all three scatters

    Events that fail are removed and counted in events_rejected_filters and
    more specific counters:

      - events_rejected_tof_window{,_n,_g}
      - events_rejected_L1_min_n
      - events_rejected_L2_min_n
      - events_rejected_L_any_min_g
    """
    kept: list[Event] = []

    for ev in events:
        if isinstance(ev, NeutronEvent):
            species = "n"
        elif isinstance(ev, GammaEvent):
            species = "g"
        else:
            # Unknown species → keep for now, no event-level cuts.
            kept.append(ev)
            continue

        _inc(counters, "events_total_for_filters", 1)
        if species == "n":
            _inc(counters, "events_total_for_filters_n", 1)
        elif species == "g":
            _inc(counters, "events_total_for_filters_g", 1)

        # We assume typed events always have at least two hits.
        h1 = ev.h1
        h2 = ev.h2
        dt = float(h2.t_ns - h1.t_ns)

        (tof_window, min_L1, min_L2, min_L_any) = _resolve_event_params(cfg, species)
        tmin, tmax = tof_window

        # --- ToF window cut ---
        if (dt < tmin) or (dt > tmax):
            _inc(counters, "events_rejected_filters", 1)
            _inc(counters, "events_rejected_tof_window", 1)
            if species == "n":
                _inc(counters, "events_rejected_tof_window_n", 1)
            elif species == "g":
                _inc(counters, "events_rejected_tof_window_g", 1)
            continue

        # --- Neutron-specific L thresholds (L1/L2) ---
        if species == "n":
            L1 = float(getattr(h1, "L", 0.0))
            L2 = float(getattr(h2, "L", 0.0))

            if (min_L1 is not None) and (L1 < min_L1):
                _inc(counters, "events_rejected_filters", 1)
                _inc(counters, "events_rejected_L1_min_n", 1)
                continue

            if (min_L2 is not None) and (L2 < min_L2):
                _inc(counters, "events_rejected_filters", 1)
                _inc(counters, "events_rejected_L2_min_n", 1)
                continue

        # --- Gamma-specific L threshold (any scatter) ---
        if species == "g" and (min_L_any is not None):
            Ls = [
                float(getattr(ev.h1, "L", 0.0)),
                float(getattr(ev.h2, "L", 0.0)),
                float(getattr(ev.h3, "L", 0.0)),
            ]
            if any(L < min_L_any for L in Ls):
                _inc(counters, "events_rejected_filters", 1)
                _inc(counters, "events_rejected_L_any_min_g", 1)
                continue

        kept.append(ev)

    _inc(counters, "events_after_filters", len(kept))
    return kept

hit_filters

apply_hit_filters
apply_hit_filters(hits, cfg, counters, *, particle_type=None)

Apply universal + species-specific hit-level cuts.

Parameters:

Name Type Description Default
hits Iterable[Hit]

Input Hit objects for a single raw event.

required
cfg HitsFiltersCfg

[filters.hits] configuration (including .neutron/.gamma overrides).

required
counters Dict[str, int]

Shared counters dict to be updated in-place.

required
particle_type Optional[str]

Optional event-level 'n' or 'g'. Used as a fallback species tag when Hit.type is missing.

None
Source code in ngimager/filters/hit_filters.py
def apply_hit_filters(
    hits: Iterable[Hit],
    cfg: HitsFiltersCfg,
    counters: Dict[str, int],
    *,
    particle_type: Optional[str] = None,
) -> List[Hit]:
    """
    Apply universal + species-specific hit-level cuts.

    Parameters
    ----------
    hits :
        Input Hit objects for a single raw event.
    cfg :
        [filters.hits] configuration (including .neutron/.gamma overrides).
    counters :
        Shared counters dict to be updated in-place.
    particle_type :
        Optional event-level 'n' or 'g'. Used as a fallback species tag
        when Hit.type is missing.
    """
    hits_list = list(hits)

    # Totals before any cuts (event-level accounting)
    _inc(counters, "hits_total", len(hits_list))
    if particle_type == "n":
        _inc(counters, "hits_total_n", len(hits_list))
    elif particle_type == "g":
        _inc(counters, "hits_total_g", len(hits_list))

    # Pre-resolve configs for species and unknown
    global_cfg = _resolve_hits_cfg_for_species(cfg, None)
    n_cfg = _resolve_hits_cfg_for_species(cfg, "n")
    g_cfg = _resolve_hits_cfg_for_species(cfg, "g")

    def cfg_for_hit(h: Hit) -> Dict[str, object]:
        # Prefer per-hit type (from adapters), fall back to event-level particle_type.
        h_type = getattr(h, "type", None)
        s: Optional[str]
        if isinstance(h_type, str) and h_type.lower() in ("n", "g"):
            s = h_type.lower()
        else:
            pt = (particle_type or "").lower()
            s = pt if pt in ("n", "g") else None

        if s == "n":
            return n_cfg
        if s == "g":
            return g_cfg
        return global_cfg

    def suffix_for_hit(h: Hit) -> str:
        h_type = getattr(h, "type", None)
        if isinstance(h_type, str):
            t = h_type.lower()
            if t == "n":
                return "_n"
            if t == "g":
                return "_g"
        # fall back to event-level type
        pt = (particle_type or "").lower()
        if pt == "n":
            return "_n"
        if pt == "g":
            return "_g"
        return ""

    kept: List[Hit] = []

    for h in hits_list:
        eff = cfg_for_hit(h)
        min_L = eff["min_light_MeVee"]
        max_L = eff["max_light_MeVee"]
        psd_min = eff["psd_min"]
        psd_max = eff["psd_max"]
        bars_inc = eff["bars_include"]
        bars_exc = eff["bars_exclude"]
        mats_inc = eff["materials_include"]
        mats_exc = eff["materials_exclude"]

        suffix = suffix_for_hit(h)

        L = float(getattr(h, "L", 0.0))

        # Light/energy threshold cuts
        if (L < min_L) or (L > max_L):
            _inc(counters, "hits_rejected_threshold", 1)
            if suffix:
                _inc(counters, f"hits_rejected_threshold{suffix}", 1)
            continue

        # PSD window cuts (if configured and if the hit carries a PSD value)
        # PSD is expected to be stored in h.extras["psd"] by the adapter.
        if (psd_min is not None) or (psd_max is not None):
            psd_val = None
            extras = getattr(h, "extras", None)
            if extras is not None:
                psd_val = extras.get("psd", None)

            if psd_val is not None:
                too_low = (psd_min is not None) and (psd_val < psd_min)
                too_high = (psd_max is not None) and (psd_val > psd_max)
                if too_low or too_high:
                    _inc(counters, "hits_rejected_psd", 1)
                    if suffix:
                        _inc(counters, f"hits_rejected_psd{suffix}", 1)
                    continue

        # Bar exclude list (if provided)
        if bars_exc and (h.det_id in bars_exc):
            _inc(counters, "hits_rejected_bar_exclude", 1)
            if suffix:
                _inc(counters, f"hits_rejected_bar_exclude{suffix}", 1)
            continue

        # Bar whitelist (if provided)
        if bars_inc and (h.det_id not in bars_inc):
            _inc(counters, "hits_rejected_bar_include", 1)
            if suffix:
                _inc(counters, f"hits_rejected_bar_include{suffix}", 1)
            continue

        # Material exclude list (if provided)
        if mats_exc and (h.material in mats_exc):
            _inc(counters, "hits_rejected_material_exclude", 1)
            if suffix:
                _inc(counters, f"hits_rejected_material_exclude{suffix}", 1)
            continue

        # Material whitelist (if provided)
        if mats_inc and (h.material not in mats_inc):
            _inc(counters, "hits_rejected_material_include", 1)
            if suffix:
                _inc(counters, f"hits_rejected_material_include{suffix}", 1)
            continue

        kept.append(h)

    # Totals after filters
    _inc(counters, "hits_after_filters", len(kept))
    if particle_type == "n":
        _inc(counters, "hits_after_filters_n", len(kept))
    elif particle_type == "g":
        _inc(counters, "hits_after_filters_g", len(kept))

    return kept
is_reconstructable
is_reconstructable(hits, cfg, counters, *, event_type=None)

Early decision: does this raw event still have enough hits to ever form a reconstructable cone?

For now: - neutron (event_type 'n'): require ≥ 2 hits - gamma (event_type 'g'): require ≥ 3 hits - unknown: require ≥ 2 hits (conservative default)

If not reconstructable, the appropriate raw_events_rejected_unreconstructable counters are incremented.

Source code in ngimager/filters/hit_filters.py
def is_reconstructable(
    hits: Iterable[Hit],
    cfg,  # reserved for future, e.g. more complex criteria
    counters: Dict[str, int],
    *,
    event_type: Optional[str] = None,
) -> bool:
    """
    Early decision: does this raw event still have enough hits to ever form
    a reconstructable cone?

    For now:
        - neutron (event_type 'n'): require ≥ 2 hits
        - gamma   (event_type 'g'): require ≥ 3 hits
        - unknown: require ≥ 2 hits (conservative default)

    If not reconstructable, the appropriate raw_events_rejected_unreconstructable
    counters are incremented.
    """
    hits_list = list(hits)
    n_hits = len(hits_list)

    if event_type == "n":
        needed = 2
        suffix = "_n"
    elif event_type == "g":
        needed = 3
        suffix = "_g"
    else:
        needed = 2
        suffix = ""

    if n_hits < needed:
        _inc(counters, "raw_events_rejected_unreconstructable", 1)
        if suffix:
            _inc(counters, f"raw_events_rejected_unreconstructable{suffix}", 1)
        return False

    return True

shapers

ShapedEvent dataclass
ShapedEvent(species, hits, meta=dict())

Minimal shaped event used between hit-level filtering and typed events.

species: "n" or "g" hits: [Hit,...] of correct multiplicity (2 for n, 3 for g) meta: dict of event-level bookkeeping (iomp/batch/history/etc.)

shape_events_for_cones
shape_events_for_cones(raw_events, cfg, counters=None)

Shape raw coincidence windows / events (variable multiplicity, mixed species) into candidate fixed-multiplicity ShapedEvents (2-hit neutron and 3-hit gamma events) suitable for cone building

Inputs: raw_events: iterable of dicts, each with at least a 'hits' key. 'hits' must be a sequence of canonical physics.hits.Hit objects. (Adapters and/or canonicalization are responsible for constructing Hits.) cfg: ShapeConfig controlling policies and caps.

Outputs: shaped: list of shaped event dicts with: - 'event_type': 'n' or 'g' - 'hits': list of hits (same objects as input) - plus any original metadata keys preserved diag: ShapeDiagnostics with counters and reasons.

Source code in ngimager/filters/shapers.py
def shape_events_for_cones(
    raw_events: Iterable[Dict[str, Any]],
    cfg: ShapeConfig,
    counters: Dict[str, int] | None = None,
) -> Tuple[List[ShapedEvent], ShapeDiagnostics]:
    """
    Shape raw coincidence windows / events (variable multiplicity, mixed species)
    into candidate fixed-multiplicity ShapedEvents (2-hit neutron and 3-hit gamma events) suitable for cone building

    Inputs:
      raw_events: iterable of dicts, each with at least a 'hits' key.
                  'hits' must be a sequence of canonical physics.hits.Hit objects.
                  (Adapters and/or canonicalization are responsible for constructing Hits.)
      cfg: ShapeConfig controlling policies and caps.

    Outputs:
      shaped: list of shaped event dicts with:
                - 'event_type': 'n' or 'g'
                - 'hits': list of hits (same objects as input)
                - plus any original metadata keys preserved
      diag:   ShapeDiagnostics with counters and reasons.
    """
    diag = ShapeDiagnostics()
    shaped: List[ShapedEvent] = []

    for ev in raw_events:
        diag.total_events += 1
        hits = list(ev.get("hits", []))
        if not hits:
            diag.inc("no_hits")
            continue

        # Partition by species using Hit.type when available
        n_hits: List[Hit] = []
        g_hits: List[Hit] = []
        for h in hits:
            sp = _hit_species(h)
            if sp == "n":
                n_hits.append(h)
            elif sp == "g":
                g_hits.append(h)
            else:
                # Unknown species; currently ignore for shaping
                diag.inc("unknown_species_hit")

        meta = {k: v for k, v in ev.items() if k not in ("hits", "event_type")}

        if n_hits:
            diag.neutron_in += 1
            selected_n = _select_hits(
                n_hits, k=2,
                policy=cfg.neutron_policy,
                max_combinations=cfg.max_combinations,
                diag=diag,
                species="n",
            )
            if selected_n:
                for hs in selected_n:
                    shaped.append(ShapedEvent(species="n", hits=hs, meta=dict(meta)))
                    diag.shaped_neutron += 1

        if g_hits:
            diag.gamma_in += 1
            selected_g = _select_hits(
                g_hits, k=3,
                policy=cfg.gamma_policy,
                max_combinations=cfg.max_combinations,
                diag=diag,
                species="g",
            )
            if selected_g:
                for hs in selected_g:
                    shaped.append(ShapedEvent(species="g", hits=hs, meta=dict(meta)))
                    diag.shaped_gamma += 1

        if not n_hits and not g_hits:
            diag.inc("no_usable_species")

    # Hook into shared counters if provided
    if counters is not None:
        total_shaped = diag.shaped_neutron + diag.shaped_gamma
        counters["shaped_events_total"] = counters.get("shaped_events_total", 0) + total_shaped
        counters["shaped_events_n"] = counters.get("shaped_events_n", 0) + diag.shaped_neutron
        counters["shaped_events_g"] = counters.get("shaped_events_g", 0) + diag.shaped_gamma

    return shaped, diag

to_typed_events

shaped_to_typed_events
shaped_to_typed_events(shaped, *, order_time=True)

Convert shaped events (from shapers.shape_events_for_cones) into typed physics events (NeutronEvent / GammaEvent).

Assumptions: - shaped[i].species is "n" or "g" - shaped[i].hits has exactly 2 hits for "n", 3 hits for "g" - hits are already canonical physics.hits.Hit objects

Source code in ngimager/filters/to_typed_events.py
def shaped_to_typed_events(
    shaped: Sequence[ShapedEvent],
    *,
    order_time: bool = True,
) -> List[Event]:
    """
    Convert shaped events (from shapers.shape_events_for_cones) into
    typed physics events (NeutronEvent / GammaEvent).

    Assumptions:
      - shaped[i].species is "n" or "g"
      - shaped[i].hits has exactly 2 hits for "n", 3 hits for "g"
      - hits are already canonical physics.hits.Hit objects
    """
    typed: List[Event] = []

    for ev in shaped:
        hh = list(ev.hits)
        meta = dict(ev.meta)

        if ev.species == "n":
            if len(hh) != 2:
                # Defensive: inconsistent shaped neutron multiplicity
                # We silently skip for now; could log into counters later.
                continue
            obj = NeutronEvent(h1=hh[0], h2=hh[1], meta=meta)
            typed.append(obj.ordered() if order_time else obj)

        elif ev.species == "g":
            if len(hh) != 3:
                # Defensive: inconsistent shaped gamma multiplicity
                continue
            obj = GammaEvent(h1=hh[0], h2=hh[1], h3=hh[2], meta=meta)
            typed.append(obj.ordered() if order_time else obj)

        else:
            # Unknown species are ignored for cone building; could increment
            # events_rejected_* counters in the caller.
            continue

    return typed

geometry

plane

Plane dataclass
Plane(P0, n, eu, ev, u_min, u_max, du, v_min, v_max, dv)
center
center()

Return the world-space coordinates of the geometric center of the imaging plane grid.

This is defined as the point corresponding to the midpoint in (u, v) coordinates:

u_c = 0.5 * (u_min + u_max)
v_c = 0.5 * (v_min + v_max)

and mapped back to 3D via plane_to_world.

Source code in ngimager/geometry/plane.py
def center(self) -> np.ndarray:
    """
    Return the world-space coordinates of the geometric center of the
    imaging plane grid.

    This is defined as the point corresponding to the midpoint in (u, v)
    coordinates:

        u_c = 0.5 * (u_min + u_max)
        v_c = 0.5 * (v_min + v_max)

    and mapped back to 3D via plane_to_world.
    """
    u_c = 0.5 * (self.u_min + self.u_max)
    v_c = 0.5 * (self.v_min + self.v_max)
    return self.plane_to_world(u_c, v_c)

transforms

apply_rigid_transform
apply_rigid_transform(points_cm, origin_cm, rotation_deg)

Apply a rigid transform (rotation + translation) to 3D points.

Parameters:

Name Type Description Default
points_cm ndarray | Sequence[Sequence[float]]

Array-like of shape (..., 3). Interpreted as row vectors in cm.

required
origin_cm Sequence[float]

Translation vector (x, y, z) in cm, giving the detector origin in the world frame.

required
rotation_deg Sequence[float]

Euler angles (rx, ry, rz) in degrees, applied as Rx → Ry → Rz.

required

Returns:

Type Description
ndarray

Transformed points with the same shape as points_cm.

Notes

Mathematically:

p_world = R_xyz(rotation_deg) @ p_local + origin_cm

With row-vector data, we implement this as:

p_world_row = p_local_row @ R_xyz(rotation_deg).T + origin_cm
Source code in ngimager/geometry/transforms.py
def apply_rigid_transform(
    points_cm: np.ndarray | Sequence[Sequence[float]],
    origin_cm: Sequence[float],
    rotation_deg: Sequence[float],
) -> np.ndarray:
    """
    Apply a rigid transform (rotation + translation) to 3D points.

    Parameters
    ----------
    points_cm
        Array-like of shape (..., 3). Interpreted as *row* vectors in cm.
    origin_cm
        Translation vector (x, y, z) in cm, giving the detector origin
        in the world frame.
    rotation_deg
        Euler angles (rx, ry, rz) in degrees, applied as Rx → Ry → Rz.

    Returns
    -------
    np.ndarray
        Transformed points with the same shape as `points_cm`.

    Notes
    -----
    Mathematically:

        p_world = R_xyz(rotation_deg) @ p_local + origin_cm

    With row-vector data, we implement this as:

        p_world_row = p_local_row @ R_xyz(rotation_deg).T + origin_cm
    """
    pts = np.asarray(points_cm, dtype=float)
    if pts.shape[-1] != 3:
        raise ValueError(
            f"`points_cm` must have shape (..., 3); got shape {pts.shape!r}"
        )

    R = euler_xyz_deg_to_matrix(rotation_deg)
    t = _as_vec3(origin_cm)

    pts_flat = pts.reshape(-1, 3)
    out_flat = pts_flat @ R.T + t  # row-vector convention
    return out_flat.reshape(pts.shape)
euler_xyz_deg_to_matrix
euler_xyz_deg_to_matrix(rotation_deg)

Build a 3x3 rotation matrix from XYZ Euler angles in degrees.

Convention (matches what we discussed):

  • rotation_deg = (rx, ry, rz) in degrees.
  • Rotations applied in fixed order Rx → Ry → Rz.
  • Mathematical column-vector form:
    v_world = R_xyz @ v_local
    

For row-vector data (shape (..., 3)), right-multiply by R.T instead.

Source code in ngimager/geometry/transforms.py
def euler_xyz_deg_to_matrix(rotation_deg: Sequence[float]) -> np.ndarray:
    """
    Build a 3x3 rotation matrix from XYZ Euler angles in degrees.

    Convention (matches what we discussed):

      - `rotation_deg = (rx, ry, rz)` in degrees.
      - Rotations applied in fixed order Rx → Ry → Rz.
      - Mathematical column-vector form:

            v_world = R_xyz @ v_local

    For row-vector data (shape (..., 3)), right-multiply by R.T instead.
    """
    rx_deg, ry_deg, rz_deg = _as_vec3(rotation_deg)
    rx = np.deg2rad(rx_deg)
    ry = np.deg2rad(ry_deg)
    rz = np.deg2rad(rz_deg)

    cx, sx = np.cos(rx), np.sin(rx)
    cy, sy = np.cos(ry), np.sin(ry)
    cz, sz = np.cos(rz), np.sin(rz)

    # Elementary rotations (active, right-handed)
    Rx = np.array(
        [
            [1.0, 0.0, 0.0],
            [0.0, cx, -sx],
            [0.0, sx, cx],
        ],
        dtype=np.float64,
    )
    Ry = np.array(
        [
            [cy, 0.0, sy],
            [0.0, 1.0, 0.0],
            [-sy, 0.0, cy],
        ],
        dtype=np.float64,
    )
    Rz = np.array(
        [
            [cz, -sz, 0.0],
            [sz, cz, 0.0],
            [0.0, 0.0, 1.0],
        ],
        dtype=np.float64,
    )

    # Apply Rx then Ry then Rz:
    #   v_world = Rz @ (Ry @ (Rx @ v_local))
    return (Rz @ Ry) @ Rx
is_identity_transform
is_identity_transform(origin_cm, rotation_deg, tol=1e-09)

Return True if the transform is (numerically) the identity.

This lets the pipeline skip transform work when both the origin and all Euler angles are effectively zero.

Source code in ngimager/geometry/transforms.py
def is_identity_transform(
    origin_cm: Sequence[float],
    rotation_deg: Sequence[float],
    tol: float = 1e-9,
) -> bool:
    """
    Return True if the transform is (numerically) the identity.

    This lets the pipeline skip transform work when both the origin and
    all Euler angles are effectively zero.
    """
    o = _as_vec3(origin_cm)
    r = _as_vec3(rotation_deg)
    return np.allclose(o, 0.0, atol=tol) and np.allclose(r, 0.0, atol=tol)

imaging

projection_metrics

compute_projection_metrics
compute_projection_metrics(u_centers_cm, v_centers_cm, proj_u, proj_v, proj_u_roi, proj_v_roi, cfg)

Compute metrics for u/v projections (all + ROI) given configuration.

Returns a nested dict:

{
  "u": {
    "all": {...metrics...},
    "roi": {...metrics...}  # omitted if no ROI
  },
  "v": {
    "all": {...},
    "roi": {...}
  }
}
Source code in ngimager/imaging/projection_metrics.py
def compute_projection_metrics(
    u_centers_cm: np.ndarray,
    v_centers_cm: np.ndarray,
    proj_u: np.ndarray,
    proj_v: np.ndarray,
    proj_u_roi: Optional[np.ndarray],
    proj_v_roi: Optional[np.ndarray],
    cfg: Optional[ProjectionMetricsCfg],
) -> Dict[str, Dict[str, Dict[str, float | bool]]]:
    """
    Compute metrics for u/v projections (all + ROI) given configuration.

    Returns a nested dict:

        {
          "u": {
            "all": {...metrics...},
            "roi": {...metrics...}  # omitted if no ROI
          },
          "v": {
            "all": {...},
            "roi": {...}
          }
        }
    """
    if cfg is None or not cfg.enabled:
        return {}

    u_centers_cm = np.asarray(u_centers_cm, dtype=float)
    v_centers_cm = np.asarray(v_centers_cm, dtype=float)
    proj_u = np.asarray(proj_u, dtype=float)
    proj_v = np.asarray(proj_v, dtype=float)

    result: Dict[str, Dict[str, Dict[str, float | bool]]] = {
        "u": {},
        "v": {},
    }

    # u-axis
    u_all = _metrics_for_curve(u_centers_cm, proj_u, cfg.u)
    if u_all:
        result["u"]["all"] = u_all

    if proj_u_roi is not None:
        u_roi = _metrics_for_curve(u_centers_cm, proj_u_roi, cfg.u)
        if u_roi:
            result["u"]["roi"] = u_roi

    # v-axis
    v_all = _metrics_for_curve(v_centers_cm, proj_v, cfg.v)
    if v_all:
        result["v"]["all"] = v_all

    if proj_v_roi is not None:
        v_roi = _metrics_for_curve(v_centers_cm, proj_v_roi, cfg.v)
        if v_roi:
            result["v"]["roi"] = v_roi

    return result

sbp

cone_to_indices
cone_to_indices(c, plane, engine='poly', n_poly=360, use_jit=False)

Unified entry point: cone → flat pixel indices.

engine = "scan": Use matrix-math scanning across rows/columns (continuous arcs). engine = "poly": Use ellipse parameterization when possible, falling back to general ray sampling for non-elliptic conics.

use_jit: When True and numba is available: - "scan" engine uses a JIT-compiled inner loop. - "poly" engine uses a JIT-compiled perimeter sampler. Otherwise, pure-Python paths are used.

Source code in ngimager/imaging/sbp.py
def cone_to_indices(
    c: Cone,
    plane: Plane,
    engine: SBPEngine = "poly",
    n_poly: int = 360,
    use_jit: bool = False,
) -> np.ndarray:
    """
    Unified entry point: cone → flat pixel indices.

    engine = "scan":
        Use matrix-math scanning across rows/columns (continuous arcs).
    engine = "poly":
        Use ellipse parameterization when possible, falling back to
        general ray sampling for non-elliptic conics.

    use_jit:
        When True and numba is available:
          - "scan" engine uses a JIT-compiled inner loop.
          - "poly" engine uses a JIT-compiled perimeter sampler.
        Otherwise, pure-Python paths are used.
    """
    M = _cone_matrix(c.dir, c.theta)
    Q = _conic_Q(M, c.apex, plane)

    if engine == "scan":
        if use_jit and _scan_conic_core_numba is not None:
            return _scan_conic_indices_numba(Q, plane)
        else:
            return _scan_conic_indices_python(Q, plane)

    # Default: "poly" perimeter sampling
    el = _ellipse_from_Q(Q)
    if el is None:
        # Fallback: general ray sampling around the cone axis
        return _ray_sample_indices(c.apex, c.dir, c.theta, plane, n_phi=720)

    uv0, a, b, R = el

    if use_jit and _ellipse_poly_numba is not None:
        # Ensure types are friendly to numba
        uv0_arr = np.asarray(uv0, dtype=np.float64)
        R_arr = np.asarray(R, dtype=np.float64)
        pts = _ellipse_poly_numba(float(a), float(b), R_arr, uv0_arr, int(n_poly))
    else:
        pts = _ellipse_poly(uv0, a, b, R, n=n_poly)

    return _pixels_from_poly(pts, plane)
reconstruct_sbp
reconstruct_sbp(cones, plane, list_mode=False, uncertainty_mode='off', workers='auto', chunk_cones='auto', progress=True, n_poly=360, sbp_engine='poly', use_jit=False)

Parallel SBP (analytic conic). If workers==0, runs single-process.

sbp_engine: "poly" – perimeter parametric ellipse (with ray fallback). "scan" – matrix-math scan across pixel-centered lines (continuous arcs).

use_jit: When True and numba is available, use a JIT-compiled inner loop for the "scan" engine to accelerate the row/column solving.

Source code in ngimager/imaging/sbp.py
def reconstruct_sbp(
    cones: Iterable[Cone],
    plane: Plane,
    list_mode: bool = False,
    uncertainty_mode: Literal["off", "thicken", "weighted"] = "off",
    workers: int | str = "auto",
    chunk_cones: int | str = "auto",
    progress: bool = True,
    n_poly: int = 360,
    sbp_engine: SBPEngine = "poly",
    use_jit: bool = False,
) -> ReconResult:
    """
    Parallel SBP (analytic conic). If workers==0, runs single-process.

    sbp_engine:
        "poly" – perimeter parametric ellipse (with ray fallback).
        "scan" – matrix-math scan across pixel-centered lines (continuous arcs).

    use_jit:
        When True and numba is available, use a JIT-compiled inner loop
        for the "scan" engine to accelerate the row/column solving.
    """
    # Normalize inputs
    cones_list = list(cones)
    N = len(cones_list)
    img = np.zeros((plane.nv, plane.nu), dtype=np.uint32)
    flat_len = plane.nv * plane.nu

    if N == 0:
        return ReconResult(img, [] if list_mode else None)

    if workers == "auto":
        workers = max(1, os.cpu_count() or 1)
    elif isinstance(workers, int):
        workers = max(0, workers)
    else:
        raise ValueError("workers must be int or 'auto'")

    # Single-process path (also good for debugging)
    if workers == 0 or N < 1500:
        hit_count = 0
        lm = [] if list_mode else None
        it = tqdm(cones_list, desc="SBP", unit="cone") if progress and tqdm else cones_list
        for c in it:
            idx = cone_to_indices(c, plane, engine=sbp_engine, n_poly=n_poly, use_jit=use_jit)
            if idx.size:
                hit_count += 1
                np.add.at(img.ravel(), idx, 1)
                if lm is not None:
                    lm.append(idx)
        print(f"SBP: {hit_count}/{N} cones intersected the plane")
        return ReconResult(img, lm)

    # Multi-process path
    if chunk_cones == "auto":
        chunk_cones = _auto_chunk_size(N, plane.nu, plane.nv, workers)
    else:
        chunk_cones = int(chunk_cones)

    # Chunk the work
    chunks: List[Sequence[Cone]] = [cones_list[i : i + chunk_cones] for i in range(0, N, chunk_cones)]

    # Progress bar over chunks
    pbar = tqdm(total=len(chunks), desc=f"SBP x{workers}", unit="chunk") if (progress and tqdm) else None

    flat_total = np.zeros(flat_len, dtype=np.uint32)
    lm_all: List[np.ndarray] | None = [] if list_mode else None

    # Use spawn-friendly ProcessPoolExecutor
    with ProcessPoolExecutor(max_workers=workers) as ex:
        futs = [
            ex.submit(
                _process_chunk,
                ch,
                plane,
                list_mode,
                plane.nu,
                n_poly,
                sbp_engine,
                use_jit,
            )
            for ch in chunks
        ]
        for fut in as_completed(futs):
            flat_counts, lm_list = fut.result()
            flat_total += flat_counts
            if lm_all is not None and lm_list:
                lm_all.extend(lm_list)
            if pbar:
                pbar.update(1)
    if pbar:
        pbar.close()

    img = flat_total.reshape(plane.nv, plane.nu)
    return ReconResult(img, lm_all)

io

adapters

ngimager.io.adapters

Modular readers that turn external NOVO data sources (PHITS dumps or experiment/MC ROOT trees) into normalized physics-layer events (ngimager.physics.hits.Hit; ngimager.physics.events.{NeutronEvent,GammaEvent}) for the cone builder.

Design goals
  • Keep I/O concerns isolated from physics/kinematics.
  • Normalize units on ingest:
  • distances -> cm
  • times -> ns
  • Be tolerant to schema variants by using small, explicit field maps.
  • Stream (iterate) large files without loading everything into RAM.
  • Remain side-effect free: yield Python objects; HDF5 is handled downstream.
Entry points
  • class ROOTAdapter: reads NOVO ROOT trees ("novo_ddaq" or "hvl_geant4" styles).
  • class PHITSAdapter: reads tabular PHITS lists (CSV/Parquet/HDF5).
  • function make_adapter(cfg): factory from the [io.adapter] TOML section.
Config (example)

[io] input = "data/run42.root"

[io.adapter] type = "root" # "root" | "phits" style = "novo_ddaq" # ROOT styles: "novo_ddaq" | "hvl_geant4" unit_pos_is_mm = true time_units = "ns" # "ns" | "ps" require_gamma_triples = false # keep filtering in pipeline by default default_material = "M600" # tag assigned to all hits unless mapped

BaseAdapter

Abstract adapter interface.

Yields physics-layer events normalized to cm/ns (and L if present).

iter_events
iter_events(path)

Yield fully-typed physics events (NeutronEvent / GammaEvent, etc.) ready for cone building.

Source code in ngimager/io/adapters.py
def iter_events(self, path: str):
    """
    Yield fully-typed physics events (NeutronEvent / GammaEvent, etc.)
    ready for cone building.
    """
    raise NotImplementedError
iter_raw_events
iter_raw_events(path)

Yield 'raw' events as collections of canonical Hit objects.

Semantics: - Each yielded item represents a single raw coincidence window. - For PHITS usrdef, this is a dict with at least: { "event_type": "n" | "g" | ..., "hits": [Hit, Hit, ...], ... (bookkeeping fields) } - Other adapters may choose a different raw representation, but must include a 'hits' field with a sequence of Hit objects.

Source code in ngimager/io/adapters.py
def iter_raw_events(self, path: str):
    """
    Yield 'raw' events as collections of canonical Hit objects.

    Semantics:
      - Each yielded item represents a single raw coincidence window.
      - For PHITS usrdef, this is a dict with at least:
            {
                "event_type": "n" | "g" | ...,
                "hits": [Hit, Hit, ...],
                ... (bookkeeping fields)
            }
      - Other adapters may choose a different raw representation, but
        must include a 'hits' field with a sequence of Hit objects.
    """
    raise NotImplementedError
PHITSAdapter
PHITSAdapter(unit_pos_is_mm=True, time_units='ns', default_material='M600', material_map=None)

Bases: BaseAdapter

Read tabular event lists exported from PHITS post-processing.

Supported inputs: CSV (.csv), Parquet (.parquet/.pq), HDF (.h5/.hdf5).

The adapter expects row-wise events. Each row is either a neutron double or a gamma triple.

Canonical field names (columns): - x1,y1,z1,t1 ; x2,y2,z2,t2 ; [x3,y3,z3,t3] - det1,det2,[det3] ; L1,L2,[L3] (or elong1,elong2,[elong3]) - type (optional) values: 'n'|'g' ; if absent we infer by presence of 3rd hit

Units are assumed mm (pos) and ns (time) unless overridden.

Source code in ngimager/io/adapters.py
def __init__(
    self,
    unit_pos_is_mm: bool = True,
    time_units: Literal["ns", "ps"] = "ns",
    default_material: str = "M600",
    material_map: Optional[Dict[int, str]] = None,
) -> None:
    self.unit_pos_is_mm = unit_pos_is_mm
    self.time_scale = 0.001 if time_units == "ps" else 1.0
    self.default_material = default_material
    #mat_map = kwargs.get("material_map", None)
    #default_mat = kwargs.get("default_material", "UNK")
    self._material_resolver = MaterialResolver.from_mapping(material_map, default=default_material)
iter_events
iter_events(path)

Unified iterator: - If 'path' ends with .out (PHITS usrdef, ragged): parse→Hit→shape→typed and yield typed events. - Otherwise (CSV/Parquet/HDF): fall back to the existing table-based row iterator.

Source code in ngimager/io/adapters.py
def iter_events(self, path: str) -> Iterable[NeutronEvent | GammaEvent]:
    """
    Unified iterator:
      - If 'path' ends with .out (PHITS usrdef, ragged): parse→Hit→shape→typed and yield typed events.
      - Otherwise (CSV/Parquet/HDF): fall back to the existing table-based row iterator.
    """
    p = Path(path)
    if p.suffix.lower() == ".out":
        # 1) parse usrdef → Hit objects (your current helper)
        raw_events = self.iter_raw_events(path)
        #events = from_phits_usrdef(p, resolver=self._material_resolver)
        # 2) shape variable multiplicity into pairs/triples (policy from config later; defaults okay now)
        shaped, _diag = shape_events_for_cones(raw_events, ShapeConfig())
        #shaped, _diag = shape_events_for_cones(events, ShapeConfig())
        # 3) convert shaped → typed NeutronEvent/GammaEvent
        typed = shaped_to_typed_events(shaped, default_material=self.default_material, order_time=True)
        # 4) yield typed events to the pipeline
        for ev in typed:
            yield ev
        return

    # Fallback: table-based path (unchanged behavior)
    df = self._read_table(path)
    for _, r in df.iterrows():
        # Your existing table-row → typed conversion logic stays as-is here.
        # Example (pseudocode placeholder; keep your real code):
        # ev = self._row_to_event(r)  # existing function
        # yield ev
        raise NotImplementedError("Table row→typed event conversion is unchanged; keep your existing code here.")
iter_raw_events
iter_raw_events(path)

Yield PHITS 'raw' events as dicts whose 'hits' entry is a list of canonical Hit objects.

For usrdef .out files this wraps from_phits_usrdef, which: - parses the ragged usrdef text, - canonicalizes hit fields to x_cm / y_cm / z_cm / t_ns / Edep_MeV / L, - and converts each hit dict into a physics.hits.Hit, resolving the material via this adapter's MaterialResolver.

For table-like PHITS exports (CSV/Parquet/HDF5) we currently don't have a native raw-event representation, so we conservatively reconstruct a minimal raw event around each typed event.

Source code in ngimager/io/adapters.py
def iter_raw_events(self, path: str):
    """
    Yield PHITS 'raw' events as dicts whose 'hits' entry is a list of
    canonical Hit objects.

    For usrdef .out files this wraps `from_phits_usrdef`, which:
      - parses the ragged usrdef text,
      - canonicalizes hit fields to x_cm / y_cm / z_cm / t_ns / Edep_MeV / L,
      - and converts each hit dict into a physics.hits.Hit, resolving the
        material via this adapter's MaterialResolver.

    For table-like PHITS exports (CSV/Parquet/HDF5) we currently don't have
    a native raw-event representation, so we conservatively reconstruct a
    minimal raw event around each typed event.
    """
    p = Path(path)
    suffix = p.suffix.lower()

    if suffix == ".out":
        # `from_phits_usrdef` already returns a List[Dict] where
        #   ev["hits"] : List[Hit]
        # and event-level bookkeeping fields from the usrdef line.
        events = from_phits_usrdef(p, resolver=self._material_resolver)
        for ev in events:
            yield ev
        return

    # Fallback: wrap typed events as single raw events (non-.out inputs).
    from ngimager.physics.events import NeutronEvent, GammaEvent  # local import to avoid cycles

    for ev in self.iter_events(path):
        if isinstance(ev, NeutronEvent):
            hits = [ev.h1, ev.h2]
            ev_type = "n"
        elif isinstance(ev, GammaEvent):
            hits = [ev.h1, ev.h2, ev.h3]
            ev_type = "g"
        else:
            # Unknown/unsupported event type; skip
            continue

        yield {
            "event_type": ev_type,
            "hits": hits,
            "meta": getattr(ev, "meta", {}),
        }
RootNovoDdaqAdapter dataclass
RootNovoDdaqAdapter(tree_key='image_tree', unit_pos_is_mm=True, time_units='ns', default_material='UNK', material_map=None, require_gamma_triples=False, meta_tree_key='meta')

Bases: BaseAdapter

Adapter for NOVO DDAQ ROOT files ("image_tree" + optional "meta" tree).

This adapter: - reads the main coincidence tree (image_tree) and yields raw events with canonicalized hits, and - can optionally read the run-level metadata tree (meta) via read_meta_tree for passthrough into HDF5.

Parameters:

Name Type Description Default
tree_key str

Name of the ROOT TTree containing the imaging events (default: "image_tree").

'image_tree'
unit_pos_is_mm bool

If True, hit positions are stored in mm and converted to cm.

True
time_units ('ns', 'ps')

Units of the time branches (converted to ns).

"ns"
default_material str

Material tag to use when no mapping is provided.

'UNK'
material_map dict[int, str] or None

Mapping from det_id to material name.

None
require_gamma_triples bool

If True, drop gamma events that do not have exactly 3 hits.

False
meta_tree_key str or None

Name of the metadata TTree (default "meta"). If None, metadata extraction is disabled.

'meta'
iter_events
iter_events(path)

Placeholder: higher-level event shaping for NOVO DDAQ ROOT data.

For now, the ng-imager pipeline should consume iter_raw_events and run the standard shaping / filtering stack on top. This method is defined only to satisfy the BaseAdapter interface.

Source code in ngimager/io/adapters.py
def iter_events(self, path: str):
    """
    Placeholder: higher-level event shaping for NOVO DDAQ ROOT data.

    For now, the ng-imager pipeline should consume `iter_raw_events`
    and run the standard shaping / filtering stack on top. This method
    is defined only to satisfy the BaseAdapter interface.
    """
    raise NotImplementedError(
        "RootNovoDdaqAdapter.iter_events is not implemented yet; "
        "use iter_raw_events() via a staged pipeline."
    )
iter_raw_events
iter_raw_events(path)

Yield raw coincidence windows as dicts:

{
  "hits": [Hit, ...],
  "multi": int,          # as stored in the ROOT tree, if present
  "entry": int,          # global entry index
  "source": "ROOT_NOVO_DDAQ",
}

This method is intentionally conservative and does not make any physics decisions about which hits belong to neutron vs gamma events; it simply exposes the coincidence window.

Source code in ngimager/io/adapters.py
def iter_raw_events(self, path: str):
    """
    Yield raw coincidence windows as dicts:

        {
          "hits": [Hit, ...],
          "multi": int,          # as stored in the ROOT tree, if present
          "entry": int,          # global entry index
          "source": "ROOT_NOVO_DDAQ",
        }

    This method is intentionally conservative and does **not** make
    any physics decisions about which hits belong to neutron vs
    gamma events; it simply exposes the coincidence window.
    """
    path = str(path)
    tree = self._find_tree(path)

    # Determine which hit indices (1,2,3,...) are present in this tree.
    prefixes = ("x", "y", "z", "t", "dE", "psd", "det", "particle", "clipped")
    hit_indices: set[int] = set()
    for name in tree.keys():
        name_str = str(name)
        for p in prefixes:
            if name_str.startswith(p):
                suffix = name_str[len(p):]
                if suffix.isdigit():
                    hit_indices.add(int(suffix))

    if not hit_indices:
        # No per-hit branches found; nothing to yield.
        return

    indices = sorted(hit_indices)

    # Restrict arrays to just the branches we actually need.
    existing = set(str(n) for n in tree.keys())
    branch_names: list[str] = []
    if "multi" in existing:
        branch_names.append("multi")
    for idx in indices:
        for p in prefixes:
            key = f"{p}{idx}"
            if key in existing:
                branch_names.append(key)

    pos_scale = _CM_PER_MM if self.unit_pos_is_mm else 1.0
    time_scale = self._time_scale

    entry_offset = 0
    # Stream in chunks to avoid loading the full file into RAM.
    for arrays in tree.iterate(filter_name=branch_names, step_size="100 MB", library="np"):
        # NOTE: uproot returns dict-like arrays with NumPy arrays as values.
        multi_arr = arrays.get("multi")
        # Determine the row count from any branch.
        some_arr = next(iter(arrays.values()))
        n_rows = len(some_arr)

        for i in range(n_rows):
            hits: list[Hit] = []

            for idx in indices:
                # Minimal required fields; if positions or time are missing, skip this hit.
                try:
                    x = arrays[f"x{idx}"][i]
                    y = arrays[f"y{idx}"][i]
                    z = arrays[f"z{idx}"][i]
                    t = arrays[f"t{idx}"][i]
                    det = arrays[f"det{idx}"][i]
                except KeyError:
                    continue

                # Some entries may be "empty" placeholders; guard against obvious sentinels.
                try:
                    det_id = int(det)
                except Exception:
                    continue
                if det_id < 0:
                    continue

                # Positions in cm
                x_cm = float(x) * pos_scale
                y_cm = float(y) * pos_scale
                z_cm = float(z) * pos_scale
                rvec = np.array([x_cm, y_cm, z_cm], dtype=float)

                # Times in ns
                t_ns = float(t) * time_scale

                # Use dE as light-like quantity (MeVee) for now.
                dE_arr = arrays.get(f"dE{idx}")
                L_mevee = float(dE_arr[i]) if dE_arr is not None else 0.0

                # Optional PSD / particle / clipped info goes into extras.
                extras: Dict[str, Any] = {}
                psd_arr = arrays.get(f"psd{idx}")
                if psd_arr is not None:
                    extras["psd"] = float(psd_arr[i])

                part_code = None
                part_arr = arrays.get(f"particle{idx}")
                if part_arr is not None:
                    try:
                        part_code = int(part_arr[i])
                    except Exception:
                        part_code = None
                # Only keep neutron (1) and gamma (2) hits for imaging.
                if part_code not in (1, 2):
                    # e.g. laser or other special hits: drop them
                    continue
                extras["particle_code"] = part_code
                h_type = "n" if part_code == 1 else "g"

                clipped_arr = arrays.get(f"clipped{idx}")
                if clipped_arr is not None:
                    is_clipped = bool(clipped_arr[i])
                    if is_clipped:
                        # Drop this hit entirely; legacy imaging does not use clipped hits
                        continue
                    extras["clipped"] = is_clipped

                # Map particle code (if present) to a simple hit type.
                h_type: Optional[str] = None
                if part_code == 1:
                    h_type = "n"
                elif part_code == 2:
                    h_type = "g"
                elif part_code == 3:
                    h_type = "laser"

                # Resolve material from detector ID if mapping is available.
                material = self._material_resolver.material_for(det_id)

                hits.append(
                    Hit(
                        det_id=det_id,
                        r=rvec,
                        t_ns=t_ns,
                        L=L_mevee,
                        material=material,
                        type=h_type,
                        extras=extras,
                    )
                )

            if not hits:
                # Nothing usable in this coincidence window entry.
                continue

            multi_val = int(multi_arr[i]) if multi_arr is not None else len(hits)
            yield {
                "hits": hits,
                "multi": multi_val,
                "entry": entry_offset + i,
                "source": "ROOT_NOVO_DDAQ",
            }

        entry_offset += n_rows
read_meta_tree
read_meta_tree(path)

Read the NOVO 'meta' TTree (if present) and return a flat dict mapping branch names → Python scalars/strings.

This is intended for run-level metadata passthrough into HDF5. Returns None if no compatible meta tree is found.

Source code in ngimager/io/adapters.py
def read_meta_tree(self, path: str) -> Optional[Dict[str, Any]]:
    """
    Read the NOVO 'meta' TTree (if present) and return a flat dict
    mapping branch names → Python scalars/strings.

    This is intended for run-level metadata passthrough into HDF5.
    Returns None if no compatible meta tree is found.
    """
    if uproot is None:  # pragma: no cover
        return None

    path = str(path)
    f = uproot.open(path)
    try:
        tree = self._find_meta_tree(f)
        if tree is None:
            return None

        arrays = tree.arrays(library="np")
    finally:
        try:
            f.close()
        except Exception:
            pass

    if not arrays:
        return {}

    meta: Dict[str, Any] = {}
    for key, vals in arrays.items():
        # Expect exactly one entry; take the first.
        try:
            v = vals[0]
        except Exception:
            v = vals

        if isinstance(v, np.generic):
            v = v.item()

        if isinstance(v, (bytes, bytearray)):
            try:
                v = v.decode("utf-8", "ignore")
            except Exception:
                v = repr(v)

        meta[str(key)] = v

    return meta
from_phits_usrdef
from_phits_usrdef(path, *, format_hint='auto', resolver=None)

Public convenience entry point for PHITS usrdef ingestion. Currently supports the 'short' format. 'auto' is reserved for future sniffing.

Source code in ngimager/io/adapters.py
def from_phits_usrdef(path: str | Path, *, format_hint: Literal["short","auto"]="auto", 
                      resolver: MaterialResolver | None = None) -> List[Dict[str, Any]]:
    """
    Public convenience entry point for PHITS usrdef ingestion.
    Currently supports the 'short' format. 'auto' is reserved for future sniffing.
    """
    # In the future: sniff tokens/columns to choose short vs full.
    events = parse_phits_usrdef_short(path)
    canonicalize_events_inplace(events)

    # Resolve material from detector/region id via config (optional)
    if resolver is None: 
        resolver = MaterialResolver.from_env_or_defaults()

    # Convert dict-hits → Hit objects (keep source fields in extras)
    for ev in events:
        hits_H: List[Hit] = []
        ev_type = ev.get("event_type", "UNK") # event-level particle type from PHITS ("n" | "g")
        # Normalize to our canonical one-letter codes
        if ev_type.startswith("n"):
            hit_type = "n"
        elif ev_type.startswith("g"):
            hit_type = "g"
        else:
            hit_type = "UNK"
        for h in ev["hits"]:
            det = int(h["det_id"]) if "det_id" in h else int(h.get("reg", 0))
            r = np.array([h["x_cm"], h["y_cm"], h["z_cm"]], dtype=float)
            extras = dict(h.get("__extras__", {}))
            # Keep Edep explicitly in extras if present
            if "Edep_MeV" in h:
                extras.setdefault("Edep_MeV", h["Edep_MeV"])
            material = resolver.material_for(det)
            hits_H.append(Hit(det_id=det, r=r, t_ns=float(h["t_ns"]), L=float(h.get("L", extras.get("Edep_MeV", 0.0))),
                              type=hit_type, material=material, extras=extras))
        ev["hits"] = hits_H
    return events
make_adapter
make_adapter(cfg)

Create an adapter from a config dict (from TOML/CLI).

Expected keys under [io.adapter]: type: "root" | "phits" style: "novo_ddaq" | "hvl_geant4" (ROOT-only) unit_pos_is_mm: bool time_units: "ns" | "ps" require_gamma_triples: bool (ROOT-only) default_material: str

Source code in ngimager/io/adapters.py
def make_adapter(cfg: Dict) -> BaseAdapter:
    """
    Create an adapter from a config dict (from TOML/CLI).

    Expected keys under [io.adapter]:
      type: "root" | "phits"
      style: "novo_ddaq" | "hvl_geant4"            (ROOT-only)
      unit_pos_is_mm: bool
      time_units: "ns" | "ps"
      require_gamma_triples: bool       (ROOT-only)
      default_material: str
    """
    typ = (cfg.get("type") or "root").lower()

    if typ == "root":
        return ROOTAdapter(
            unit_pos_is_mm=bool(cfg.get("unit_pos_is_mm", True)),
            time_units=cfg.get("time_units", "ns"),
            default_material=cfg.get("default_material", "UNK"),
            material_map=cfg.get("material_map"),
        )

    if typ == "phits":
        return PHITSAdapter(
            unit_pos_is_mm=bool(cfg.get("unit_pos_is_mm", True)),
            time_units=cfg.get("time_units", "ns"),
            default_material=cfg.get("default_material", "UNK"),
            material_map=cfg.get("material_map"),
        )

    raise ValueError(f"Unknown adapter type: {typ}")
parse_phits_usrdef_short
parse_phits_usrdef_short(path)

Parse PHITS 'usrdef.out' short format into variable-multiplicity events. The [T-Userdefined] source code for this tally and documentation can be found at: https://github.com/Lindt8/T-Userdefined/tree/main/multi-coincidence_ng

Input row format (tokens; delimiters ';' and ',' are cosmetic): event_type #iomp #batch #history #no #name ; reg Edep(MeV) x(cm) y(cm) z(cm) t(ns) , reg Edep x y z t , ...

Where: - event_type: 'ne' (neutron) or 'ge' (gamma) - #iomp, #batch, #history, #no, #name: integers (PHITS bookkeeping) - For each hit: reg (int), Edep_MeV (float), x_cm (float), y_cm (float), z_cm (float), t_ns (float) - 2 hits min for 'ne', 3 hits min for 'ge', but higher multiplicities may appear.

Returns a list of dicts, each with: { "event_type": "n" | "g", "iomp": int, "batch": int, "history": int, "no": int, "name": int, "hits": [ {"reg": int, "Edep_MeV": float, "x_cm": float, "y_cm": float, "z_cm": float, "t_ns": float}, ... ], "source": "PHITS", "format": "usrdef.short", }

NOTE: This function performs no physics decisions (pair/triple selection, species mixing, etc.). It preserves all hits in the order they appear. Shaping happens downstream.

Source code in ngimager/io/adapters.py
def parse_phits_usrdef_short(path: str | Path) -> List[Dict[str, Any]]:
    """
    Parse PHITS 'usrdef.out' short format into variable-multiplicity events.
    The [T-Userdefined] source code for this tally and documentation can be found at:
    https://github.com/Lindt8/T-Userdefined/tree/main/multi-coincidence_ng

    Input row format (tokens; delimiters ';' and ',' are cosmetic):
        event_type  #iomp  #batch  #history  #no  #name  ;  reg  Edep(MeV)  x(cm)  y(cm)  z(cm)  t(ns)  ,  reg  Edep  x  y  z  t  ,  ...

    Where:
      - event_type: 'ne' (neutron) or 'ge' (gamma)
      - #iomp, #batch, #history, #no, #name: integers (PHITS bookkeeping)
      - For each hit: reg (int), Edep_MeV (float), x_cm (float), y_cm (float), z_cm (float), t_ns (float)
      - 2 hits min for 'ne', 3 hits min for 'ge', but higher multiplicities may appear.

    Returns a list of dicts, each with:
      {
        "event_type": "n" | "g",
        "iomp": int, "batch": int, "history": int, "no": int, "name": int,
        "hits": [
           {"reg": int, "Edep_MeV": float, "x_cm": float, "y_cm": float, "z_cm": float, "t_ns": float},
           ...
        ],
        "source": "PHITS",
        "format": "usrdef.short",
      }

    NOTE: This function performs *no* physics decisions (pair/triple selection, species mixing, etc.).
          It preserves all hits in the order they appear. Shaping happens downstream.
    """
    p = Path(path)
    events: List[Dict[str, Any]] = []

    # Fast replacements: remove cosmetic delimiters; keep whitespace tokenization stable.
    delim_re = re.compile(r"[;,]")

    with open(p, "r", encoding="utf-8", errors="ignore") as f:
        for raw in f:
            line = raw.strip()
            if not line or line.startswith(("!", "#")):
                continue

            # Normalize delimiters to spaces and split.
            line = delim_re.sub(" ", line)
            parts = line.split()
            if not parts:
                continue

            # Header: event_type + five ints
            # Defensive checks: ensure we have at least 6 tokens before hits begin.
            if len(parts) < 6:
                continue

            ev_type_tok = parts[0].lower()
            if ev_type_tok not in ("ne", "ge"):
                # If PHITS writes other tags in the future, skip for now (could log)
                continue

            try:
                iomp   = int(parts[1])
                batch  = int(parts[2])
                hist   = int(parts[3])
                no     = int(parts[4])
                name   = int(parts[5])
            except ValueError:
                # Malformed header row; skip
                continue

            # Remaining tokens are in groups of 6 per hit
            toks = parts[6:]
            if len(toks) < 6:
                # No hits present; skip this row
                continue

            if len(toks) % 6 != 0:
                # Truncated or malformed line; drop trailing incomplete group
                toks = toks[: (len(toks)//6) * 6]

            hits: List[Dict[str, Any]] = []
            for i in range(0, len(toks), 6):
                try:
                    reg  = int(toks[i + 0])
                    edep = float(toks[i + 1])   # MeV
                    x    = float(toks[i + 2])   # cm
                    y    = float(toks[i + 3])   # cm
                    z    = float(toks[i + 4])   # cm
                    t    = float(toks[i + 5])   # ns
                except ValueError:
                    # Skip this hit if any conversion fails
                    continue
                hits.append({
                    "reg": reg,
                    "Edep_MeV": edep,
                    "x_cm": x, "y_cm": y, "z_cm": z,
                    "t_ns": t,
                })

            if not hits:
                continue

            events.append({
                "event_type": "n" if ev_type_tok == "ne" else "g",
                "iomp": iomp, "batch": batch, "history": hist, "no": no, "name": name,
                "hits": hits,
                "source": "PHITS",
                "format": "usrdef.short",
            })

    return events

canonicalize

canonicalize_events_inplace
canonicalize_events_inplace(events)

Ensure each hit dict has the canonical keys used by filters/shapers: x_cm, y_cm, z_cm, t_ns, L, Edep_MeV, det_id Missing values are filled conservatively (L from Edep_MeV if absent). Mutates in place; safe to call on PHITS/ROOT outputs.

Source code in ngimager/io/canonicalize.py
def canonicalize_events_inplace(events: List[Dict[str, Any]]) -> None:
    """
    Ensure each hit dict has the canonical keys used by filters/shapers:
        x_cm, y_cm, z_cm, t_ns, L, Edep_MeV, det_id
    Missing values are filled conservatively (L from Edep_MeV if absent).
    Mutates in place; safe to call on PHITS/ROOT outputs.
    """
    for ev in events:
        hits = ev.get("hits", [])
        canon_hits = []
        for h in hits:
            ch = {}
            ch["x_cm"] = float(_first(h, _CANON_KEYS["x_cm"], 0.0))
            ch["y_cm"] = float(_first(h, _CANON_KEYS["y_cm"], 0.0))
            ch["z_cm"] = float(_first(h, _CANON_KEYS["z_cm"], 0.0))
            ch["t_ns"] = float(_first(h, _CANON_KEYS["t_ns"], 0.0))
            # energy + light-like
            edep = _first(h, _CANON_KEYS["Edep_MeV"], None)
            ch["Edep_MeV"] = float(edep) if edep is not None else 0.0
            L = _first(h, _CANON_KEYS["L"], None)
            ch["L"] = float(L) if L is not None else float(ch["Edep_MeV"])  # fallback
            # det/region
            det = _first(h, _CANON_KEYS["det_id"], 0)
            ch["det_id"] = int(det)
            # preserve all source fields as extras
            ch["__extras__"] = dict(h)
            canon_hits.append(ch)
        ev["hits"] = canon_hits

lm_store

write_cones
write_cones(f, cone_ids, apex_xyz_cm, axis_xyz, theta_rad, species, recoil_code, incident_energy_MeV, event_index, gamma_hit_order=None)

Store per-cone geometric and classification parameters under /cones.

Layout: /cones/cone_id : [N] uint32 /cones/apex_xyz_cm : [N,3] float32 /cones/axis_xyz : [N,3] float32 /cones/theta_rad : [N] float32 /cones/species : [N] uint8 (0=neutron, 1=gamma) /cones/recoil_code : [N] uint8 (0=NA, 1=proton, 2=carbon) /cones/incident_energy_MeV : [N] float32 (En for n, Eg for g) /cones/event_index : [N] int32 (row index into /lm/event_* arrays) /cones/gamma_hit_order : [N,3] int8 (optional; see below)

/cones/species_labels : ["0=neutron", "1=gamma"] /cones/recoil_code_labels : ["0=NA", "1=proton", "2=carbon"]

Notes
  • For gamma cones (species == 1), gamma_hit_order[i] = (i0, i1, i2) gives the indices into /lm/hit_*[event_index[i], :, :] that correspond to (first scatter, second scatter, third point) used to build that cone.
  • For neutron cones (species == 0), gamma_hit_order[i] is (-1, -1, -1) and should be ignored.
Source code in ngimager/io/lm_store.py
def write_cones(
    f: h5py.File,
    cone_ids: np.ndarray,
    apex_xyz_cm: np.ndarray,
    axis_xyz: np.ndarray,
    theta_rad: np.ndarray,
    species: np.ndarray,
    recoil_code: np.ndarray,
    incident_energy_MeV: np.ndarray,
    event_index: np.ndarray,
    gamma_hit_order: np.ndarray | None = None,
) -> None:
    """
    Store per-cone geometric and classification parameters under /cones.

    Layout:
      /cones/cone_id             : [N]   uint32 
      /cones/apex_xyz_cm         : [N,3] float32
      /cones/axis_xyz            : [N,3] float32
      /cones/theta_rad           : [N]   float32
      /cones/species             : [N]   uint8  (0=neutron, 1=gamma)
      /cones/recoil_code         : [N]   uint8  (0=NA, 1=proton, 2=carbon)
      /cones/incident_energy_MeV : [N]   float32 (En for n, Eg for g)
      /cones/event_index         : [N]   int32 (row index into /lm/event_* arrays)
      /cones/gamma_hit_order     : [N,3] int8  (optional; see below)

      /cones/species_labels      : ["0=neutron", "1=gamma"]
      /cones/recoil_code_labels  : ["0=NA", "1=proton", "2=carbon"]

    Notes
    -----
    * For gamma cones (species == 1), gamma_hit_order[i] = (i0, i1, i2) gives
      the indices into /lm/hit_*[event_index[i], :, :] that correspond to
      (first scatter, second scatter, third point) used to build that cone.
    * For neutron cones (species == 0), gamma_hit_order[i] is (-1, -1, -1)
      and should be ignored.
    """
    grp = f.require_group("cones")
    for name in (
            "cone_id",
            "apex_xyz_cm",
            "axis_xyz",
            "theta_rad",
            "species",
            "recoil_code",
            "incident_energy_MeV",
            "event_index",
            "gamma_hit_order",
            "species_labels",
            "recoil_code_labels",
    ):
        if name in grp:
            del grp[name]

    cone_ids = cone_ids.astype(np.uint32)
    apex_xyz_cm = apex_xyz_cm.astype(np.float32)
    axis_xyz = axis_xyz.astype(np.float32)
    theta_rad = theta_rad.astype(np.float32)

    grp.create_dataset(
        "cone_id",
        data=cone_ids,
        compression="gzip",
    )
    grp.create_dataset(
        "apex_xyz_cm",
        data=apex_xyz_cm,
        compression="gzip",
    )
    grp.create_dataset(
        "axis_xyz",
        data=axis_xyz,
        compression="gzip",
    )
    grp.create_dataset(
        "theta_rad",
        data=theta_rad,
        compression="gzip",
    )

    # Species: 0 = neutron, 1 = gamma.
    if species is None:
        species_arr = np.zeros_like(cone_ids, dtype=np.uint8)
    else:
        species_arr = np.asarray(species, dtype=np.uint8)
    d_species = grp.create_dataset(
        "species",
        data=species_arr,
        compression="gzip",
    )
    d_species.attrs["legend"] = np.array(
        ["0=neutron", "1=gamma"],
        dtype=h5py.string_dtype(),
    )

    # Recoil code: 0 = unknown / N/A, 1 = proton, 2 = carbon.
    if recoil_code is None:
        recoil_arr = np.zeros_like(cone_ids, dtype=np.uint8)
    else:
        recoil_arr = np.asarray(recoil_code, dtype=np.uint8)
    d_recoil = grp.create_dataset(
        "recoil_code",
        data=recoil_arr,
        compression="gzip",
    )
    d_recoil.attrs["legend"] = np.array(
        ["0=unknown_or_gamma", "1=proton", "2=carbon"],
        dtype=h5py.string_dtype(),
    )

    # Visible legends as datasets (similar to /lm/materials/labels)
    species_labels = np.array(
        ["0=neutron", "1=gamma"],
        dtype=h5py.string_dtype(),
    )
    recoil_labels = np.array(
        ["0=NA/gamma/unknown", "1=proton", "2=carbon"],
        dtype=h5py.string_dtype(),
    )

    grp.create_dataset("species_labels", data=species_labels)
    grp.create_dataset("recoil_code_labels", data=recoil_labels)

    grp.create_dataset(
        "incident_energy_MeV",
        data=incident_energy_MeV.astype(np.float32),
        compression="gzip",
    )

    grp.create_dataset(
        "event_index",
        data=event_index.astype(np.int32),
        compression="gzip",
    )

    if gamma_hit_order is not None:
        gamma_hit_order = np.asarray(gamma_hit_order, dtype=np.int8)
        if gamma_hit_order.ndim != 2 or gamma_hit_order.shape[1] != 3:
            raise ValueError(
                "gamma_hit_order must have shape (N_cones, 3). "
                f"Got {gamma_hit_order.shape!r}."
            )
        if gamma_hit_order.shape[0] != cone_ids.shape[0]:
            raise ValueError(
                "gamma_hit_order length must match number of cones: "
                f"{gamma_hit_order.shape[0]} vs {cone_ids.shape[0]}"
            )
        grp.create_dataset(
            "gamma_hit_order",
            data=gamma_hit_order,
            compression="gzip",
        )
write_counters
write_counters(f, counters)

Store scalar counters under /meta/counters as attributes.

Each key in counters becomes an attribute on the /meta/counters group, prefixed with a stage number:

S1_... → Stage 1 (raw events → hits)
S2_... → Stage 2 (hits → shaped/typed → event filters)
S3_... → Stage 3 (events → cones → cone filters)
S4_... → Stage 4 (cones → images)

This forces a "chronological" ordering when viewed in tools like HDFView (which sort attributes alphabetically).

Source code in ngimager/io/lm_store.py
def write_counters(f: h5py.File, counters: Dict[str, int]) -> None:
    """
    Store scalar counters under /meta/counters as attributes.

    Each key in `counters` becomes an attribute on the /meta/counters group,
    prefixed with a stage number:

        S1_... → Stage 1 (raw events → hits)
        S2_... → Stage 2 (hits → shaped/typed → event filters)
        S3_... → Stage 3 (events → cones → cone filters)
        S4_... → Stage 4 (cones → images)

    This forces a "chronological" ordering when viewed in tools like HDFView
    (which sort attributes alphabetically).
    """
    meta = f.require_group("meta")
    if "counters" in meta:
        del meta["counters"]
    grp = meta.create_group("counters")

    # Sort by (stage, original key) so that attributes appear grouped by stage,
    # then alphabetically within each stage.
    for key in sorted(counters.keys(), key=lambda k: (_counter_stage(k), k)):
        stage = _counter_stage(key)
        if stage > 0:
            out_key = f"S{stage}_{key}"
        else:
            out_key = key
        value = counters[key]
        try:
            grp.attrs[out_key] = int(value)
        except Exception:
            grp.attrs[out_key] = str(value)
write_event_cone_survival
write_event_cone_survival(f, event_cone_id, event_imaged_cone_id)

Store per-event survival information linking events → cones.

Layout (all under /lm):

/lm/event_cone_id : [N_events] int32 For each event row i (as in /lm/event_type, /lm/hit_*): - cone_id of the cone built from this event, or -1 if no cone.

/lm/event_imaged_cone_id : [N_events] int32 For each event row i: - cone_id of the cone that both exists AND hits the imaging plane (has non-empty pixel set), or -1 if none.

Notes
  • event index i is simply the row index into /lm/event_type, /lm/hit_*.
  • event_imaged_cone_id is only meaningfully populated when [run].list = true; for non-list runs it will typically be all -1.
Source code in ngimager/io/lm_store.py
def write_event_cone_survival(
    f: h5py.File,
    event_cone_id: np.ndarray,
    event_imaged_cone_id: np.ndarray,
) -> None:
    """
    Store per-event survival information linking events → cones.

    Layout (all under /lm):

      /lm/event_cone_id         : [N_events] int32
          For each event row i (as in /lm/event_type, /lm/hit_*):
              - cone_id of the cone built from this event, or -1 if no cone.

      /lm/event_imaged_cone_id  : [N_events] int32
          For each event row i:
              - cone_id of the cone that both exists AND hits the imaging plane
                (has non-empty pixel set), or -1 if none.

    Notes
    -----
    * event index i is simply the row index into /lm/event_type, /lm/hit_*.
    * event_imaged_cone_id is only meaningfully populated when [run].list = true;
      for non-list runs it will typically be all -1.
    """
    lm_grp = f.require_group("lm")

    if "event_cone_id" in lm_grp:
        del lm_grp["event_cone_id"]
    lm_grp.create_dataset(
        "event_cone_id",
        data=event_cone_id.astype(np.int32),
        compression="gzip",
    )

    if "event_imaged_cone_id" in lm_grp:
        del lm_grp["event_imaged_cone_id"]
    lm_grp.create_dataset(
        "event_imaged_cone_id",
        data=event_imaged_cone_id.astype(np.int32),
        compression="gzip",
    )
write_events_hits
write_events_hits(f, events)

Store per-event and per-hit data for list-mode analysis.

Layout (all under /lm):

/lm/materials/labels : [M] array of material strings /lm/event_type : [N] uint8, 0=n, 1=g /lm/event_meta_run_id : [N] int32 (optional meta) /lm/event_meta_file_ix : [N] int32 (optional meta) /lm/hit_pos_cm : [N,3,3] float32 (event, hit_index, xyz) /lm/hit_t_ns : [N,3] float64 /lm/hit_L_mevee : [N,3] float32 /lm/hit_det_id : [N,3] int32 /lm/hit_material_id : [N,3] int16

Convention: - Neutron events use hits [0,1] and leave slot 2 as NaN/-1. - Gamma events use hits [0,1,2].

Source code in ngimager/io/lm_store.py
def write_events_hits(
    f: h5py.File,
    events: list[NeutronEvent | GammaEvent],
) -> None:
    """
    Store per-event and per-hit data for list-mode analysis.

    Layout (all under /lm):

      /lm/materials/labels    : [M]  array of material strings
      /lm/event_type          : [N]  uint8, 0=n, 1=g
      /lm/event_meta_run_id   : [N]  int32 (optional meta)
      /lm/event_meta_file_ix  : [N]  int32 (optional meta)
      /lm/hit_pos_cm          : [N,3,3] float32 (event, hit_index, xyz)
      /lm/hit_t_ns            : [N,3]   float64
      /lm/hit_L_mevee         : [N,3]   float32
      /lm/hit_det_id          : [N,3]   int32
      /lm/hit_material_id     : [N,3]   int16

    Convention:
      - Neutron events use hits [0,1] and leave slot 2 as NaN/-1.
      - Gamma events use hits [0,1,2].
    """
    if not events:
        return

    N = len(events)

    # Helper: always return a list[Hit] in time order for any supported event
    def _ordered_hits(ev: NeutronEvent | GammaEvent):
        ev_ord = ev.ordered()
        if isinstance(ev_ord, NeutronEvent):
            return [ev_ord.h1, ev_ord.h2]
        elif isinstance(ev_ord, GammaEvent):
            return [ev_ord.h1, ev_ord.h2, ev_ord.h3]
        else:
            raise TypeError(f"Unsupported event type in write_events_hits: {type(ev_ord)!r}")

    # Gather materials to build a small vocabulary
    material_labels: set[str] = set()
    for ev in events:
        for h in _ordered_hits(ev):
            # Hit.material is a required field in our current design; we still
            # defensively allow None just in case.
            mat = getattr(h, "material", None)
            if mat is not None:
                material_labels.add(mat)

    material_list = sorted(material_labels)
    material_to_id = {m: i for i, m in enumerate(material_list)}

    def mat_id(mat: str | None) -> int:
        if mat is None:
            return -1
        return material_to_id.get(mat, -1)

    # Allocate arrays
    hit_pos = np.full((N, 3, 3), np.nan, dtype=np.float32)
    hit_t = np.full((N, 3), np.nan, dtype=np.float64)
    hit_L = np.full((N, 3), np.nan, dtype=np.float32)
    hit_det = np.full((N, 3), -1, dtype=np.int32)
    hit_mat = np.full((N, 3), -1, dtype=np.int16)
    ev_type = np.zeros(N, dtype=np.uint8)  # 0=n,1=g

    # very light meta placeholders
    ev_run = np.full(N, -1, dtype=np.int32)
    ev_file_ix = np.full(N, -1, dtype=np.int32)

    for i, ev in enumerate(events):
        hits = _ordered_hits(ev)
        is_gamma = isinstance(ev, GammaEvent)
        ev_type[i] = 1 if is_gamma else 0

        # very generic meta → two common keys, everything else stays in ev.meta
        if getattr(ev, "meta", None):
            if "run" in ev.meta:
                try:
                    ev_run[i] = int(ev.meta["run"])
                except Exception:
                    pass
            if "file_index" in ev.meta:
                try:
                    ev_file_ix[i] = int(ev.meta["file_index"])
                except Exception:
                    pass

        for j, h in enumerate(hits[:3]):
            r = np.asarray(h.r, dtype=float).reshape(3)
            hit_pos[i, j, :] = r
            hit_t[i, j] = np.float64(h.t_ns)
            hit_L[i, j] = float(h.L)
            hit_det[i, j] = int(h.det_id) if h.det_id is not None else -1
            hit_mat[i, j] = mat_id(getattr(h, "material", None))

    lm_grp = f.require_group("lm")

    # Store material vocabulary as a flat label list:
    # index into this with hit_material_id
    mat_labels = np.array(material_list, dtype=h5py.string_dtype())
    if "hit_material_id_labels" in lm_grp:
        del lm_grp["hit_material_id_labels"]
    lm_grp.create_dataset("hit_material_id_labels", data=mat_labels)

    def _replace_or_create(name: str, data: np.ndarray):
        if name in lm_grp:
            del lm_grp[name]
        lm_grp.create_dataset(name, data=data, compression="gzip")

    _replace_or_create("event_type", ev_type)
    # Add a legend for event_type: 0 = neutron, 1 = gamma.
    d_event_type = lm_grp["event_type"]
    d_event_type.attrs["legend"] = np.array(
        ["0=neutron", "1=gamma"],
        dtype=h5py.string_dtype(),
    )
    _replace_or_create("event_meta_run_id", ev_run)
    _replace_or_create("event_meta_file_ix", ev_file_ix)
    _replace_or_create("hit_pos_cm", hit_pos)
    _replace_or_create("hit_t_ns", hit_t)
    _replace_or_create("hit_L_mevee", hit_L)
    _replace_or_create("hit_det_id", hit_det)
    _replace_or_create("hit_material_id", hit_mat)

    # Legend for event_type (0=neutron, 1=gamma) as a visible dataset
    event_type_labels = np.array(
        ["0=neutron", "1=gamma"],
        dtype=h5py.string_dtype(),
    )
    if "event_type_labels" in lm_grp:
        del lm_grp["event_type_labels"]
    lm_grp.create_dataset("event_type_labels", data=event_type_labels)
write_lm_indices
write_lm_indices(f, lm_cone_pixel_lists)

Store list-mode indices mapping cones -> (u,v) pixels.

We store: /lm/cone_pixel_indices : ragged array of (cone_id, flat_index) pairs

where: - cone_id is the index into /cones/cone_id - flat_index is the flattened pixel index (row-major) on the imaging plane.

Source code in ngimager/io/lm_store.py
def write_lm_indices(
    f: h5py.File,
    lm_cone_pixel_lists: list[tuple[int, np.ndarray]],
) -> None:
    """
    Store list-mode indices mapping cones -> (u,v) pixels.

    We store:
      /lm/cone_pixel_indices : ragged array of (cone_id, flat_index) pairs

    where:
      - cone_id is the index into /cones/cone_id
      - flat_index is the flattened pixel index (row-major) on the imaging plane.
    """
    grp = f.require_group("lm")

    # Flatten all LM lists with cone_id
    all_rows: list[np.ndarray] = []

    for cone_id, arr in lm_cone_pixel_lists:
        if arr is None:
            continue
        flat = np.asarray(arr, dtype=np.uint32).ravel()
        if flat.size == 0:
            continue
        cone_ids = np.full_like(flat, int(cone_id), dtype=np.uint32)
        stacked = np.vstack([cone_ids, flat]).T  # (M,2)
        all_rows.append(stacked)

    if all_rows:
        all_rows_arr = np.concatenate(all_rows, axis=0)
    else:
        all_rows_arr = np.zeros((0, 2), dtype=np.uint32)

    # Single, clearly-named dataset for cone→pixel mapping
    if "cone_pixel_indices" in grp:
        del grp["cone_pixel_indices"]
    grp.create_dataset("cone_pixel_indices", data=all_rows_arr, compression="gzip")

    # Alias for convenience under /images/list_mode; this is a standard HDF5
    # soft link, so it does not duplicate data on disk.
    images_grp = f.require_group("images")
    list_mode_grp = images_grp.require_group("list_mode")
    if "cone_pixel_indices" in list_mode_grp:
        del list_mode_grp["cone_pixel_indices"]
    list_mode_grp["cone_pixel_indices"] = h5py.SoftLink("/lm/cone_pixel_indices")

    # The old /lm/indices and /lm/events datasets are intentionally no longer
    # written to avoid confusion about their semantics.
    if "indices" in grp:
        del grp["indices"]
    if "events" in grp:
        del grp["events"]
write_lm_ragged
write_lm_ragged(h5, phits_events, *, group='/lm')

Write variable-length list-mode (ragged) datasets for events with arbitrary hit multiplicity. This is ADDITIVE and does not modify existing fixed-shape datasets you already write elsewhere.

Source code in ngimager/io/lm_store.py
def write_lm_ragged(h5: h5py.File, phits_events: Sequence[Dict[str, Any]], *, group: str = "/lm") -> None:
    """
    Write variable-length list-mode (ragged) datasets for events with arbitrary hit multiplicity.
    This is ADDITIVE and does not modify existing fixed-shape datasets you already write elsewhere.
    """
    if group.endswith("/"):
        group = group[:-1]
    g_hits = h5.require_group(f"{group}/hits")
    g_ev   = h5.require_group(f"{group}/events")

    event_ptr, cols = _flatten_hits_for_ragged(phits_events)

    # Event pointer (CSR)
    if "event_ptr" in g_hits:
        del g_hits["event_ptr"]
    g_hits.create_dataset("event_ptr", data=event_ptr, dtype="i8")

    # Flat hit columns
    for key in ("x_cm", "y_cm", "z_cm", "t_ns", "Edep_MeV", "reg"):
        if key in g_hits:
            del g_hits[key]
        g_hits.create_dataset(key, data=cols[key])

    # Event-level arrays
    for key in ("event_type", "iomp", "batch", "history", "no", "name"):
        arr = cols[f"events/{key}"]
        if key in g_ev:
            del g_ev[key]
        g_ev.create_dataset(key, data=arr)
write_projections
write_projections(f, species, img, roi_bounds_cm=None, metrics_cfg=None)

Write 1D u/v projections (and optional ROI-limited projections) to HDF5, and optionally compute/write metrics.

Layout under /images/summed/projections/{species}:

u      : [nu] float32, sum over v (rows)
v      : [nv] float32, sum over u (cols)
u_roi  : [nu] float32, ROI-limited u projection (zeros outside ROI)
v_roi  : [nv] float32, ROI-limited v projection (zeros outside ROI)

Metrics layout (per species):

metrics/u      : scalar metrics for the "all" u-projection
metrics/v      : scalar metrics for the "all" v-projection
metrics/u_roi  : scalar metrics for the ROI u-projection (if ROI defined)
metrics/v_roi  : scalar metrics for the ROI v-projection (if ROI defined)

Each metrics group contains 0D datasets such as:

total_counts
mean_cm, median_cm, std_cm
peak_pos_cm, peak_value
edge_low_cm, edge_high_cm, edge_width_cm
summary_ok, peak_ok, edges_ok

The imaging plane grid (u_min/u_max/v_min/v_max/du/dv) is read from /meta.attrs as written by write_init().

Source code in ngimager/io/lm_store.py
def write_projections(
    f: h5py.File,
    species: str,
    img: np.ndarray,
    roi_bounds_cm: Optional[tuple[float, float, float, float]] = None,
    metrics_cfg: Optional[ProjectionMetricsCfg] = None,
) -> None:
    """
    Write 1D u/v projections (and optional ROI-limited projections) to HDF5,
    and optionally compute/write metrics.

    Layout under /images/summed/projections/{species}:

        u      : [nu] float32, sum over v (rows)
        v      : [nv] float32, sum over u (cols)
        u_roi  : [nu] float32, ROI-limited u projection (zeros outside ROI)
        v_roi  : [nv] float32, ROI-limited v projection (zeros outside ROI)

    Metrics layout (per species):

        metrics/u      : scalar metrics for the "all" u-projection
        metrics/v      : scalar metrics for the "all" v-projection
        metrics/u_roi  : scalar metrics for the ROI u-projection (if ROI defined)
        metrics/v_roi  : scalar metrics for the ROI v-projection (if ROI defined)

    Each metrics group contains 0D datasets such as:

        total_counts
        mean_cm, median_cm, std_cm
        peak_pos_cm, peak_value
        edge_low_cm, edge_high_cm, edge_width_cm
        summary_ok, peak_ok, edges_ok

    The imaging plane grid (u_min/u_max/v_min/v_max/du/dv) is read from
    /meta.attrs as written by write_init().
    """
    img = np.asarray(img, dtype=float)
    nv, nu = img.shape

    proj_root = _ensure_projections_group(f)
    grp = proj_root.require_group(species)

    # Global projections
    proj_u = img.sum(axis=0)  # over v
    proj_v = img.sum(axis=1)  # over u

    # Fetch grid info from meta
    meta_attrs = f["meta"].attrs
    u_min_cm = float(meta_attrs["grid.u_min"])
    v_min_cm = float(meta_attrs["grid.v_min"])
    du_cm = float(meta_attrs["grid.du"])
    dv_cm = float(meta_attrs["grid.dv"])

    # Pixel-center coordinates in cm
    u_centers_cm = u_min_cm + (np.arange(nu) + 0.5) * du_cm
    v_centers_cm = v_min_cm + (np.arange(nv) + 0.5) * dv_cm

    # ROI projections (same length as global; zeros outside ROI)
    proj_u_roi: Optional[np.ndarray] = None
    proj_v_roi: Optional[np.ndarray] = None

    if roi_bounds_cm is not None:
        ru_min, ru_max, rv_min, rv_max = roi_bounds_cm

        u_mask = (u_centers_cm >= ru_min) & (u_centers_cm <= ru_max)
        v_mask = (v_centers_cm >= rv_min) & (v_centers_cm <= rv_max)

        if np.any(u_mask) and np.any(v_mask):
            block = img[np.ix_(v_mask, u_mask)]

            proj_u_roi = np.zeros_like(proj_u)
            proj_v_roi = np.zeros_like(proj_v)

            proj_u_roi[u_mask] = block.sum(axis=0)
            proj_v_roi[v_mask] = block.sum(axis=1)

        # Store ROI bounds as attributes on the species group
        grp.attrs["roi_u_min_cm"] = float(ru_min)
        grp.attrs["roi_u_max_cm"] = float(ru_max)
        grp.attrs["roi_v_min_cm"] = float(rv_min)
        grp.attrs["roi_v_max_cm"] = float(rv_max)

    # Write projections themselves
    for name, data in (
        ("u", proj_u),
        ("v", proj_v),
    ):
        if name in grp:
            del grp[name]
        grp.create_dataset(name, data=data.astype(np.float32), compression="gzip")

    if proj_u_roi is not None:
        if "u_roi" in grp:
            del grp["u_roi"]
        grp.create_dataset("u_roi", data=proj_u_roi.astype(np.float32), compression="gzip")

    if proj_v_roi is not None:
        if "v_roi" in grp:
            del grp["v_roi"]
        grp.create_dataset("v_roi", data=proj_v_roi.astype(np.float32), compression="gzip")

    # ------------------------------------------------------------------
    # Optional metrics
    # ------------------------------------------------------------------
    if metrics_cfg is None or not metrics_cfg.enabled:
        # No metrics requested; nothing else to do here.
        # If you want to aggressively clean old metrics, you could:
        #   del grp["metrics"]
        # but for now we leave any existing metrics untouched.
        return

    metrics = compute_projection_metrics(
        u_centers_cm=u_centers_cm,
        v_centers_cm=v_centers_cm,
        proj_u=proj_u,
        proj_v=proj_v,
        proj_u_roi=proj_u_roi,
        proj_v_roi=proj_v_roi,
        cfg=metrics_cfg,
    )

    if not metrics:
        return

    stats_root = grp.require_group("metrics")

    # Axis-level groups: "u", "v" for all-pixel metrics; "u_roi", "v_roi" for ROI metrics.
    for axis_name, axis_metrics in metrics.items():
        axis_cfg: ProjectionAxisMetricsCfg = getattr(metrics_cfg, axis_name)

        # ---- "all" curve → metrics/u or metrics/v ----
        all_metrics = axis_metrics.get("all")
        axis_grp = stats_root.require_group(axis_name)

        # Axis-level attrs (apply to both all+ROI for this axis)
        axis_grp.attrs["edge_low_frac"] = float(axis_cfg.edge_low_frac)
        axis_grp.attrs["edge_high_frac"] = float(axis_cfg.edge_high_frac)
        axis_grp.attrs["min_counts"] = float(axis_cfg.min_counts)

        # Clear any stale datasets in the "all" group
        for ds_name in list(axis_grp.keys()):
            del axis_grp[ds_name]

        if all_metrics:
            for key, value in all_metrics.items():
                if value is None:
                    continue
                if key in axis_grp:
                    del axis_grp[key]
                axis_grp.create_dataset(key, data=value)

        # ---- ROI curve → metrics/u_roi or metrics/v_roi ----
        roi_metrics = axis_metrics.get("roi")
        if roi_metrics:
            roi_group_name = f"{axis_name}_roi"
            roi_grp = stats_root.require_group(roi_group_name)

            # Clear any stale datasets in the ROI group
            for ds_name in list(roi_grp.keys()):
                del roi_grp[ds_name]

            for key, value in roi_metrics.items():
                if value is None:
                    continue
                if key in roi_grp:
                    del roi_grp[key]
                roi_grp.create_dataset(key, data=value)
write_root_novo_meta
write_root_novo_meta(f, meta)

Persist NOVO DDAQ ROOT run-level metadata into HDF5 under /meta/root_novo_ddaq.

The input 'meta' is expected to be a flat mapping from branch names in the ROOT 'meta' TTree to Python scalars/strings, as returned by RootNovoDdaqAdapter.read_meta_tree().

Layout

/meta/root_novo_ddaq : group attrs: InputFileName, OutputFileName, CDFFileName, PSDCutsFileName SampleRate, NumDet, NumThreads, WriteHistograms, MergeMode, CardOffsetChannel, UsePositionVeto

/meta/root_novo_ddaq/detectors
  det_id            : [NumDet] int32
  pos               : [NumDet, 3] float32   (PosX, PosY, PosZ)   [mm]
  dim               : [NumDet, 3] float32   (DimX, DimY, DimZ)   [mm]
  rot_deg           : [NumDet, 3] float32   (RotX, RotY, RotZ)   [deg]
  local_time_offset : [NumDet] float32      [ns]
  global_time_offset: [NumDet] float32      [ns]
  pos_cal_file      : [NumDet] string
  energy_cal_file   : [NumDet] string
  is_start_det      : [NumDet] int8
  is_laser_det      : [NumDet] int8
Source code in ngimager/io/lm_store.py
def write_root_novo_meta(f: h5py.File, meta: Dict[str, Any]) -> None:
    """
    Persist NOVO DDAQ ROOT run-level metadata into HDF5 under /meta/root_novo_ddaq.

    The input 'meta' is expected to be a flat mapping from branch names
    in the ROOT 'meta' TTree to Python scalars/strings, as returned by
    RootNovoDdaqAdapter.read_meta_tree().

    Layout
    ------
      /meta/root_novo_ddaq           : group
        attrs:
          InputFileName, OutputFileName, CDFFileName, PSDCutsFileName
          SampleRate, NumDet, NumThreads, WriteHistograms,
          MergeMode, CardOffsetChannel, UsePositionVeto

        /meta/root_novo_ddaq/detectors
          det_id            : [NumDet] int32
          pos               : [NumDet, 3] float32   (PosX, PosY, PosZ)   [mm]
          dim               : [NumDet, 3] float32   (DimX, DimY, DimZ)   [mm]
          rot_deg           : [NumDet, 3] float32   (RotX, RotY, RotZ)   [deg]
          local_time_offset : [NumDet] float32      [ns]
          global_time_offset: [NumDet] float32      [ns]
          pos_cal_file      : [NumDet] string
          energy_cal_file   : [NumDet] string
          is_start_det      : [NumDet] int8
          is_laser_det      : [NumDet] int8
    """
    meta_root = f.require_group("meta").require_group("root_novo_ddaq")

    # ---- Run-level scalars as attributes ----
    attr_keys = [
        "InputFileName",
        "OutputFileName",
        "CDFFileName",
        "PSDCutsFileName",
        "SampleRate",
        "NumDet",
        "NumThreads",
        "WriteHistograms",
        "MergeMode",
        "CardOffsetChannel",
        "UsePositionVeto",
    ]
    for key in attr_keys:
        if key not in meta:
            continue
        val = meta[key]
        if isinstance(val, np.generic):
            val = val.item()
        meta_root.attrs[key] = val

    # ---- Attempt to extract run number from metadata / filenames ----
    #
    # Priority:
    #   1. Explicit numeric run key if present (e.g. "RunNumber" or "RunNo").
    #   2. Infer from InputFileName / OutputFileName by looking for the last
    #      block of digits before ".root" (e.g. "..._000041.root" → 41).
    run_number_int: int | None = None
    run_number_str: str | None = None

    # 1) If the meta dict already has an explicit run number, prefer that.
    for key in ("RunNumber", "RunNo", "runNumber", "run_no"):
        if key in meta:
            v = meta[key]
            if isinstance(v, np.generic):
                v = v.item()
            try:
                run_number_int = int(v)
                run_number_str = f"{run_number_int:d}"
            except Exception:
                # Fall back to string representation if not cleanly integer.
                run_number_str = str(v)
            break

    # 2) Otherwise, try to infer from the filename pattern.
    if run_number_int is None:
        fname = meta.get("InputFileName") or meta.get("OutputFileName")
        if isinstance(fname, np.generic):
            fname = fname.item()
        if fname:
            try:
                # Work with just the basename, e.g. "coinc_detector_..._000041.root"
                base = str(Path(fname).name)
                # Look for the last run of digits before ".root"
                m = re.search(r"(\d+)\.root$", base)
                if m:
                    run_number_str = m.group(1)
                    try:
                        run_number_int = int(run_number_str)
                    except Exception:
                        # Keep the string even if int conversion fails
                        pass
            except Exception:
                pass

    # If we found something, store it as attributes.
    if run_number_int is not None:
        meta_root.attrs["RunNumber"] = int(run_number_int)
    if run_number_str is not None:
        meta_root.attrs["RunNumber_str"] = str(run_number_str)



    # ---- Detector table ----
    num_det = int(meta.get("NumDet", 0) or 0)
    if num_det <= 0:
        return

    det_grp = meta_root.require_group("detectors")

    # Helper to (re)create a dataset
    def _create_or_replace(name: str, data: np.ndarray, **kwargs) -> h5py.Dataset:
        if name in det_grp:
            del det_grp[name]
        return det_grp.create_dataset(name, data=data, compression="gzip", **kwargs)

    det_ids = np.arange(num_det, dtype=np.int32)
    _create_or_replace("det_id", det_ids)

    # Numeric tables
    pos = np.zeros((num_det, 3), dtype=np.float32)
    dim = np.zeros((num_det, 3), dtype=np.float32)
    rot = np.zeros((num_det, 3), dtype=np.float32)
    local_off = np.zeros(num_det, dtype=np.float32)
    global_off = np.zeros(num_det, dtype=np.float32)
    is_start = np.zeros(num_det, dtype=np.int8)
    is_laser = np.zeros(num_det, dtype=np.int8)

    pos_cal: List[str] = []
    e_cal: List[str] = []

    for i in range(num_det):
        def _get(name: str, default=0.0):
            key = f"Det{i}_{name}"
            v = meta.get(key, default)
            if isinstance(v, np.generic):
                v = v.item()
            return v

        pos[i, 0] = float(_get("PosX", 0.0))
        pos[i, 1] = float(_get("PosY", 0.0))
        pos[i, 2] = float(_get("PosZ", 0.0))

        dim[i, 0] = float(_get("DimX", 0.0))
        dim[i, 1] = float(_get("DimY", 0.0))
        dim[i, 2] = float(_get("DimZ", 0.0))

        rot[i, 0] = float(_get("RotX", 0.0))
        rot[i, 1] = float(_get("RotY", 0.0))
        rot[i, 2] = float(_get("RotZ", 0.0))

        local_off[i] = float(_get("LocalTimeOffset", 0.0))
        global_off[i] = float(_get("GlobalTimeOffset", 0.0))

        # Filenames
        pc = meta.get(f"Det{i}_PosCalFileName", "")
        ec = meta.get(f"Det{i}_EnergyCalFileName", "")
        pos_cal.append(str(pc))
        e_cal.append(str(ec))

        # Flags (stored as ints)
        is_start[i] = int(_get("IsStartDet", 0))
        is_laser[i] = int(_get("IsLaserDet", 0))

    ds_pos = _create_or_replace("pos", pos)
    ds_pos.attrs["units"] = "mm"

    ds_dim = _create_or_replace("dim", dim)
    ds_dim.attrs["units"] = "mm"

    ds_rot = _create_or_replace("rot_deg", rot)
    ds_rot.attrs["units"] = "deg"

    ds_local = _create_or_replace("local_time_offset", local_off)
    ds_local.attrs["units"] = "ns"

    ds_global = _create_or_replace("global_time_offset", global_off)
    ds_global.attrs["units"] = "ns"

    _create_or_replace(
        "is_start_det",
        is_start,
    )
    _create_or_replace(
        "is_laser_det",
        is_laser,
    )

    # String datasets
    str_dt = h5py.string_dtype(encoding="utf-8")
    _create_or_replace("pos_cal_file", np.asarray(pos_cal, dtype=str_dt))
    _create_or_replace("energy_cal_file", np.asarray(e_cal, dtype=str_dt))
write_summed
write_summed(f, species, img)

Write summed image for a given species.

Parameters:

Name Type Description Default
f open h5py.File
required
species "n" | "g" | "all" (string key)
required
img 2D numpy array (nv, nu), float or int
required
Source code in ngimager/io/lm_store.py
def write_summed(
    f: h5py.File,
    species: str,
    img: np.ndarray,
) -> None:
    """
    Write summed image for a given species.

    Parameters
    ----------
    f : open h5py.File
    species : "n" | "g" | "all" (string key)
    img : 2D numpy array (nv, nu), float or int
    """
    grp = _ensure_summed_group(f)
    dset_name = species
    if dset_name in grp:
        del grp[dset_name]
    grp.create_dataset(dset_name, data=img.astype(np.float32), compression="gzip")

lut

build_lut_registry
build_lut_registry(lut_paths, base_dir=None)

Build a registry mapping material -> species -> LUT.

Parameters:

Name Type Description Default
lut_paths Dict[str, Dict[str, str]] | None

Configuration-style mapping, e.g.:

{
    "M600": {"proton": "data/lut/M600/lut_M600_proton_Birks.npz"},
    "OGS":  {"carbon": "custom/OGS_carbon.npz"},
}

Paths may be relative; they are resolved against base_dir when given. If a configured path does not exist on disk but a built-in LUT is available for that material/species (M600/OGS proton/carbon), the built-in is used as a fallback.

When a material/species is omitted entirely from lut_paths, this function will still inject built-in defaults for common NOVO scintillators (M600, OGS).

required
base_dir str | Path | None

Base directory for resolving relative paths (typically the directory containing the TOML config). If None, uses the current working directory.

None

Returns:

Type Description
dict

Nested dictionary: {material: {species: LUT, ...}, ...}

Source code in ngimager/io/lut.py
def build_lut_registry(
    lut_paths: Dict[str, Dict[str, str]] | None,
    base_dir: str | Path | None = None,
) -> Dict[str, Dict[str, LUT]]:
    """
    Build a registry mapping material -> species -> LUT.

    Parameters
    ----------
    lut_paths
        Configuration-style mapping, e.g.:

            {
                "M600": {"proton": "data/lut/M600/lut_M600_proton_Birks.npz"},
                "OGS":  {"carbon": "custom/OGS_carbon.npz"},
            }

        Paths may be relative; they are resolved against `base_dir` when given.
        If a configured path does not exist on disk but a built-in LUT is
        available for that material/species (M600/OGS proton/carbon), the
        built-in is used as a fallback.

        When a material/species is *omitted* entirely from `lut_paths`, this
        function will still inject built-in defaults for common NOVO
        scintillators (M600, OGS).

    base_dir
        Base directory for resolving relative paths (typically the directory
        containing the TOML config). If None, uses the current working
        directory.

    Returns
    -------
    dict
        Nested dictionary: {material: {species: LUT, ...}, ...}
    """
    if lut_paths is None:
        lut_paths = {}

    base = Path(base_dir) if base_dir is not None else Path(".")

    registry: Dict[str, Dict[str, LUT]] = {}

    # ------------------------------------------------------------------
    # 1) Explicit configuration entries
    # ------------------------------------------------------------------
    for material, species_map in lut_paths.items():
        if not species_map:
            continue

        mat_key = str(material)
        mat_reg = registry.setdefault(mat_key, {})

        for species, raw_path in species_map.items():
            sp_key = str(species)

            # Resolve path if provided
            path: Path
            if raw_path:
                p = Path(raw_path)
                if not p.is_absolute():
                    p = base / p
                if p.exists():
                    path = p
                else:
                    # Config path is missing; fall back to built-in if available
                    try:
                        path = builtin_lut_path(mat_key, sp_key)
                    except FileNotFoundError as exc:
                        raise FileNotFoundError(
                            f"LUT path '{raw_path}' for {mat_key}/{sp_key} "
                            f"does not exist and no built-in LUT is available."
                        ) from exc
            else:
                # Empty string / falsy path => force built-in for known materials
                try:
                    path = builtin_lut_path(mat_key, sp_key)
                except FileNotFoundError as exc:
                    raise FileNotFoundError(
                        f"No LUT path specified for {mat_key}/{sp_key} "
                        f"and no built-in LUT is available."
                    ) from exc

            mat_reg[sp_key] = load_npz_lut(path)

    # ------------------------------------------------------------------
    # 2) Built-in defaults for common NOVO scintillators
    #
    #    This is what lets a fresh 'pip install ng-imager' user write:
    #
    #        [energy]
    #        strategy = "ELUT"
    #
    #    and rely on packaged M600/OGS proton+carbon LUTs without any
    #    [energy.lut_paths.*] block.
    # ------------------------------------------------------------------
    builtin_defaults = {
        "M600": ("proton", "carbon"),
        "OGS": ("proton", "carbon"),
    }

    for material, species_list in builtin_defaults.items():
        mat_reg = registry.setdefault(material, {})
        for species in species_list:
            if species in mat_reg:
                # User already configured (or partially overrode) this species.
                continue
            try:
                path = builtin_lut_path(material, species)
            except FileNotFoundError:
                # If we somehow don't ship this LUT, just skip quietly.
                continue
            mat_reg[species] = load_npz_lut(path)

    return registry
builtin_lut_path
builtin_lut_path(material, species)

Return path to a built-in LUT .npz for given material/species.

Source code in ngimager/io/lut.py
def builtin_lut_path(material: str, species: str) -> Path:
    """Return path to a built-in LUT .npz for given material/species."""
    try:
        return res.files(f"ngimager.data.lut.{material}") / f"lut_{material}_{species}_Birks.npz"
    except ModuleNotFoundError:
        raise FileNotFoundError(f"No built-in LUT found for {material}/{species}")

physics

cones

build_cone_from_gamma
build_cone_from_gamma(ev, energy_model, plane=None, prior=None, return_meta=False, return_perm=False)

Build a Compton gamma cone from a three-hit GammaEvent.

Behavior without plane/prior (backwards-compatible, PHITS-oriented): - Use ev.ordered() so that h1, h2, h3 are in increasing time, which is physically the true order in PHITS data. - Attempt to build a cone from this ordered triplet using _gamma_cone_from_ordered_hits. - If no physically valid cone exists for this ordering, raise ValueError.

Enhanced behavior when plane is provided: - Generate all 3! permutations of (h1, h2, h3). - For each ordering: * call _gamma_cone_from_ordered_hits(h1, h2, h3), * discard if it returns None (non-physical), * discard if the cone axis does not point toward the plane (t_int <= 0 via _axis_towards_plane), * compute Δ = |φ − θ| using the configured prior or, if prior is None, the plane center as an implicit prior. - Select the candidate with minimal Δ. - If no candidate survives, fall back to the ordered (time) triplet as in the simple behavior; if that also fails, raise ValueError.

Return value
  • If return_meta and return_perm are both False (default), returns only a Cone.
  • If return_meta is True and return_perm is False, returns (cone, Eg_MeV) where Eg_MeV is the incident gamma energy for the selected ordering.
  • If return_meta is False and return_perm is True, returns (cone, perm) where perm is a tuple (i0, i1, i2) with indices into the event's time-ordered hit list.
  • If both return_meta and return_perm are True, returns (cone, Eg_MeV, perm).
Notes
  • For now, we do not use energy_model for gammas: Hit.L is already the deposited energy in MeV (Edep) from the adapter.

  • This function is designed so that callers who do not yet pass a Plane or Prior still get the old, simple behavior.

Source code in ngimager/physics/cones.py
def build_cone_from_gamma(
    ev: GammaEvent,
    energy_model: EnergyStrategy,
    plane: Optional[Plane] = None,
    prior: Optional[Prior] = None,
    return_meta: bool = False,
    return_perm: bool = False,
) -> Cone:
    """
    Build a Compton gamma cone from a three-hit GammaEvent.

    Behavior without plane/prior (backwards-compatible, PHITS-oriented):
      - Use ev.ordered() so that h1, h2, h3 are in increasing time,
        which is physically the true order in PHITS data.
      - Attempt to build a cone from this ordered triplet using
        _gamma_cone_from_ordered_hits.
      - If no physically valid cone exists for this ordering, raise ValueError.

    Enhanced behavior when `plane` is provided:
      - Generate all 3! permutations of (h1, h2, h3).
      - For each ordering:
          * call _gamma_cone_from_ordered_hits(h1, h2, h3),
          * discard if it returns None (non-physical),
          * discard if the cone axis does not point toward the plane
            (t_int <= 0 via _axis_towards_plane),
          * compute Δ = |φ − θ| using the configured prior or, if prior is None,
            the plane center as an implicit prior.
      - Select the candidate with minimal Δ.
      - If no candidate survives, fall back to the ordered (time) triplet
        as in the simple behavior; if that also fails, raise ValueError.

    Return value
    ------------
    * If return_meta and return_perm are both False (default), returns only
      a Cone.
    * If return_meta is True and return_perm is False, returns (cone, Eg_MeV)
      where Eg_MeV is the incident gamma energy for the selected ordering.
    * If return_meta is False and return_perm is True, returns (cone, perm)
      where perm is a tuple (i0, i1, i2) with indices into the event's
      time-ordered hit list.
    * If both return_meta and return_perm are True, returns
      (cone, Eg_MeV, perm).

    Notes
    -----
    * For now, we do not use `energy_model` for gammas: Hit.L is already
      the deposited energy in MeV (Edep) from the adapter.

    * This function is designed so that callers who do not yet pass a Plane
      or Prior still get the old, simple behavior.
    """
    # Ensure we have a time-ordered GammaEvent (PHITS case)
    ev_ord = ev.ordered(copy=True)
    hits = [ev_ord.h1, ev_ord.h2, ev_ord.h3]

    def _pack_return(cone: Cone, Eg: float) -> Cone:
        """
        Helper to package return values according to return_meta/return_perm.
        """
        perm_default = (0, 0, 0)  # will be overridden when we really track a perm
        if not return_meta and not return_perm:
            return cone
        if return_meta and not return_perm:
            return cone, float(Eg)
        if not return_meta and return_perm:
            # For callers that only care about the permutation and not Eg,
            # the caller will override perm_default with the actual perm.
            return cone, perm_default
        # Both meta and permutation requested
        return cone, float(Eg), perm_default

    # Backwards-compatible path: no plane provided → use only the ordered triplet
    if plane is None:
        cone, Eg = _gamma_cone_from_ordered_hits(*hits, return_Eg=True)
        if cone is None:
            raise ValueError(
                "GammaEvent cannot produce a physical Compton cone from ordered hits."
            )

        # Simple case: the ordering is just (0, 1, 2) in time
        base_perm = (0, 1, 2)
        if not return_perm:
            if return_meta:
                return cone, float(Eg)
            return cone
        else:
            if return_meta:
                return cone, float(Eg), base_perm
            return cone, base_perm

    # Full permutation + prior-aware scoring path
    best_cone: Cone | None = None
    best_score: float | None = None
    best_Eg: float | None = None
    best_perm: tuple[int, int, int] | None = None

    for perm in permutations((0, 1, 2), 3):
        i0, i1, i2 = perm
        h1, h2, h3 = hits[i0], hits[i1], hits[i2]
        c, Eg = _gamma_cone_from_ordered_hits(h1, h2, h3, return_Eg=True)
        if c is None:
            continue

        # Reject cones whose axis does not point toward the imaging plane
        if not _axis_towards_plane(c.apex, c.dir, plane):
            continue

        # Δ = |φ − θ| using explicit prior or implicit plane-center prior
        score = _score_cone_against_prior(c, plane, prior)
        if score is None:
            # Degenerate prior geometry; treat as unusable candidate
            continue

        if best_cone is None or score < best_score:
            best_cone = c
            best_score = score
            best_Eg = float(Eg)
            best_perm = perm

    if best_cone is not None and best_perm is not None:
        Eg = float(best_Eg) if best_Eg is not None else float("nan")
        if not return_perm:
            if return_meta:
                return best_cone, Eg
            return best_cone
        else:
            if return_meta:
                return best_cone, Eg, best_perm
            return best_cone, best_perm

    # If no candidate survived, fall back to the time-ordered triplet as a last resort
    fallback_cone, fallback_Eg = _gamma_cone_from_ordered_hits(*hits, return_Eg=True)
    if fallback_cone is None or not _axis_towards_plane(
        fallback_cone.apex, fallback_cone.dir, plane
    ):
        raise ValueError(
            "GammaEvent cannot produce a physical Compton cone from any hit permutation."
        )

    base_perm = (0, 1, 2)
    if not return_perm:
        if return_meta:
            return fallback_cone, float(fallback_Eg)
        return fallback_cone
    else:
        if return_meta:
            return fallback_cone, float(fallback_Eg), base_perm
        return fallback_cone, base_perm
build_cone_from_neutron
build_cone_from_neutron(ev, energy_model, plane=None, prior=None, force_proton=False, return_meta=False)

Build a neutron cone using the NOVO imaging primer convention:

  • apex O = X1 (first hit position),
  • axis D = unit vector along the scattered neutron direction (X2 - X1),
  • half-angle θ from elastic n–N kinematics in the lab frame.
Behavior (high level)
  • The event is assumed to be time-ordered (h1 before h2); callers should use ev.ordered() upstream, as the pipeline already does.

  • We always use the full kinematic chain from kinematics.py:

    E' = E_n' from ToF between hits 1→2 (relativistic), En = E' + E_dep,1, θ = θ_lab(E_dep,1, En, A) with A = m_recoil / m_n,

where E_dep,1 is obtained from energy_model.first_scatter_energy(...) and A is set by the assumed recoil nucleus ("H" or "C").

  • If force_proton is True, or if plane is None, we build a single proton-recoil hypothesis and return it (backwards-compatible path).

  • Otherwise, we build both proton and carbon hypotheses, reject any that are non-physical, enforce that the cone axis points toward the imaging plane, and then score the survivors against the prior using the same Δ = |φ − θ| metric used for gammas. The winner is the hypothesis with the smallest Δ.

If both hypotheses fail scoring (e.g. degenerate prior geometry), we fall back to the proton-only construction.

Notes
  • This function does not mutate the event or record which hypothesis "won"; that bookkeeping is left to callers via the returned recoil_code and En.
Source code in ngimager/physics/cones.py
def build_cone_from_neutron(
    ev: NeutronEvent,
    energy_model: EnergyStrategy,
    plane: Optional[Plane] = None,
    prior: Optional[Prior] = None,
    force_proton: bool = False,
    return_meta: bool = False,
) -> Cone | tuple[Cone, int]:
    """
    Build a neutron cone using the NOVO imaging primer convention:

      - apex O = X1 (first hit position),
      - axis D = unit vector along the scattered neutron direction (X2 - X1),
      - half-angle θ from elastic n–N kinematics in the lab frame.

    Behavior (high level)
    ---------------------
    * The event is assumed to be time-ordered (h1 before h2); callers
      should use ev.ordered() upstream, as the pipeline already does.

    * We always use the full kinematic chain from kinematics.py:

          E'   = E_n' from ToF between hits 1→2 (relativistic),
          En   = E' + E_dep,1,
          θ    = θ_lab(E_dep,1, En, A) with A = m_recoil / m_n,

      where E_dep,1 is obtained from `energy_model.first_scatter_energy(...)`
      and A is set by the assumed recoil nucleus ("H" or "C").

    * If `force_proton` is True, or if `plane` is None, we build a single
      proton-recoil hypothesis and return it (backwards-compatible path).

    * Otherwise, we build both proton and carbon hypotheses, reject any
      that are non-physical, enforce that the cone axis points toward
      the imaging plane, and then score the survivors against the prior
      using the same Δ = |φ − θ| metric used for gammas. The winner is
      the hypothesis with the smallest Δ.

      If both hypotheses fail scoring (e.g. degenerate prior geometry),
      we fall back to the proton-only construction.

    Metadata / return value
    -----------------------
    * By default (return_meta=False), the function returns only a Cone.

    * If return_meta is True, it returns (cone, recoil_code, En_MeV) where:

          recoil_code = 1  → proton recoil hypothesis ("H")
          recoil_code = 2  → carbon recoil hypothesis ("C")
          En_MeV      = incident neutron energy for the selected hypothesis

      This is suitable for compact storage in HDF5 and for cone-level
      max-energy cuts. Callers that do not care about the recoil species
      or En can continue to use the old, cone-only behavior.

    Notes
    -----
    * This function does not mutate the event or record which hypothesis
      "won"; that bookkeeping is left to callers via the returned
      recoil_code and En.
    """
    # Basic sanity: caller should already have ordered() + validate(), but
    # doing a light check here keeps this function robust for standalone use.
    ev.validate(strict=False)
    h1, h2 = ev.h1, ev.h2

    r1 = np.asarray(h1.r, dtype=float)
    r2 = np.asarray(h2.r, dtype=float)
    t1 = float(h1.t_ns)
    t2 = float(h2.t_ns)

    # Axis: scattered neutron direction (h1 → h2), normalized
    D = r1 - r2
    L = np.linalg.norm(D)
    if not np.isfinite(L) or L <= 0.0:
        raise ValueError("Zero or non-physical baseline between hits in NeutronEvent.")

    Dhat = D / L
    apex = r1.copy()

    def _candidate_for_nucleus(
        recoil_nucleus: str,
    ) -> tuple[Optional[Cone], Optional[float], Optional[float]]:
        """
        Helper: construct a Cone for a given recoil nucleus label ("H" or "C")
        and return (cone, score, En) where:

          - cone  : Cone instance or None if non-physical
          - score : Δ = |φ − θ| if a prior is available, else None
          - En    : incident neutron energy [MeV] for this hypothesis, or None
        """
        # Decide which species key to use for the energy strategy.
        # For ELUT, we have distinct proton/carbon bands; for other
        # strategies (ToF, FixedEn, Edep) we keep proton-like behavior
        # for E_dep,1 to remain compatible with legacy usage.
        species_key = "proton"
        if getattr(energy_model, "name", None) == "ELUT":
            species_key = "proton" if recoil_nucleus == "H" else "carbon"

        # E_dep at first scatter from the energy model.
        try:
            Edep1_MeV, _ = energy_model.first_scatter_energy(
                h1,
                h2,
                h1.material,
                species=species_key,
            )
        except Exception:
            return None, None, None

        # Full COM → lab mapping using primer-consistent kinematics.
        try:
            theta, En = neutron_theta_from_hits(
                r1, t1,
                r2, t2,
                Edep1_MeV,
                scatter_nucleus=recoil_nucleus,
                return_En=True,
            )
        except Exception:
            return None, None, None

        # Reject clearly non-physical / degenerate angles
        if (not np.isfinite(theta)) or (theta <= 0.0) or (theta >= np.pi):
            return None, None, None

        c = Cone(apex=apex, direction=Dhat, theta=float(theta))

        # If we have a plane, enforce that the cone axis points toward it.
        if plane is not None and (not _axis_towards_plane(apex, Dhat, plane)):
            return None, None, None

        # If we don't have a plane, we can't do prior-based scoring.
        if plane is None:
            return c, None, float(En)

        score = _score_cone_against_prior(c, plane, prior)
        return c, score, float(En)

    # Proton-only path: either explicitly requested, or no plane available.
    if force_proton or plane is None:
        c_p, _, En_p = _candidate_for_nucleus("H")
        if c_p is None:
            raise ValueError("NeutronEvent cannot produce a physical proton-recoil cone.")

        if return_meta:
            # 1 = proton recoil
            return c_p, 1, float(En_p)
        return c_p

    # Full proton vs carbon hypothesis test with prior-aware scoring.
    # Store tuples of (score, recoil_nucleus, cone, En).
    candidates: list[tuple[float, str, Cone, float]] = []

    for nuc in ("H", "C"):
        c, score, En = _candidate_for_nucleus(nuc)
        if c is None:
            continue
        # If scoring failed (e.g. degenerate prior geometry), skip from
        # the prior-based comparison.
        if score is None:
            continue
        candidates.append((float(score), nuc, c, float(En)))

    if candidates:
        # Pick the cone with minimal Δ = |φ − θ|
        candidates.sort(key=lambda scn: scn[0])
        best_score, best_nuc, best_cone, best_En = candidates[0]

        if return_meta:
            recoil_code = 1 if best_nuc == "H" else 2  # 1=proton, 2=carbon
            return best_cone, recoil_code, float(best_En)
        return best_cone

    # If we got here, either both hypotheses were non-physical or prior
    # scoring failed for both. Fall back to proton-only construction
    # without using the prior.
    c_p, _, En_p = _candidate_for_nucleus("H")
    if c_p is None:
        raise ValueError("NeutronEvent cannot produce a physical cone (proton or carbon).")

    if return_meta:
        # In this fallback, we default to proton-like tagging.
        return c_p, 1, float(En_p)
    return c_p
enumerate_gamma_cone_candidates
enumerate_gamma_cone_candidates(ev)

Enumerate all physically valid Compton cones for the 3! permutations of a three-hit GammaEvent.

Parameters:

Name Type Description Default
ev GammaEvent

A GammaEvent with exactly three hits (h1, h2, h3). The event is assumed to be already validated for basic consistency.

required

Returns:

Name Type Description
candidates list[tuple[Cone, tuple[int, int, int]]]

List of (cone, perm) tuples where:

  • cone is a Cone instance produced by _gamma_cone_from_ordered_hits
  • perm is a tuple of indices (i0, i1, i2) into (h1, h2, h3), describing which hit played the role of first/second/third scatter in the kinematic construction.

Only permutations that yield a physically valid Compton cone (non-negative energies, sensible angles, non-degenerate geometry) are returned. If no permutation is viable, the list is empty.

Notes
  • This function is kinematics-only: it does NOT apply any priors or scoring; it simply reports all physically allowed cones.

  • Subsequent stages (e.g. in the pipeline) can:

    • apply event- or cone-level filters to the candidates, and
    • use spatial/energy priors to select a "best" cone for imaging.
Source code in ngimager/physics/cones.py
def enumerate_gamma_cone_candidates(
    ev: GammaEvent,
) -> list[tuple[Cone, tuple[int, int, int]]]:
    """
    Enumerate all physically valid Compton cones for the 3! permutations
    of a three-hit GammaEvent.

    Parameters
    ----------
    ev:
        A GammaEvent with exactly three hits (h1, h2, h3). The event is
        assumed to be already validated for basic consistency.

    Returns
    -------
    candidates:
        List of (cone, perm) tuples where:

          - cone is a Cone instance produced by _gamma_cone_from_ordered_hits
          - perm is a tuple of indices (i0, i1, i2) into (h1, h2, h3),
            describing which hit played the role of first/second/third
            scatter in the kinematic construction.

        Only permutations that yield a physically valid Compton cone
        (non-negative energies, sensible angles, non-degenerate geometry)
        are returned. If no permutation is viable, the list is empty.

    Notes
    -----
    * This function is kinematics-only: it does NOT apply any priors
      or scoring; it simply reports all physically allowed cones.

    * Subsequent stages (e.g. in the pipeline) can:

        - apply event- or cone-level filters to the candidates, and
        - use spatial/energy priors to select a "best" cone for imaging.
    """
    # Access hits in a stable order; for now GammaEvent always has h1..h3.
    hits = [ev.h1, ev.h2, ev.h3]
    candidates: list[tuple[Cone, tuple[int, int, int]]] = []

    # Enumerate all permutations of (0, 1, 2). For each permutation, treat
    # hits[i0] as the first scatter, hits[i1] as the second, and hits[i2]
    # as the "third" (used only for geometry).
    for perm in itertools.permutations((0, 1, 2), 3):
        i0, i1, i2 = perm
        cone = _gamma_cone_from_ordered_hits(hits[i0], hits[i1], hits[i2], return_Eg=False)
        if cone is None:
            continue
        candidates.append((cone, perm))

    return candidates

energy_strategies

EnergyFromDeposited

Bases: EnergyStrategy

Treat Hit.L as deposited energy (MeV) directly.

This is intended for synthetic/sim sources like PHITS where Hit.L has already been filled from Edep_MeV in the adapter, so no E(L) inversion or ToF logic is needed.

EnergyFromFixedIncident
EnergyFromFixedIncident(En_MeV=14.1)

Bases: EnergyStrategy

Monoenergetic incident neutron energy (e.g. DT source).

This strategy assumes a fixed incident neutron kinetic energy En. For a given 2-hit neutron event, we:

  1. Compute the post-scatter neutron energy E' from ToF between h1 and h2.
  2. Infer the first-scatter deposited energy as Edep1 = En - E'.
  3. Reject the event if E' >= En (non-physical upscatter).

The returned value is Edep1, which downstream kinematics combine with E' to reconstruct En again. This keeps the math consistent with neutron_theta_from_hits while enforcing monoenergetic DT semantics.

Source code in ngimager/physics/energy_strategies.py
def __init__(self, En_MeV: float = 14.1):
    self.En = En_MeV
EnergyFromToF
EnergyFromToF(timing_sigma_ns=0.5)

Bases: EnergyStrategy

Compute E' from ToF, then E_total = dE + E'.

Source code in ngimager/physics/energy_strategies.py
def __init__(self, timing_sigma_ns: float = 0.5):
    self.sigma_t = timing_sigma_ns
EnergyStrategy

Base protocol: compute first-scatter energy and optional σ.

first_scatter_energy
first_scatter_energy(h1, h2, material, species='proton')

Parameters:

Name Type Description Default
h1 Hit

First and optional second hits in the event.

required
h2 Hit

First and optional second hits in the event.

required
material str

Material key (e.g. "OGS", "M600") for LUT-based strategies.

required
species Literal['proton', 'carbon'] | None

Recoil species key when relevant (e.g. "proton", "carbon").

'proton'

Returns:

Name Type Description
Edep1_MeV float

First-scatter deposited energy [MeV] to feed into the kinematics.

sigma_MeV float or None

Optional uncertainty estimate on Edep1, or None if not provided.

Source code in ngimager/physics/energy_strategies.py
def first_scatter_energy(
    self,
    h1: Hit,
    h2: Hit | None,
    material: str,
    species: Literal["proton","carbon"] | None = "proton",
) -> tuple[float, float | None]:
    """
    Parameters
    ----------
    h1, h2
        First and optional second hits in the event.
    material
        Material key (e.g. "OGS", "M600") for LUT-based strategies.
    species
        Recoil species key when relevant (e.g. "proton", "carbon").

    Returns
    -------
    Edep1_MeV : float
        First-scatter deposited energy [MeV] to feed into the kinematics.
    sigma_MeV : float or None
        Optional uncertainty estimate on Edep1, or None if not provided.
    """
    raise NotImplementedError

events

GammaEvent dataclass
GammaEvent(h1, h2, h3, meta=dict())

Three-interaction gamma event.

As with NeutronEvent, hits can arrive unsequenced; use .ordered() to get a time-ordered copy and .validate() to assert ordering.

ordered
ordered(copy=True)

Return a GammaEvent with hits sorted by t_ns (h1 earliest).

If copy=False, reorders self in-place and returns self.

Source code in ngimager/physics/events.py
def ordered(self, copy: bool = True) -> "GammaEvent":
    """
    Return a GammaEvent with hits sorted by t_ns (h1 earliest).

    If copy=False, reorders self in-place and returns self.
    """
    hits = self._sorted_hits()
    if copy:
        return GammaEvent(h1=hits[0], h2=hits[1], h3=hits[2], meta=dict(self.meta))
    self.h1, self.h2, self.h3 = hits
    return self
validate
validate(strict=True)

Raise ValueError if hits are not in (weakly/strictly) increasing time.

Source code in ngimager/physics/events.py
def validate(self, strict: bool = True) -> None:
    """
    Raise ValueError if hits are not in (weakly/strictly) increasing time.
    """
    if not self.is_time_ordered(strict=strict):
        raise ValueError(
            f"GammaEvent time order violation: "
            f"[{self.h1.t_ns}, {self.h2.t_ns}, {self.h3.t_ns}]"
        )
NeutronEvent dataclass
NeutronEvent(h1, h2, meta=dict())

Two-scatter neutron event.

Hits can be unsequenced when first ingested (e.g. from ROOT/PHITS), so use .ordered() to get a time-ordered event and .validate() to assert the ordering.

ordered
ordered(copy=True)

Return a NeutronEvent with hits ordered by t_ns (h1 earliest).

If copy=False, reorders self in-place and returns self.

Source code in ngimager/physics/events.py
def ordered(self, copy: bool = True) -> "NeutronEvent":
    """
    Return a NeutronEvent with hits ordered by t_ns (h1 earliest).

    If copy=False, reorders self in-place and returns self.
    """
    hits = [self.h1, self.h2]
    hits.sort(key=lambda h: h.t_ns)
    if copy:
        return NeutronEvent(h1=hits[0], h2=hits[1], meta=dict(self.meta))
    self.h1, self.h2 = hits
    return self
validate
validate(strict=True)

Raise ValueError if the hits are not in time order.

Source code in ngimager/physics/events.py
def validate(self, strict: bool = True) -> None:
    """
    Raise ValueError if the hits are not in time order.
    """
    if not self.is_time_ordered(strict=strict):
        raise ValueError(
            f"NeutronEvent time order violation: "
            f"h1.t_ns={self.h1.t_ns}, h2.t_ns={self.h2.t_ns}"
        )

hits

Hit dataclass
Hit(det_id, r, t_ns, L=0.0, type='UNK', material='UNK', sigma_r_cm=None, sigma_t_ns=None, sigma_L=None, extras=dict())

Canonical detector hit (physics layer).

r: position [cm] t_ns: time [ns] L: light-like measure (e.g., Elong) (dimensionless or MeVee-scale per your LUT) type: particle tag for this hit (e.g., "n" for neutron, "g" for gamma, "UNK" if unknown) material: detector material tag (e.g., "M600") extras: arbitrary per-hit fields preserved from input (psd, dE_MeV, raw columns...)

kinematics

compton_incident_energy_from_second_scatter
compton_incident_energy_from_second_scatter(dE1_MeV, dE2_MeV, theta2_rad)

Incident gamma energy Eg [MeV] from:

  • dE1: energy deposited at 1st scatter [MeV]
  • dE2: energy deposited at 2nd scatter [MeV]
  • theta2: angle between 1->2 and 2->3 baselines [rad]

This mirrors the NOVO primer / legacy implementation:

Eg = dE1 + 0.5 * ( dE2 + sqrt( dE2^2 + 4*dE2*me / (1 - cos(theta2)) ) )

Raises:

Type Description
ValueError

If inputs are non-physical (negative energies, grazing angles, etc.).

Source code in ngimager/physics/kinematics.py
def compton_incident_energy_from_second_scatter(
    dE1_MeV: float,
    dE2_MeV: float,
    theta2_rad: float,
) -> float:
    """
    Incident gamma energy Eg [MeV] from:

      - dE1: energy deposited at 1st scatter [MeV]
      - dE2: energy deposited at 2nd scatter [MeV]
      - theta2: angle between 1->2 and 2->3 baselines [rad]

    This mirrors the NOVO primer / legacy implementation:

        Eg = dE1 + 0.5 * ( dE2 + sqrt( dE2^2 + 4*dE2*me / (1 - cos(theta2)) ) )

    Raises
    ------
    ValueError
        If inputs are non-physical (negative energies, grazing angles, etc.).
    """
    if dE1_MeV <= 0.0 or dE2_MeV <= 0.0:
        raise ValueError(f"Non-positive gamma deposits: dE1={dE1_MeV}, dE2={dE2_MeV}")

    cos_t2 = float(np.cos(theta2_rad))
    denom = 1.0 - cos_t2
    if denom <= 0.0:
        raise ValueError(f"Non-physical second-scatter angle theta2={theta2_rad} (denominator <= 0).")

    radicand = dE2_MeV**2 + (4.0 * dE2_MeV * M_E_MEV / denom)
    if radicand <= 0.0:
        raise ValueError(f"Non-physical Compton radicand={radicand} for gamma cone.")

    Eg = dE1_MeV + 0.5 * (dE2_MeV + float(np.sqrt(radicand)))
    if not np.isfinite(Eg) or Eg <= 0.0:
        raise ValueError(f"Non-physical incident gamma energy Eg={Eg}")

    return Eg
compton_theta_from_energies
compton_theta_from_energies(Eg_MeV, Egp_MeV)

First Compton scatter angle theta1 [rad] from:

Eg  : incident gamma energy [MeV]
Egp : post-first-scatter gamma energy [MeV]

Uses the standard Compton relation:

cos(theta) = 1 + me * (1/Eg - 1/Egp)

Raises:

Type Description
ValueError

If energies are non-physical or the argument to arccos is out of [-1, 1].

Source code in ngimager/physics/kinematics.py
def compton_theta_from_energies(Eg_MeV: float, Egp_MeV: float) -> float:
    """
    First Compton scatter angle theta1 [rad] from:

        Eg  : incident gamma energy [MeV]
        Egp : post-first-scatter gamma energy [MeV]

    Uses the standard Compton relation:

        cos(theta) = 1 + me * (1/Eg - 1/Egp)

    Raises
    ------
    ValueError
        If energies are non-physical or the argument to arccos is out of [-1, 1].
    """
    if Eg_MeV <= 0.0 or Egp_MeV <= 0.0 or Egp_MeV >= Eg_MeV:
        raise ValueError(f"Non-physical gamma energies Eg={Eg_MeV}, Eg'={Egp_MeV}")

    arg = 1.0 + M_E_MEV * ((1.0 / Eg_MeV) - (1.0 / Egp_MeV))

    # If we drift outside [-1, 1] more than a tiny epsilon, treat as non-physical
    if arg < -1.0 - 1e-6 or arg > 1.0 + 1e-6:
        raise ValueError(f"Compton argument out of domain: arg={arg}")

    arg = float(np.clip(arg, -1.0, 1.0))
    theta = float(np.arccos(arg))
    return theta
neutron_theta_from_hits
neutron_theta_from_hits(r1_cm, t1_ns, r2_cm, t2_ns, Edep1_MeV, scatter_nucleus='H', return_En=False)

Full calculation consistent with the NOVO primer: E' via ToF between hits 1->2 (relativistic), E_n = E' + Edep1, theta_lab from COM using A = m_recoil/m_n.

If return_En is False (default), returns theta [rad]. If return_En is True, returns (theta [rad], En [MeV]).

Source code in ngimager/physics/kinematics.py
def neutron_theta_from_hits(
    r1_cm: np.ndarray, t1_ns: float,
    r2_cm: np.ndarray, t2_ns: float,
    Edep1_MeV: float,
    scatter_nucleus: str = "H",
    return_En: bool = False,
) -> float:
    """
    Full calculation consistent with the NOVO primer:
      E' via ToF between hits 1->2 (relativistic),
      E_n = E' + Edep1,
      theta_lab from COM using A = m_recoil/m_n.

    If return_En is False (default), returns theta [rad].
    If return_En is True, returns (theta [rad], En [MeV]).
    """
    s = float(np.linalg.norm(r2_cm - r1_cm))
    dt = float(t2_ns - t1_ns)
    Eprime = tof_energy_relativistic(s, dt)      # MeV
    En = Eprime + Edep1_MeV                      # MeV
    A = mass_ratio_A(scatter_nucleus)
    theta = theta_lab_from_Erecoil_En(Edep1_MeV, En, A)
    if return_En:
        return float(theta), float(En)
    return float(theta)
theta_lab_from_Erecoil_En
theta_lab_from_Erecoil_En(E_recoil, E_n, A)

Compute neutron lab-frame scattering half-angle [rad] from E_recoil, E_n, and A = m_recoil/m_n. Follows primer equations for theta_CoM then lab mapping.

Source code in ngimager/physics/kinematics.py
def theta_lab_from_Erecoil_En(E_recoil: float, E_n: float, A: float) -> float:
    """
    Compute neutron lab-frame scattering half-angle [rad] from E_recoil, E_n, and A = m_recoil/m_n.
    Follows primer equations for theta_CoM then lab mapping.
    """
    if E_recoil <= 0 or E_n <= 0 or E_recoil > E_n:
        raise ValueError("Non-physical energies for theta.")
    # theta_CoM
    cos_arg = 1.0 - (E_recoil / E_n) * ((1.0 + A)**2) / (2.0 * A)
    # guard numerical drift
    cos_arg = np.clip(cos_arg, -1.0, 1.0)
    theta_CoM = np.arccos(cos_arg)

    # theta_lab
    num = np.sin(theta_CoM)
    den = np.cos(theta_CoM) + (1.0 / A)
    return np.arctan2(num, den)
tof_energy_relativistic
tof_energy_relativistic(s_cm, dt_ns)

Relativistic neutron KE E' [MeV] from flight distance s [cm] and time dt [ns].

Source code in ngimager/physics/kinematics.py
def tof_energy_relativistic(s_cm: float, dt_ns: float) -> float:
    """
    Relativistic neutron KE E' [MeV] from flight distance s [cm] and time dt [ns].
    """
    if dt_ns <= 0:
        raise ValueError("Non-positive ToF; cannot compute E'.")
    v = s_cm / dt_ns  # cm/ns
    beta2 = (v / C_CM_PER_NS)**2
    if beta2 <= 0 or beta2 >= 1:
        raise ValueError("Non-physical beta^2 from s/dt.")
    gamma = 1.0 / np.sqrt(1.0 - beta2)
    return (gamma - 1.0) * M_N_MEV

priors

Prior

Bases: Protocol

weight_field
weight_field(plane)

Return (nv, nu) float32 weights in [0,1].

Source code in ngimager/physics/priors.py
def weight_field(self, plane: Plane) -> np.ndarray:
    """Return (nv, nu) float32 weights in [0,1]."""
make_prior
make_prior(cfg_prior, plane)

Small factory used by pipelines.core; returns a Prior or None.

Expected cfg_prior schema (from TOML, after Pydantic):

[prior] type = "none" | "point" | "line" strength = 1.0

# Point prior: # either: # point = [x, y, z] # or (future) nested [prior.point] can be normalized upstream.

# Line prior (preferred, nested): # [prior.line] # p0 = [x0, y0, z0] # p1 = [x1, y1, z1] # or: # [prior.line] # r0 = [x0, y0, z0] # direction = [dx, dy, dz] # # For backward compatibility we also accept flat: # line_p0 = [x0, y0, z0] # line_p1 = [x1, y1, z1]

Source code in ngimager/physics/priors.py
def make_prior(cfg_prior: dict, plane: Plane) -> Optional[Prior]:
    """
    Small factory used by pipelines.core; returns a Prior or None.

    Expected cfg_prior schema (from TOML, after Pydantic):

      [prior]
      type = "none" | "point" | "line"
      strength = 1.0

      # Point prior:
      # either:
      #   point = [x, y, z]
      # or (future) nested [prior.point] can be normalized upstream.

      # Line prior (preferred, nested):
      #   [prior.line]
      #   p0 = [x0, y0, z0]
      #   p1 = [x1, y1, z1]
      # or:
      #   [prior.line]
      #   r0        = [x0, y0, z0]
      #   direction = [dx, dy, dz]
      #
      # For backward compatibility we also accept flat:
      #   line_p0 = [x0, y0, z0]
      #   line_p1 = [x1, y1, z1]
    """
    #typ = (cfg_prior.get("type") or "none").lower()
    typ = cfg_prior.get("type", "none")
    strength = float(cfg_prior.get("strength", 1.0))

    if typ == "none":
        return None

    if typ == "point":
        #return PointPrior(np.asarray(cfg_prior["point"], dtype=float), strength=strength)
        if "point" in cfg_prior:
            p = np.asarray(cfg_prior["point"], dtype=float)
        else:
            # default: center of imaging plane
            p = plane.center  # or plane.origin() if we add such a helper
        return PointPrior(p, strength=strength)

    if typ == "line":
        line_cfg = cfg_prior.get("line")
        p0 = p1 = None

        if line_cfg is not None:
            # Preferred nested forms
            if "p0" in line_cfg and "p1" in line_cfg:
                p0 = np.asarray(line_cfg["p0"], dtype=float)
                p1 = np.asarray(line_cfg["p1"], dtype=float)
            elif "r0" in line_cfg and "direction" in line_cfg:
                r0 = np.asarray(line_cfg["r0"], dtype=float)
                direction = np.asarray(line_cfg["direction"], dtype=float)
                p0 = r0
                p1 = r0 + direction
            else:
                raise KeyError(
                    "prior.line must define either p0/p1 or r0/direction "
                    "(e.g. [prior.line] p0=[...] p1=[...] or r0=[...] direction=[...])"
                )
        else:
            # Backwards-compatible flat keys (if someone ever used them)
            if "line_p0" in cfg_prior and "line_p1" in cfg_prior:
                p0 = np.asarray(cfg_prior["line_p0"], dtype=float)
                p1 = np.asarray(cfg_prior["line_p1"], dtype=float)
            else:
                raise KeyError(
                    "Line prior configured with type='line' but no usable "
                    "line geometry found (expected [prior.line] or line_p0/line_p1)."
                )

        return LinePrior(p0, p1, strength=strength)

    raise ValueError(f"Unknown prior.type={cfg_prior['type']!r}")

pipelines

High-level imaging pipelines for ng-imager.

The main entry point is run_pipeline, which is what the ng-run command-line tool calls under the hood.

run_pipeline

run_pipeline(cfg_path, *, fast=None, list_mode=None, neutrons=None, gammas=None, input_path=None, output_path=None, plot_label=None)

Orchestrate the full pipeline from a TOML config file.

CLI flags (--fast/--list/--neutrons/--no-neutrons/--gammas/--no-gammas) override the corresponding [run] fields when not None.

Parameters:

Name Type Description Default
cfg_path str

Path to TOML configuration file.

required
input_path str

When provided, overrides [io].input_path from the TOML file.

None
output_path str

When provided, overrides [io].output_path from the TOML file.

None
plot_label str

When provided, overrides [run].plot_label (used for HDF5 and visualization annotations).

None

Returns:

Type Description
Path to written HDF5 file.
Source code in ngimager/pipelines/core.py
 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
def run_pipeline(
    cfg_path: str,
    *,
    fast: Optional[bool] = None,
    list_mode: Optional[bool] = None,
    neutrons: Optional[bool] = None,
    gammas: Optional[bool] = None,
    input_path: Optional[str] = None,
    output_path: Optional[str] = None,
    plot_label: Optional[str] = None,
) -> Path:
    """
    Orchestrate the full pipeline from a TOML config file.

    CLI flags (--fast/--list/--neutrons/--no-neutrons/--gammas/--no-gammas)
    override the corresponding [run] fields when not None.

    Parameters
    ----------
    cfg_path : str
        Path to TOML configuration file.
    input_path : str, optional
        When provided, overrides [io].input_path from the TOML file.
    output_path : str, optional
        When provided, overrides [io].output_path from the TOML file.
    plot_label : str, optional
        When provided, overrides [run].plot_label (used for HDF5 and
        visualization annotations).

    Returns
    -------
    Path to written HDF5 file.
    """
    #cfg_path = str(cfg_path)
    cfg = load_config(cfg_path)

    # ---- apply CLI overrides on top of TOML ----
    if fast is not None:
        cfg.run.fast = fast
    if list_mode is not None:
        cfg.run.list = list_mode
    if neutrons is not None:
        cfg.run.neutrons = neutrons
    if gammas is not None:
        cfg.run.gammas = gammas

    if input_path is not None:
        cfg.io.input_path = input_path
    if output_path is not None:
        cfg.io.output_path = output_path
    if plot_label is not None:
        cfg.run.plot_label = plot_label

    # Conveniences
    diag_level = cfg.run.diagnostics_level
    verbose = diag_level >= 2

    # Basic logging
    if diag_level >= 1:
        print(f"[run] config = {cfg_path}")
        print(f"[run] neutrons={cfg.run.neutrons} gammas={cfg.run.gammas} "
              f"fast={cfg.run.fast} list={cfg.run.list}")
        print(f"[run] input={cfg.io.input_path} -> output={cfg.io.output_path}")
        if getattr(cfg.run, "plot_label", None):
            print(f"[run] plot_label={cfg.run.plot_label!r}")

    # Apply fast-mode overrides (if run.fast is true)
    _apply_fast_overrides(cfg, diag_level=diag_level)

    # Imaging plane
    plane = Plane.from_cfg(
        cfg.plane.origin,
        cfg.plane.normal,
        cfg.plane.u_min,
        cfg.plane.u_max,
        cfg.plane.du,
        cfg.plane.v_min,
        cfg.plane.v_max,
        cfg.plane.dv,
        eu=cfg.plane.u_axis,
        ev=cfg.plane.v_axis,
    )

    # HDF5 output
    out_path = Path(cfg.io.output_path)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    cli_argv = sys.argv
    f = write_init(str(out_path), cfg_path, cfg, plane, cli_argv=cli_argv)


    # If this is a NOVO DDAQ ROOT run, try to capture the 'meta' TTree.
    if cfg.io.input_format == "root_novo_ddaq":
        adapter_cfg_meta: Dict[str, object] = dict(cfg.io.adapter)

        adapter_for_meta = make_adapter(adapter_cfg_meta)
        extractor = getattr(adapter_for_meta, "read_meta_tree", None)

        if callable(extractor):
            try:
                meta_dict = extractor(str(cfg.io.input_path))
            except Exception as exc:
                if diag_level >= 1:
                    print(f"[meta] Failed to read ROOT meta tree: {exc}")
            else:
                if meta_dict:
                    write_root_novo_meta(f, meta_dict)
                    if diag_level >= 1:
                        print("[meta] ROOT meta tree written to /meta/root_novo_ddaq")
        else:
            if diag_level >= 1:
                print("[meta] Adapter does not support ROOT meta tree extraction; skipping")


    # Shared counters for this run
    counters: Dict[str, int] = {}

    # ---- Stage 1: adapter → raw events → hit-level filters → is_reconstructable ----
    if cfg.io.input_format in ("phits_usrdef", "root_novo_ddaq"):
        from ngimager.filters.shapers import shape_events_for_cones, ShapeConfig
        from ngimager.filters.to_typed_events import shaped_to_typed_events
        from ngimager.filters.hit_filters import apply_hit_filters, is_reconstructable

        if diag_level >= 1:
            print("\n[stage1] Raw events → hits")
            print("[stage1] Using staged path: raw events → hits → shaped → typed")

        # Build adapter config, injecting detector-level material info.
        adapter_cfg: Dict[str, object] = dict(cfg.io.adapter)

        det_cfg = getattr(cfg, "detectors", None)
        if det_cfg is not None:
            mat_map = getattr(det_cfg, "material_map", None)
            if mat_map and "material_map" not in adapter_cfg:
                adapter_cfg["material_map"] = mat_map

            default_mat = getattr(det_cfg, "default_material", None)
            if default_mat and "default_material" not in adapter_cfg:
                adapter_cfg["default_material"] = default_mat

        # --- Geometry: detector-frame → world-frame + per-detector corrections ----
        geom_cfg = getattr(det_cfg, "geometry", None) if det_cfg is not None else None

        # Global frame transform
        frame_cfg = getattr(geom_cfg, "frame", None) if geom_cfg is not None else None
        origin_cm = [0.0, 0.0, 0.0]
        rotation_deg = [0.0, 0.0, 0.0]
        use_global_transform = False
        if frame_cfg is not None:
            origin_cm = getattr(frame_cfg, "origin_cm", origin_cm)
            rotation_deg = getattr(frame_cfg, "rotation_deg", rotation_deg)
            if not is_identity_transform(origin_cm, rotation_deg):
                use_global_transform = True
                if diag_level >= 1:
                    print(
                        "[stage1] Using global detector→world transform: "
                        f"origin_cm={origin_cm}, rotation_deg={rotation_deg}"
                    )

        # Per-detector transforms: id → (origin_cm, rotation_deg)
        per_det_transforms: Dict[int, tuple[list[float], list[float]]] = {}
        if geom_cfg is not None:
            det_list = getattr(geom_cfg, "detectors", []) or []
            for det_entry in det_list:
                try:
                    det_id = int(det_entry.id)
                except Exception:
                    continue
                o = getattr(det_entry, "origin_cm", [0.0, 0.0, 0.0])
                r = getattr(det_entry, "rotation_deg", [0.0, 0.0, 0.0])
                # Skip entries that are effectively identity transforms
                if is_identity_transform(o, r):
                    continue
                per_det_transforms[det_id] = (o, r)

        use_per_det_transforms = bool(per_det_transforms)
        if use_per_det_transforms and diag_level >= 1:
            print(
                "[stage1] Per-detector transforms configured for "
                f"{len(per_det_transforms)} detector IDs"
            )


        adapter = make_adapter(adapter_cfg)

        raw_events_after_filters = []

        for ev in adapter.iter_raw_events(str(cfg.io.input_path)):
            hits = list(ev.get("hits", []))

            # Apply per-detector corrections first (local → detector frame)
            if use_per_det_transforms and hits:
                for h in hits:
                    t_cfg = per_det_transforms.get(h.det_id)
                    if t_cfg is None:
                        continue
                    o_det, r_det = t_cfg
                    # apply_rigid_transform accepts (..., 3); we pass a single row
                    h.r = apply_rigid_transform(h.r[None, :], o_det, r_det)[0]

            # Apply global detector-frame → world-frame transform
            if use_global_transform and hits:
                pts = np.stack([h.r for h in hits], axis=0)
                pts_world = apply_rigid_transform(pts, origin_cm, rotation_deg)
                for h, r_new in zip(hits, pts_world):
                    h.r = r_new

            # Normalize event_type to 'n' / 'g' where possible
            et_raw = str(ev.get("event_type", "")).lower()
            if et_raw.startswith("n"):
                et = "n"
            elif et_raw.startswith("g"):
                et = "g"
            else:
                et = None

            counters["raw_events_total"] = counters.get("raw_events_total", 0) + 1

            # ---- Stage 1.5: Apply Hit-level filters
            filtered_hits = apply_hit_filters(
                hits,
                cfg.filters.hits,
                counters,
                particle_type=et,
            )

            # Early reconstructability decision (also updates *_unreconstructable counters)
            if not is_reconstructable(filtered_hits, cfg.filters.events, counters, event_type=et):
                continue

            if not filtered_hits:
                # Should be caught by is_reconstructable, but guard anyway
                continue

            ev2 = dict(ev)
            ev2["hits"] = filtered_hits
            if et is not None:
                ev2["event_type"] = et
            raw_events_after_filters.append(ev2)

        if diag_level >= 1:
            print(
                "[stage1] raw_events_total={total} "
                "raw_events_after_filters={surv} "
                "raw_events_rejected_unreconstructable={rej}".format(
                    total=counters.get("raw_events_total", 0),
                    surv=len(raw_events_after_filters),
                    rej=counters.get("raw_events_rejected_unreconstructable", 0),
                )
            )


        # ---- Stage 2: Hits → shaped events → typed events ----
        if diag_level >= 1:
            print("\n[stage2] Hits → shaped events → typed events")

        # Shaper configuration:
        #   - PHITS: use default policies (time-ascending for both species).
        #   - ROOT NOVO DDAQ: keep neutrons time-ordered, but pick the
        #     brightest 3 gamma hits when multiplicity > 3.
        shape_cfg = ShapeConfig()
        if cfg.io.input_format == "root_novo_ddaq":
            # ROOT neutrons: enforce time-order like legacy
            shape_cfg.neutron_policy = "time_asc"
            # ROOT gammas: keep brightest-gamma rule
            shape_cfg.gamma_policy = "energy_desc"

        shaped_events, shape_diag = shape_events_for_cones(
            raw_events_after_filters,
            shape_cfg,
            counters=counters,
        )

        if diag_level >= 1:
            print(
                "[stage2] shaper: total_events_in={total} "
                "shaped_n={sn} shaped_g={sg}".format(
                    total=shape_diag.total_events,
                    sn=shape_diag.shaped_neutron,
                    sg=shape_diag.shaped_gamma,
                )
            )


        if diag_level >= 2 and shaped_events:
            print(f"[stage2] Example shaped events (up to first 3):")
            for se in shaped_events[:3]:
                ts = [h.t_ns for h in se.hits]
                Ls = [h.L for h in se.hits]
                types = [h.type for h in se.hits]
                print(
                    f"    species={se.species} "
                    f"n_hits={len(se.hits)} "
                    f"types={types} "
                    f"t_ns={ts} "
                    f"L={Ls}"
                )

        events = shaped_to_typed_events(
            shaped_events,
            order_time=True,
        )

        # ---- Typed events diagnostics (Stage: hits → shaped → typed) ----
        # Species breakdown based on the typed-event objects themselves.
        # This does not assume any particular event-level filters yet.
        from ngimager.physics.events import NeutronEvent, GammaEvent
        n_n = sum(isinstance(ev, NeutronEvent) for ev in events)
        n_g = sum(isinstance(ev, GammaEvent) for ev in events)
        counters["events_typed_total"] = n_n + n_g
        counters["events_typed_n"] = n_n
        counters["events_typed_g"] = n_g
        # Placeholder for future event-level rejections (event filters)
        # so that a later filter stage can do:
        #   counters["events_rejected_filters"] = ...
        events_rejected = counters.get("events_rejected_filters", 0)

        if diag_level >= 1:
            print(
                "[stage2] typed_total={total} "
                "typed_n={n} typed_g={g} "
                "events_rejected_filters={rej}".format(
                    total=len(events),
                    n=n_n,
                    g=n_g,
                    rej=events_rejected,
                )
            )

    else:
        # For non-PHITS sources, keep the existing direct typed-event path.
        events = list(_iter_source_events(cfg))


    # ---- Stage 2.5: Apply Event-level filters (e.g. ToF windows) ----
    events = apply_event_filters(
        events,
        cfg.filters.events,
        counters,
    )

    if diag_level >= 1:
        print(
            "[stage2] events_total_for_filters={etf} "
            "events_after_filters={eaf} "
            "events_rejected_filters={er}".format(
                etf=counters.get("events_total_for_filters", 0),
                eaf=counters.get("events_after_filters", 0),
                er=counters.get("events_rejected_filters", 0),
            )
        )

    # Existing diagnostics on typed events
    if diag_level >= 1:
        print(f"[stage2] Got {len(events)} events")
    if events:
        first = events[0]
        h1 = getattr(first, "h1", None)
        h2 = getattr(first, "h2", None)
        if diag_level >= 2:
            print(f"[stage2] First event type: {type(first).__name__}")
            print(f"[stage2] First event h1: {h1!r}")
            print(f"[stage2] First event h2: {h2!r}")
            if h1 is not None:
                print(f"[stage2] h1.r = {getattr(h1, 'r', None)}, t_ns={h1.t_ns}, L={h1.L}")
            if h2 is not None:
                print(f"[stage2] h2.r = {getattr(h2, 'r', None)}, t_ns={h2.t_ns}, L={h2.L}")
            for ev in events[:3]:
                species = "n" if isinstance(ev, NeutronEvent) else "g" if isinstance(ev, GammaEvent) else "?"
                hlist = [getattr(ev, name) for name in ("h1", "h2", "h3") if hasattr(ev, name)]
                ts = [h.t_ns for h in hlist]
                Ls = [h.L for h in hlist]
                types = [h.type for h in hlist]
                print(
                    f"    {species}-event "
                    f"n_hits={len(hlist)} "
                    f"types={types} "
                    f"t_ns={ts} "
                    f"L={Ls}"
                )

    # Write to output Per-event / per-hit physics (this links back via /lm/events dataset)
    write_events_hits(f, events)



    # ---- Stage 3: Typed events → candidate cones → selected cones ----
    if diag_level >= 1:
        print("\n[stage3] Events → candidate cones → selected cones")
    # Cones from events
    (
        cone_ids,
        apex_xyz_cm,
        axis_xyz,
        theta_rad,
        cone_species,
        recoil_code,
        incident_energy_MeV,
        cone_event_index,
        gamma_hit_order,
    ) = _build_cones_from_events(cfg, events, plane, counters)

    # --- Build per-event cone survival arrays (event → cone_id) ---
    n_events = len(events)
    event_cone_id = np.full(n_events, -1, dtype=np.int32)
    event_imaged_cone_id = np.full(n_events, -1, dtype=np.int32)  # will be filled in LM block if list-mode

    for cid, ev_idx in zip(cone_ids, cone_event_index):
        # Defensive check; ev_idx should be in [0, n_events)
        if 0 <= int(ev_idx) < n_events:
            # For now, each event yields at most one cone; keep the first if ever that changes.
            if event_cone_id[ev_idx] == -1:
                event_cone_id[ev_idx] = int(cid)

    # Counters and diagnostics for cones
    n_cones = int(len(cone_ids))
    n_n = int(np.count_nonzero(cone_species == 0))
    n_g = int(np.count_nonzero(cone_species == 1))
    # Neutron recoil breakdown
    n_p = int(np.count_nonzero((cone_species == 0) & (recoil_code == 1)))
    n_C = int(np.count_nonzero((cone_species == 0) & (recoil_code == 2)))
    n_unknown = max(0, n_n - n_p - n_C)
    _inc(counters, "cones_n_recoil_proton", n_p)
    _inc(counters, "cones_n_recoil_carbon", n_C)
    _inc(counters, "cones_n_recoil_unknown", n_unknown)

    if diag_level >= 1:
        print(
            "[stage3] Built {total} cones "
            "(neutron={n_n}, gamma={n_g})".format(
                total=n_cones,
                n_n=n_n,
                n_g=n_g,
            )
        )
        if n_cones and diag_level >= 2:
            print("[stage3] Example cone apex:", apex_xyz_cm[0])
            print("[stage3] Example cone dir:", axis_xyz[0])
            print("[stage3] Example cone theta[deg]:", np.degrees(theta_rad[0]))
            print(
                "[stage3] Neutron recoil breakdown: "
                f"proton={n_p}, carbon={n_C}, unknown={n_unknown}"
            )

    # Write to output Per-cone geometry + classification
    write_cones(
        f,
        cone_ids,
        apex_xyz_cm,
        axis_xyz,
        theta_rad,
        species=cone_species,
        recoil_code=recoil_code,
        incident_energy_MeV=incident_energy_MeV,
        event_index=cone_event_index,
        gamma_hit_order=gamma_hit_order,
    )

    # ---- Stage 4: Cones → imaging/reconstruction (SPB) ----
    if diag_level >= 1:
        print("\n[stage4] Cones → imaging / reconstruction (SBP)")
    # Choose SBP engine from config
    sbp_engine = getattr(cfg.run, "sbp_engine", "scan")

    # Build Cone objects from the geometry arrays
    cones_for_sbp: list[Cone] = [
        Cone(apex=apex_xyz_cm[i], direction=axis_xyz[i], theta=float(theta_rad[i]))
        for i in range(len(cone_ids))
    ]

    # Partition cones by species for separate images
    cones_n: list[Cone] = []
    cones_g: list[Cone] = []
    for c, s in zip(cones_for_sbp, cone_species):
        if s == 0:
            cones_n.append(c)
        elif s == 1:
            cones_g.append(c)

    img_n = None
    img_g = None

    # Projection / ROI configuration (for HDF5 + visualization)
    vis_cfg = getattr(cfg, "vis", None)
    proj_cfg = getattr(vis_cfg, "projections", None) if vis_cfg is not None else None

    projections_enabled = bool(
        getattr(proj_cfg, "enabled", False)
    ) if proj_cfg is not None else False

    roi: tuple[float, float, float, float] | None = None
    if projections_enabled and proj_cfg is not None:
        u0 = getattr(proj_cfg, "roi_u_min_cm", None)
        u1 = getattr(proj_cfg, "roi_u_max_cm", None)
        v0 = getattr(proj_cfg, "roi_v_min_cm", None)
        v1 = getattr(proj_cfg, "roi_v_max_cm", None)
        # Only use ROI if all four bounds are provided
        if None not in (u0, u1, v0, v1):
            roi = (float(u0), float(u1), float(v0), float(v1))

    # Projection-plotting configuration (metrics_source, curve_mode, etc.)
    metrics_source = "auto"
    curve_mode = "all+roi"
    annotate_summary = "compact"
    show_metrics_panel = False
    show_peak_markers = True
    show_edge_markers = True
    show_centroid_2d = False
    if proj_cfg is not None:
        plot_cfg = getattr(proj_cfg, "plot", None)
        if plot_cfg is not None:
            metrics_source = getattr(plot_cfg, "metrics_source", metrics_source)
            curve_mode = getattr(plot_cfg, "curve_mode", curve_mode)
            annotate_summary = getattr(plot_cfg, "annotate_summary", annotate_summary)
            show_metrics_panel = getattr(plot_cfg, "show_metrics_panel", show_metrics_panel)
            show_peak_markers = getattr(plot_cfg, "show_peak_markers", show_peak_markers)
            show_edge_markers = getattr(plot_cfg, "show_edge_markers", show_edge_markers)
            show_centroid_2d = getattr(plot_cfg, "show_centroid_2d", show_centroid_2d)

    # --- 4a. Neutron-only image ---
    if cones_n:
        if diag_level >= 1:
            print( "[stage4] Imaging neutrons...")

        recon_n = reconstruct_sbp(
            cones=cones_n,
            plane=plane,
            workers=cfg.run.workers,
            chunk_cones=cfg.run.chunk_cones,
            list_mode=False,  # LM handled separately below
            # uncertainty_mode stays at default "off" for now
            progress=cfg.run.progress,
            sbp_engine=sbp_engine,
            use_jit=cfg.run.jit,
        )

        img_n = recon_n.summed.astype(np.float32)
        write_summed(f, "n", img_n)

        if diag_level >= 1:
            print(
                "[stage4] Recon summed image stats (n):",
                "min=", float(img_n.min()),
                "max=", float(img_n.max()),
                "sum=", float(img_n.sum()),
                "shape=", img_n.shape,
            )

    # --- 4b. Gamma-only image ---
    if cones_g:
        if diag_level >= 1:
            print( "[stage4] Imaging gammas...")

        recon_g = reconstruct_sbp(
            cones=cones_g,
            plane=plane,
            workers=cfg.run.workers,
            chunk_cones=cfg.run.chunk_cones,
            list_mode=False,  # LM handled separately below
            progress=cfg.run.progress,
            sbp_engine=sbp_engine,
            use_jit=cfg.run.jit,
        )

        img_g = recon_g.summed.astype(np.float32)
        write_summed(f, "g", img_g)

        if diag_level >= 1:
            print(
                "[stage4] Recon summed image stats (g):",
                "min=", float(img_g.min()),
                "max=", float(img_g.max()),
                "sum=", float(img_g.sum()),
                "shape=", img_g.shape,
            )

    # --- 4c. "all" image = n + g (when both exist) ---
    img_all = None
    if img_n is not None and img_g is not None:
        # Both species present: sum them
        img_all = (img_n + img_g).astype(np.float32)
    elif img_n is not None:
        # Only neutrons present
        img_all = img_n
    elif img_g is not None:
        # Only gammas present
        img_all = img_g

    if img_all is not None:
        write_summed(f, "all", img_all)
        if diag_level >= 1:
            print(
                "[stage4] SUMMED Recon summed image stats (all):",
                "min=", float(img_all.min()),
                "max=", float(img_all.max()),
                "sum=", float(img_all.sum()),
                "shape=", img_all.shape,
            )

    # --- 4c.1. 1D projections + metrics (u / v, all + ROI) (optional) ---
    # Use [vis.projections] config, if present, to control whether projections
    # and metrics are written to the HDF5 file.
    if projections_enabled and proj_cfg is not None:
        metrics_cfg = getattr(proj_cfg, "metrics", None)

        def _maybe_write_projections(species_label: str, img):
            if img is None:
                return
            write_projections(
                f,
                species_label,
                img,
                roi_bounds_cm=roi,
                metrics_cfg=metrics_cfg,
            )

        # Per-species projections (u/v, all + ROI) + metrics
        _maybe_write_projections("n", img_n)
        _maybe_write_projections("g", img_g)
        _maybe_write_projections("all", img_all)


    # --- 4d. List-mode extras: explicit cone → pixel mapping + event survival ---
    if cfg.run.list and len(cones_for_sbp) > 0:
        lm_cone_pixel_lists: list[tuple[int, np.ndarray]] = []
        # For list-mode runs, we can now also record which cones actually
        # intersected the plane (non-empty pixel sets) on a per-event basis.
        for cid, c, ev_idx in zip(cone_ids, cones_for_sbp, cone_event_index):
            idx = cone_to_indices(c, plane, engine=sbp_engine, n_poly=360, use_jit=cfg.run.jit)  # match SBP default
            if idx is None or idx.size == 0:
                continue
            lm_cone_pixel_lists.append((int(cid), idx))
            # Mark this event as having an imaged cone, if not already set.
            if 0 <= int(ev_idx) < len(event_imaged_cone_id):
                if event_imaged_cone_id[ev_idx] == -1:
                    event_imaged_cone_id[ev_idx] = int(cid)
        write_lm_indices(f, lm_cone_pixel_lists)

    # Store per-event survival table (event → cone, event → imaged cone)
    write_event_cone_survival(f, event_cone_id, event_imaged_cone_id)

    # Store counters into the HDF5 file for later inspection
    write_counters(f, counters)

    # Optional diagnostics printout of counters (console only).
    if diag_level >= 1:
        print("\n[diagnostics] Counter summary:")
        for key in sorted(counters.keys()):
            print(f"  {key} = {counters[key]}")

    f.close()

    # ---- Stage 5: Visualization (optional) ----
    if getattr(cfg, "vis", None) and getattr(cfg.vis, "export_png_on_write", False):
        try:
            # Species to render (n/g/all)
            species = getattr(cfg.vis, "species", ["n", "g", "all"])

            # Always include PNG; add extra formats from config (e.g. ["pdf"])
            formats = ["png"]
            extra = getattr(cfg.vis, "extra_formats", [])
            for fmt in extra:
                fmt = str(fmt).lower()
                if fmt and fmt not in formats:
                    formats.append(fmt)

            image_paths = render_summed_images(
                str(out_path),
                species=species,
                filename_pattern=getattr(
                    cfg.vis,
                    "filename_pattern",
                    "{species}_{stem}.{ext}",
                ),
                center_on_plane_center=getattr(cfg.vis, "center_on_plane_center", True),
                flip_vertical=getattr(cfg.vis, "flip_vertical", True),
                axis_units=getattr(cfg.vis, "axis_units", "cm"),
                cmap=getattr(cfg.vis, "cmap", "cividis"),
                formats=formats,
                projections=projections_enabled,
                roi_u_min_cm=roi[0] if roi is not None else None,
                roi_u_max_cm=roi[1] if roi is not None else None,
                roi_v_min_cm=roi[2] if roi is not None else None,
                roi_v_max_cm=roi[3] if roi is not None else None,
                plot_label=getattr(cfg.run, "plot_label", None),
                metrics_source=metrics_source,
                curve_mode=curve_mode,
                annotate_summary=annotate_summary,
                show_metrics_panel=show_metrics_panel,
                show_peak_markers=show_peak_markers,
                show_edge_markers=show_edge_markers,
                show_centroid_2d=show_centroid_2d,
            )

            if cfg.run.diagnostics_level >= 1:
                if image_paths:
                    print("[stage4] Wrote images: " + ", ".join(str(p) for p in image_paths))
                else:
                    print("[stage4] No /images/summed/* datasets found to visualize")
        except Exception as e:
            if cfg.run.diagnostics_level >= 1:
                print(f"[stage4] Image export failed: {e!r}")



    return out_path

core

The CLI is implemented via Typer, so the script behaves like a simple one-argument command:

- argument = path to TOML config file

The pipeline will:

- Load the TOML config
- Detect the adapter (PHITS, ROOT, HDF5 restart)
- Shape/validate hits → events
- Build cones (now neutron + gamma depending on run.neutrons, run.gammas)
- Run SBP imaging
- Write unified HDF5 output

You can always show help via:

- `python -m ngimager.pipelines.core --help`

Example run commands from project root:

python -m ngimager.pipelines.core path/to/config.toml

python -m ngimager.pipelines.core examples/configs/phits_usrdef_simple.toml

python -m ngimager.pipelines.core .\examples\configs\phits_usrdef_simple.toml

main
main(cfg_path=typer.Argument(..., help='Path to TOML config file'), fast=typer.Option(False, '--fast', help='Override [run].fast = true (use aggressive fast settings)'), list_mode=typer.Option(False, '--list', help='Override [run].list = true (enable list-mode image output)'), neutrons=typer.Option(None, '--neutrons / --no-neutrons', help='Enable or disable neutron processing; overrides [run].neutrons when set'), gammas=typer.Option(None, '--gammas / --no-gammas', help='Enable or disable gamma processing; overrides [run].gammas when set'), input_path=typer.Option(None, '--input-path', '-i', help='Override [io].input_path from the TOML config.'), output_path=typer.Option(None, '--output-path', '-o', help='Override [io].output_path from the TOML config.'), plot_label=typer.Option(None, '--plot-label', help='Override [run].plot_label (annotation text used in visualization).'))

Run the unified ng-imager pipeline for a single config.

Source code in ngimager/pipelines/core.py
@app.command()
def main(
    cfg_path: str = typer.Argument(
        ...,
        help="Path to TOML config file",
    ),
    fast: bool = typer.Option(
        False,
        "--fast",
        help="Override [run].fast = true (use aggressive fast settings)",
    ),
    list_mode: bool = typer.Option(
        False,
        "--list",
        help="Override [run].list = true (enable list-mode image output)",
    ),
    neutrons: Optional[bool] = typer.Option(
        None,
        "--neutrons / --no-neutrons",
        help="Enable or disable neutron processing; overrides [run].neutrons when set",
    ),
    gammas: Optional[bool] = typer.Option(
        None,
        "--gammas / --no-gammas",
        help="Enable or disable gamma processing; overrides [run].gammas when set",
    ),
    input_path: Optional[str] = typer.Option(
        None,
        "--input-path",
        "-i",
        help="Override [io].input_path from the TOML config.",
    ),
    output_path: Optional[str] = typer.Option(
        None,
        "--output-path",
        "-o",
        help="Override [io].output_path from the TOML config.",
    ),
    plot_label: Optional[str] = typer.Option(
        None,
        "--plot-label",
        help="Override [run].plot_label (annotation text used in visualization).",
    ),
):
    """
    Run the unified ng-imager pipeline for a single config.
    """
    out_path = run_pipeline(
        cfg_path,
        fast=fast if fast else None,
        list_mode=list_mode if list_mode else None,
        neutrons=neutrons,
        gammas=gammas,
        input_path=input_path,
        output_path=output_path,
        plot_label=plot_label,
    )
    typer.echo(str(out_path))
run_pipeline
run_pipeline(cfg_path, *, fast=None, list_mode=None, neutrons=None, gammas=None, input_path=None, output_path=None, plot_label=None)

Orchestrate the full pipeline from a TOML config file.

CLI flags (--fast/--list/--neutrons/--no-neutrons/--gammas/--no-gammas) override the corresponding [run] fields when not None.

Parameters:

Name Type Description Default
cfg_path str

Path to TOML configuration file.

required
input_path str

When provided, overrides [io].input_path from the TOML file.

None
output_path str

When provided, overrides [io].output_path from the TOML file.

None
plot_label str

When provided, overrides [run].plot_label (used for HDF5 and visualization annotations).

None

Returns:

Type Description
Path to written HDF5 file.
Source code in ngimager/pipelines/core.py
 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
def run_pipeline(
    cfg_path: str,
    *,
    fast: Optional[bool] = None,
    list_mode: Optional[bool] = None,
    neutrons: Optional[bool] = None,
    gammas: Optional[bool] = None,
    input_path: Optional[str] = None,
    output_path: Optional[str] = None,
    plot_label: Optional[str] = None,
) -> Path:
    """
    Orchestrate the full pipeline from a TOML config file.

    CLI flags (--fast/--list/--neutrons/--no-neutrons/--gammas/--no-gammas)
    override the corresponding [run] fields when not None.

    Parameters
    ----------
    cfg_path : str
        Path to TOML configuration file.
    input_path : str, optional
        When provided, overrides [io].input_path from the TOML file.
    output_path : str, optional
        When provided, overrides [io].output_path from the TOML file.
    plot_label : str, optional
        When provided, overrides [run].plot_label (used for HDF5 and
        visualization annotations).

    Returns
    -------
    Path to written HDF5 file.
    """
    #cfg_path = str(cfg_path)
    cfg = load_config(cfg_path)

    # ---- apply CLI overrides on top of TOML ----
    if fast is not None:
        cfg.run.fast = fast
    if list_mode is not None:
        cfg.run.list = list_mode
    if neutrons is not None:
        cfg.run.neutrons = neutrons
    if gammas is not None:
        cfg.run.gammas = gammas

    if input_path is not None:
        cfg.io.input_path = input_path
    if output_path is not None:
        cfg.io.output_path = output_path
    if plot_label is not None:
        cfg.run.plot_label = plot_label

    # Conveniences
    diag_level = cfg.run.diagnostics_level
    verbose = diag_level >= 2

    # Basic logging
    if diag_level >= 1:
        print(f"[run] config = {cfg_path}")
        print(f"[run] neutrons={cfg.run.neutrons} gammas={cfg.run.gammas} "
              f"fast={cfg.run.fast} list={cfg.run.list}")
        print(f"[run] input={cfg.io.input_path} -> output={cfg.io.output_path}")
        if getattr(cfg.run, "plot_label", None):
            print(f"[run] plot_label={cfg.run.plot_label!r}")

    # Apply fast-mode overrides (if run.fast is true)
    _apply_fast_overrides(cfg, diag_level=diag_level)

    # Imaging plane
    plane = Plane.from_cfg(
        cfg.plane.origin,
        cfg.plane.normal,
        cfg.plane.u_min,
        cfg.plane.u_max,
        cfg.plane.du,
        cfg.plane.v_min,
        cfg.plane.v_max,
        cfg.plane.dv,
        eu=cfg.plane.u_axis,
        ev=cfg.plane.v_axis,
    )

    # HDF5 output
    out_path = Path(cfg.io.output_path)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    cli_argv = sys.argv
    f = write_init(str(out_path), cfg_path, cfg, plane, cli_argv=cli_argv)


    # If this is a NOVO DDAQ ROOT run, try to capture the 'meta' TTree.
    if cfg.io.input_format == "root_novo_ddaq":
        adapter_cfg_meta: Dict[str, object] = dict(cfg.io.adapter)

        adapter_for_meta = make_adapter(adapter_cfg_meta)
        extractor = getattr(adapter_for_meta, "read_meta_tree", None)

        if callable(extractor):
            try:
                meta_dict = extractor(str(cfg.io.input_path))
            except Exception as exc:
                if diag_level >= 1:
                    print(f"[meta] Failed to read ROOT meta tree: {exc}")
            else:
                if meta_dict:
                    write_root_novo_meta(f, meta_dict)
                    if diag_level >= 1:
                        print("[meta] ROOT meta tree written to /meta/root_novo_ddaq")
        else:
            if diag_level >= 1:
                print("[meta] Adapter does not support ROOT meta tree extraction; skipping")


    # Shared counters for this run
    counters: Dict[str, int] = {}

    # ---- Stage 1: adapter → raw events → hit-level filters → is_reconstructable ----
    if cfg.io.input_format in ("phits_usrdef", "root_novo_ddaq"):
        from ngimager.filters.shapers import shape_events_for_cones, ShapeConfig
        from ngimager.filters.to_typed_events import shaped_to_typed_events
        from ngimager.filters.hit_filters import apply_hit_filters, is_reconstructable

        if diag_level >= 1:
            print("\n[stage1] Raw events → hits")
            print("[stage1] Using staged path: raw events → hits → shaped → typed")

        # Build adapter config, injecting detector-level material info.
        adapter_cfg: Dict[str, object] = dict(cfg.io.adapter)

        det_cfg = getattr(cfg, "detectors", None)
        if det_cfg is not None:
            mat_map = getattr(det_cfg, "material_map", None)
            if mat_map and "material_map" not in adapter_cfg:
                adapter_cfg["material_map"] = mat_map

            default_mat = getattr(det_cfg, "default_material", None)
            if default_mat and "default_material" not in adapter_cfg:
                adapter_cfg["default_material"] = default_mat

        # --- Geometry: detector-frame → world-frame + per-detector corrections ----
        geom_cfg = getattr(det_cfg, "geometry", None) if det_cfg is not None else None

        # Global frame transform
        frame_cfg = getattr(geom_cfg, "frame", None) if geom_cfg is not None else None
        origin_cm = [0.0, 0.0, 0.0]
        rotation_deg = [0.0, 0.0, 0.0]
        use_global_transform = False
        if frame_cfg is not None:
            origin_cm = getattr(frame_cfg, "origin_cm", origin_cm)
            rotation_deg = getattr(frame_cfg, "rotation_deg", rotation_deg)
            if not is_identity_transform(origin_cm, rotation_deg):
                use_global_transform = True
                if diag_level >= 1:
                    print(
                        "[stage1] Using global detector→world transform: "
                        f"origin_cm={origin_cm}, rotation_deg={rotation_deg}"
                    )

        # Per-detector transforms: id → (origin_cm, rotation_deg)
        per_det_transforms: Dict[int, tuple[list[float], list[float]]] = {}
        if geom_cfg is not None:
            det_list = getattr(geom_cfg, "detectors", []) or []
            for det_entry in det_list:
                try:
                    det_id = int(det_entry.id)
                except Exception:
                    continue
                o = getattr(det_entry, "origin_cm", [0.0, 0.0, 0.0])
                r = getattr(det_entry, "rotation_deg", [0.0, 0.0, 0.0])
                # Skip entries that are effectively identity transforms
                if is_identity_transform(o, r):
                    continue
                per_det_transforms[det_id] = (o, r)

        use_per_det_transforms = bool(per_det_transforms)
        if use_per_det_transforms and diag_level >= 1:
            print(
                "[stage1] Per-detector transforms configured for "
                f"{len(per_det_transforms)} detector IDs"
            )


        adapter = make_adapter(adapter_cfg)

        raw_events_after_filters = []

        for ev in adapter.iter_raw_events(str(cfg.io.input_path)):
            hits = list(ev.get("hits", []))

            # Apply per-detector corrections first (local → detector frame)
            if use_per_det_transforms and hits:
                for h in hits:
                    t_cfg = per_det_transforms.get(h.det_id)
                    if t_cfg is None:
                        continue
                    o_det, r_det = t_cfg
                    # apply_rigid_transform accepts (..., 3); we pass a single row
                    h.r = apply_rigid_transform(h.r[None, :], o_det, r_det)[0]

            # Apply global detector-frame → world-frame transform
            if use_global_transform and hits:
                pts = np.stack([h.r for h in hits], axis=0)
                pts_world = apply_rigid_transform(pts, origin_cm, rotation_deg)
                for h, r_new in zip(hits, pts_world):
                    h.r = r_new

            # Normalize event_type to 'n' / 'g' where possible
            et_raw = str(ev.get("event_type", "")).lower()
            if et_raw.startswith("n"):
                et = "n"
            elif et_raw.startswith("g"):
                et = "g"
            else:
                et = None

            counters["raw_events_total"] = counters.get("raw_events_total", 0) + 1

            # ---- Stage 1.5: Apply Hit-level filters
            filtered_hits = apply_hit_filters(
                hits,
                cfg.filters.hits,
                counters,
                particle_type=et,
            )

            # Early reconstructability decision (also updates *_unreconstructable counters)
            if not is_reconstructable(filtered_hits, cfg.filters.events, counters, event_type=et):
                continue

            if not filtered_hits:
                # Should be caught by is_reconstructable, but guard anyway
                continue

            ev2 = dict(ev)
            ev2["hits"] = filtered_hits
            if et is not None:
                ev2["event_type"] = et
            raw_events_after_filters.append(ev2)

        if diag_level >= 1:
            print(
                "[stage1] raw_events_total={total} "
                "raw_events_after_filters={surv} "
                "raw_events_rejected_unreconstructable={rej}".format(
                    total=counters.get("raw_events_total", 0),
                    surv=len(raw_events_after_filters),
                    rej=counters.get("raw_events_rejected_unreconstructable", 0),
                )
            )


        # ---- Stage 2: Hits → shaped events → typed events ----
        if diag_level >= 1:
            print("\n[stage2] Hits → shaped events → typed events")

        # Shaper configuration:
        #   - PHITS: use default policies (time-ascending for both species).
        #   - ROOT NOVO DDAQ: keep neutrons time-ordered, but pick the
        #     brightest 3 gamma hits when multiplicity > 3.
        shape_cfg = ShapeConfig()
        if cfg.io.input_format == "root_novo_ddaq":
            # ROOT neutrons: enforce time-order like legacy
            shape_cfg.neutron_policy = "time_asc"
            # ROOT gammas: keep brightest-gamma rule
            shape_cfg.gamma_policy = "energy_desc"

        shaped_events, shape_diag = shape_events_for_cones(
            raw_events_after_filters,
            shape_cfg,
            counters=counters,
        )

        if diag_level >= 1:
            print(
                "[stage2] shaper: total_events_in={total} "
                "shaped_n={sn} shaped_g={sg}".format(
                    total=shape_diag.total_events,
                    sn=shape_diag.shaped_neutron,
                    sg=shape_diag.shaped_gamma,
                )
            )


        if diag_level >= 2 and shaped_events:
            print(f"[stage2] Example shaped events (up to first 3):")
            for se in shaped_events[:3]:
                ts = [h.t_ns for h in se.hits]
                Ls = [h.L for h in se.hits]
                types = [h.type for h in se.hits]
                print(
                    f"    species={se.species} "
                    f"n_hits={len(se.hits)} "
                    f"types={types} "
                    f"t_ns={ts} "
                    f"L={Ls}"
                )

        events = shaped_to_typed_events(
            shaped_events,
            order_time=True,
        )

        # ---- Typed events diagnostics (Stage: hits → shaped → typed) ----
        # Species breakdown based on the typed-event objects themselves.
        # This does not assume any particular event-level filters yet.
        from ngimager.physics.events import NeutronEvent, GammaEvent
        n_n = sum(isinstance(ev, NeutronEvent) for ev in events)
        n_g = sum(isinstance(ev, GammaEvent) for ev in events)
        counters["events_typed_total"] = n_n + n_g
        counters["events_typed_n"] = n_n
        counters["events_typed_g"] = n_g
        # Placeholder for future event-level rejections (event filters)
        # so that a later filter stage can do:
        #   counters["events_rejected_filters"] = ...
        events_rejected = counters.get("events_rejected_filters", 0)

        if diag_level >= 1:
            print(
                "[stage2] typed_total={total} "
                "typed_n={n} typed_g={g} "
                "events_rejected_filters={rej}".format(
                    total=len(events),
                    n=n_n,
                    g=n_g,
                    rej=events_rejected,
                )
            )

    else:
        # For non-PHITS sources, keep the existing direct typed-event path.
        events = list(_iter_source_events(cfg))


    # ---- Stage 2.5: Apply Event-level filters (e.g. ToF windows) ----
    events = apply_event_filters(
        events,
        cfg.filters.events,
        counters,
    )

    if diag_level >= 1:
        print(
            "[stage2] events_total_for_filters={etf} "
            "events_after_filters={eaf} "
            "events_rejected_filters={er}".format(
                etf=counters.get("events_total_for_filters", 0),
                eaf=counters.get("events_after_filters", 0),
                er=counters.get("events_rejected_filters", 0),
            )
        )

    # Existing diagnostics on typed events
    if diag_level >= 1:
        print(f"[stage2] Got {len(events)} events")
    if events:
        first = events[0]
        h1 = getattr(first, "h1", None)
        h2 = getattr(first, "h2", None)
        if diag_level >= 2:
            print(f"[stage2] First event type: {type(first).__name__}")
            print(f"[stage2] First event h1: {h1!r}")
            print(f"[stage2] First event h2: {h2!r}")
            if h1 is not None:
                print(f"[stage2] h1.r = {getattr(h1, 'r', None)}, t_ns={h1.t_ns}, L={h1.L}")
            if h2 is not None:
                print(f"[stage2] h2.r = {getattr(h2, 'r', None)}, t_ns={h2.t_ns}, L={h2.L}")
            for ev in events[:3]:
                species = "n" if isinstance(ev, NeutronEvent) else "g" if isinstance(ev, GammaEvent) else "?"
                hlist = [getattr(ev, name) for name in ("h1", "h2", "h3") if hasattr(ev, name)]
                ts = [h.t_ns for h in hlist]
                Ls = [h.L for h in hlist]
                types = [h.type for h in hlist]
                print(
                    f"    {species}-event "
                    f"n_hits={len(hlist)} "
                    f"types={types} "
                    f"t_ns={ts} "
                    f"L={Ls}"
                )

    # Write to output Per-event / per-hit physics (this links back via /lm/events dataset)
    write_events_hits(f, events)



    # ---- Stage 3: Typed events → candidate cones → selected cones ----
    if diag_level >= 1:
        print("\n[stage3] Events → candidate cones → selected cones")
    # Cones from events
    (
        cone_ids,
        apex_xyz_cm,
        axis_xyz,
        theta_rad,
        cone_species,
        recoil_code,
        incident_energy_MeV,
        cone_event_index,
        gamma_hit_order,
    ) = _build_cones_from_events(cfg, events, plane, counters)

    # --- Build per-event cone survival arrays (event → cone_id) ---
    n_events = len(events)
    event_cone_id = np.full(n_events, -1, dtype=np.int32)
    event_imaged_cone_id = np.full(n_events, -1, dtype=np.int32)  # will be filled in LM block if list-mode

    for cid, ev_idx in zip(cone_ids, cone_event_index):
        # Defensive check; ev_idx should be in [0, n_events)
        if 0 <= int(ev_idx) < n_events:
            # For now, each event yields at most one cone; keep the first if ever that changes.
            if event_cone_id[ev_idx] == -1:
                event_cone_id[ev_idx] = int(cid)

    # Counters and diagnostics for cones
    n_cones = int(len(cone_ids))
    n_n = int(np.count_nonzero(cone_species == 0))
    n_g = int(np.count_nonzero(cone_species == 1))
    # Neutron recoil breakdown
    n_p = int(np.count_nonzero((cone_species == 0) & (recoil_code == 1)))
    n_C = int(np.count_nonzero((cone_species == 0) & (recoil_code == 2)))
    n_unknown = max(0, n_n - n_p - n_C)
    _inc(counters, "cones_n_recoil_proton", n_p)
    _inc(counters, "cones_n_recoil_carbon", n_C)
    _inc(counters, "cones_n_recoil_unknown", n_unknown)

    if diag_level >= 1:
        print(
            "[stage3] Built {total} cones "
            "(neutron={n_n}, gamma={n_g})".format(
                total=n_cones,
                n_n=n_n,
                n_g=n_g,
            )
        )
        if n_cones and diag_level >= 2:
            print("[stage3] Example cone apex:", apex_xyz_cm[0])
            print("[stage3] Example cone dir:", axis_xyz[0])
            print("[stage3] Example cone theta[deg]:", np.degrees(theta_rad[0]))
            print(
                "[stage3] Neutron recoil breakdown: "
                f"proton={n_p}, carbon={n_C}, unknown={n_unknown}"
            )

    # Write to output Per-cone geometry + classification
    write_cones(
        f,
        cone_ids,
        apex_xyz_cm,
        axis_xyz,
        theta_rad,
        species=cone_species,
        recoil_code=recoil_code,
        incident_energy_MeV=incident_energy_MeV,
        event_index=cone_event_index,
        gamma_hit_order=gamma_hit_order,
    )

    # ---- Stage 4: Cones → imaging/reconstruction (SPB) ----
    if diag_level >= 1:
        print("\n[stage4] Cones → imaging / reconstruction (SBP)")
    # Choose SBP engine from config
    sbp_engine = getattr(cfg.run, "sbp_engine", "scan")

    # Build Cone objects from the geometry arrays
    cones_for_sbp: list[Cone] = [
        Cone(apex=apex_xyz_cm[i], direction=axis_xyz[i], theta=float(theta_rad[i]))
        for i in range(len(cone_ids))
    ]

    # Partition cones by species for separate images
    cones_n: list[Cone] = []
    cones_g: list[Cone] = []
    for c, s in zip(cones_for_sbp, cone_species):
        if s == 0:
            cones_n.append(c)
        elif s == 1:
            cones_g.append(c)

    img_n = None
    img_g = None

    # Projection / ROI configuration (for HDF5 + visualization)
    vis_cfg = getattr(cfg, "vis", None)
    proj_cfg = getattr(vis_cfg, "projections", None) if vis_cfg is not None else None

    projections_enabled = bool(
        getattr(proj_cfg, "enabled", False)
    ) if proj_cfg is not None else False

    roi: tuple[float, float, float, float] | None = None
    if projections_enabled and proj_cfg is not None:
        u0 = getattr(proj_cfg, "roi_u_min_cm", None)
        u1 = getattr(proj_cfg, "roi_u_max_cm", None)
        v0 = getattr(proj_cfg, "roi_v_min_cm", None)
        v1 = getattr(proj_cfg, "roi_v_max_cm", None)
        # Only use ROI if all four bounds are provided
        if None not in (u0, u1, v0, v1):
            roi = (float(u0), float(u1), float(v0), float(v1))

    # Projection-plotting configuration (metrics_source, curve_mode, etc.)
    metrics_source = "auto"
    curve_mode = "all+roi"
    annotate_summary = "compact"
    show_metrics_panel = False
    show_peak_markers = True
    show_edge_markers = True
    show_centroid_2d = False
    if proj_cfg is not None:
        plot_cfg = getattr(proj_cfg, "plot", None)
        if plot_cfg is not None:
            metrics_source = getattr(plot_cfg, "metrics_source", metrics_source)
            curve_mode = getattr(plot_cfg, "curve_mode", curve_mode)
            annotate_summary = getattr(plot_cfg, "annotate_summary", annotate_summary)
            show_metrics_panel = getattr(plot_cfg, "show_metrics_panel", show_metrics_panel)
            show_peak_markers = getattr(plot_cfg, "show_peak_markers", show_peak_markers)
            show_edge_markers = getattr(plot_cfg, "show_edge_markers", show_edge_markers)
            show_centroid_2d = getattr(plot_cfg, "show_centroid_2d", show_centroid_2d)

    # --- 4a. Neutron-only image ---
    if cones_n:
        if diag_level >= 1:
            print( "[stage4] Imaging neutrons...")

        recon_n = reconstruct_sbp(
            cones=cones_n,
            plane=plane,
            workers=cfg.run.workers,
            chunk_cones=cfg.run.chunk_cones,
            list_mode=False,  # LM handled separately below
            # uncertainty_mode stays at default "off" for now
            progress=cfg.run.progress,
            sbp_engine=sbp_engine,
            use_jit=cfg.run.jit,
        )

        img_n = recon_n.summed.astype(np.float32)
        write_summed(f, "n", img_n)

        if diag_level >= 1:
            print(
                "[stage4] Recon summed image stats (n):",
                "min=", float(img_n.min()),
                "max=", float(img_n.max()),
                "sum=", float(img_n.sum()),
                "shape=", img_n.shape,
            )

    # --- 4b. Gamma-only image ---
    if cones_g:
        if diag_level >= 1:
            print( "[stage4] Imaging gammas...")

        recon_g = reconstruct_sbp(
            cones=cones_g,
            plane=plane,
            workers=cfg.run.workers,
            chunk_cones=cfg.run.chunk_cones,
            list_mode=False,  # LM handled separately below
            progress=cfg.run.progress,
            sbp_engine=sbp_engine,
            use_jit=cfg.run.jit,
        )

        img_g = recon_g.summed.astype(np.float32)
        write_summed(f, "g", img_g)

        if diag_level >= 1:
            print(
                "[stage4] Recon summed image stats (g):",
                "min=", float(img_g.min()),
                "max=", float(img_g.max()),
                "sum=", float(img_g.sum()),
                "shape=", img_g.shape,
            )

    # --- 4c. "all" image = n + g (when both exist) ---
    img_all = None
    if img_n is not None and img_g is not None:
        # Both species present: sum them
        img_all = (img_n + img_g).astype(np.float32)
    elif img_n is not None:
        # Only neutrons present
        img_all = img_n
    elif img_g is not None:
        # Only gammas present
        img_all = img_g

    if img_all is not None:
        write_summed(f, "all", img_all)
        if diag_level >= 1:
            print(
                "[stage4] SUMMED Recon summed image stats (all):",
                "min=", float(img_all.min()),
                "max=", float(img_all.max()),
                "sum=", float(img_all.sum()),
                "shape=", img_all.shape,
            )

    # --- 4c.1. 1D projections + metrics (u / v, all + ROI) (optional) ---
    # Use [vis.projections] config, if present, to control whether projections
    # and metrics are written to the HDF5 file.
    if projections_enabled and proj_cfg is not None:
        metrics_cfg = getattr(proj_cfg, "metrics", None)

        def _maybe_write_projections(species_label: str, img):
            if img is None:
                return
            write_projections(
                f,
                species_label,
                img,
                roi_bounds_cm=roi,
                metrics_cfg=metrics_cfg,
            )

        # Per-species projections (u/v, all + ROI) + metrics
        _maybe_write_projections("n", img_n)
        _maybe_write_projections("g", img_g)
        _maybe_write_projections("all", img_all)


    # --- 4d. List-mode extras: explicit cone → pixel mapping + event survival ---
    if cfg.run.list and len(cones_for_sbp) > 0:
        lm_cone_pixel_lists: list[tuple[int, np.ndarray]] = []
        # For list-mode runs, we can now also record which cones actually
        # intersected the plane (non-empty pixel sets) on a per-event basis.
        for cid, c, ev_idx in zip(cone_ids, cones_for_sbp, cone_event_index):
            idx = cone_to_indices(c, plane, engine=sbp_engine, n_poly=360, use_jit=cfg.run.jit)  # match SBP default
            if idx is None or idx.size == 0:
                continue
            lm_cone_pixel_lists.append((int(cid), idx))
            # Mark this event as having an imaged cone, if not already set.
            if 0 <= int(ev_idx) < len(event_imaged_cone_id):
                if event_imaged_cone_id[ev_idx] == -1:
                    event_imaged_cone_id[ev_idx] = int(cid)
        write_lm_indices(f, lm_cone_pixel_lists)

    # Store per-event survival table (event → cone, event → imaged cone)
    write_event_cone_survival(f, event_cone_id, event_imaged_cone_id)

    # Store counters into the HDF5 file for later inspection
    write_counters(f, counters)

    # Optional diagnostics printout of counters (console only).
    if diag_level >= 1:
        print("\n[diagnostics] Counter summary:")
        for key in sorted(counters.keys()):
            print(f"  {key} = {counters[key]}")

    f.close()

    # ---- Stage 5: Visualization (optional) ----
    if getattr(cfg, "vis", None) and getattr(cfg.vis, "export_png_on_write", False):
        try:
            # Species to render (n/g/all)
            species = getattr(cfg.vis, "species", ["n", "g", "all"])

            # Always include PNG; add extra formats from config (e.g. ["pdf"])
            formats = ["png"]
            extra = getattr(cfg.vis, "extra_formats", [])
            for fmt in extra:
                fmt = str(fmt).lower()
                if fmt and fmt not in formats:
                    formats.append(fmt)

            image_paths = render_summed_images(
                str(out_path),
                species=species,
                filename_pattern=getattr(
                    cfg.vis,
                    "filename_pattern",
                    "{species}_{stem}.{ext}",
                ),
                center_on_plane_center=getattr(cfg.vis, "center_on_plane_center", True),
                flip_vertical=getattr(cfg.vis, "flip_vertical", True),
                axis_units=getattr(cfg.vis, "axis_units", "cm"),
                cmap=getattr(cfg.vis, "cmap", "cividis"),
                formats=formats,
                projections=projections_enabled,
                roi_u_min_cm=roi[0] if roi is not None else None,
                roi_u_max_cm=roi[1] if roi is not None else None,
                roi_v_min_cm=roi[2] if roi is not None else None,
                roi_v_max_cm=roi[3] if roi is not None else None,
                plot_label=getattr(cfg.run, "plot_label", None),
                metrics_source=metrics_source,
                curve_mode=curve_mode,
                annotate_summary=annotate_summary,
                show_metrics_panel=show_metrics_panel,
                show_peak_markers=show_peak_markers,
                show_edge_markers=show_edge_markers,
                show_centroid_2d=show_centroid_2d,
            )

            if cfg.run.diagnostics_level >= 1:
                if image_paths:
                    print("[stage4] Wrote images: " + ", ".join(str(p) for p in image_paths))
                else:
                    print("[stage4] No /images/summed/* datasets found to visualize")
        except Exception as e:
            if cfg.run.diagnostics_level >= 1:
                print(f"[stage4] Image export failed: {e!r}")



    return out_path

tools

bundle_repo

bundle_repo.py — produce a single-file text bundle of your repo.

Usage: python src/ngimager/tools/bundle_repo.py . -o repo_bundle.txt

What it does: - Writes a directory tree. - For each text file, writes a header with path/size/sha256 and the content. - Skips binaries and large files (configurable). - Skips typical junk dirs (.git, pycache, build artifacts).

build_tree
build_tree(root)

Return a simple text tree of the repo.

Source code in ngimager/tools/bundle_repo.py
def build_tree(root: Path) -> str:
    """Return a simple text tree of the repo."""
    lines = []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in DEFAULT_EXCLUDE_DIRS]
        rel = Path(dirpath).relative_to(root)
        indent = "  " * (0 if str(rel) == "." else len(rel.parts))
        if str(rel) != ".":
            lines.append(f"{indent}{rel}/")
        for fn in sorted(filenames):
            lines.append(f"{indent}  {fn}")
    return "\n".join(lines)
get_git_commit
get_git_commit(root)

Best-effort retrieval of the current Git commit SHA for the repo root.

Returns a full 40-character SHA string if available, otherwise None. This is intentionally non-fatal: if the repo is not a Git checkout, or git is not installed, or anything else goes wrong, we just return None.

Source code in ngimager/tools/bundle_repo.py
def get_git_commit(root: Path) -> Optional[str]:
    """
    Best-effort retrieval of the current Git commit SHA for the repo root.

    Returns a full 40-character SHA string if available, otherwise None.
    This is intentionally non-fatal: if the repo is not a Git checkout,
    or git is not installed, or anything else goes wrong, we just return None.
    """
    git_dir = root / ".git"
    if not git_dir.exists():
        return None
    try:
        result = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=str(root),
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            text=True,
            check=False,
        )
        sha = (result.stdout or "").strip()
        return sha or None
    except Exception:
        return None
is_textlike
is_textlike(p)

Heuristic to decide if a file is text-like.

Source code in ngimager/tools/bundle_repo.py
def is_textlike(p: Path) -> bool:
    """Heuristic to decide if a file is text-like."""
    if p.suffix.lower() in DEFAULT_INCLUDE_EXT:
        return True
    # Heuristic: small files without NUL bytes
    try:
        with open(p, "rb") as f:
            chunk = f.read(4096)
        return b"\x00" not in chunk
    except Exception:
        return False
walk_repo
walk_repo(root)

Yield all files under root, skipping DEFAULT_EXCLUDE_DIRS.

Source code in ngimager/tools/bundle_repo.py
def walk_repo(root: Path):
    """Yield all files under root, skipping DEFAULT_EXCLUDE_DIRS."""
    for dirpath, dirnames, filenames in os.walk(root):
        # prune excluded dirs in-place
        dirnames[:] = [d for d in dirnames if d not in DEFAULT_EXCLUDE_DIRS]
        for fn in sorted(filenames):
            yield Path(dirpath) / fn

generate_lut

Light-response LUT generation tools for NOVO scintillators.

This subpackage exposes:

  • :mod:ngimager.tools.generate_lut.NOVO_light_response_functions — the script and helpers used to build E(L) LUTs for M600 and OGS from SRIM stopping power data and calibration measurements.
NOVO_light_response_functions

Created by Hunter N. Ratliff, 2025-10-17 This code generates light response functions/lookup-tables (LUTs), forward and inverse, for NOVO's M600 and OGS scintillators using my SRIM calculations as a basis for a Birks function fit whose parameters are optimized for proton light response data collected in NOVO's March 2024 PTB experiments.

==============================================================================
Light-Response Fitting and LUT Generation — Explainer
==============================================================================

This script builds physics-based light-response models for plastic scintillators and exports fast lookup tables (LUTs) to convert measured light output (MeVee) into recoil energy (MeV). It supports both proton and carbon recoils and produces figures for fit quality and inverse-response uncertainty bands.

What the script does (high level)

1) Loads stopping power data (SRIM) for protons and carbon and converts to linear stopping power using the scintillator density. 2) Loads experimental calibration data from PTB that map proton recoil energy to measured light output. 3) Fits a Birks-type light-yield model (Birks or Birks–Chou) to the calibration data. 4) Optionally constrains S to 1 using gamma Compton-edge calibration (MeVee scale). 5) Builds dense forward and inverse LUTs: - Forward: E -> L(E) - Inverse: L -> E(L), uniform grid in L for fast np.interp 6) Computes 68 percent confidence bands on E(L) by sampling the fitted parameters and propagating to inverse LUTs. 7) Exports portable artifacts (NPZ, CSV, JSON metadata) and generates plots.

Inputs
  • SRIM stopping power files for H and C ions for each scintillator:
  • Must include energy (MeV) and mass stopping power (MeV cm^2 / g).
  • Energy range ideally covers 1 keV to at least 100–250 MeV.
  • Scintillator density rho (g/cm^3) for each material.
  • Experimental proton light-response calibration:
  • Arrays of Ep_MeV (proton recoil energies) and L_MeVee (measured light output).
  • Optional grouping labels for different neutron energies (En_indices, En_strs) to visualize subsets.
  • Gamma Compton calibration (performed upstream):
  • Data acquisition already outputs MeVee. This allows S to be fixed to 1 or softly constrained near 1.
Outputs

For each scintillator and species (proton, carbon):

  • NPZ file: basepath.npz
  • Arrays: L_inv (MeVee), E_inv (MeV)
  • Optional arrays: E_inv_lo, E_inv_hi (16th and 84th percentile inverse bands)
  • Metadata object with model, parameters, density, fit stats, grid sizes, timestamp
  • CSV file: basepath.csv
  • Two columns: L_inv_MeVee, E_inv_MeV (plaintext for sharing and longevity)
  • JSON metadata: basepath.meta.json
  • Human-readable metadata mirror of the NPZ meta
  • Plots (if enabled):
  • Birks fit and residuals (stacked) per scintillator
  • Inverse response E(L) with 68 percent bands for proton and carbon
  • Zoomed carbon inverse plot
Methods and process

1) Units and data prep - Convert mass stopping power to linear: dE/dx [MeV/cm] = rho * (dE/dx)_mass [MeV cm^2 / g]. - Interpolate dE/dx(E) with a monotone, nonnegative interpolant (shape-preserving cubic, or safe wrapper).

2) Light-response model - Birks: dL/dx = S * (dE/dx) / (1 + kB * dE/dx) - Birks–Chou (optional): dL/dx = S * (dE/dx) / (1 + kB * dE/dx + C * (dE/dx)^2) - Total light for a recoil of energy E is the integral of dL/dE over energy. Numerically integrate over a dense E grid.

3) Parameter fitting - Nonlinear least squares (scipy.optimize.least_squares) on residuals L_model(Ei) - L_data,i. - Residual variance scaling: covariance = sigma^2 * (J^T J)^-1 with sigma^2 = SSE / (N - p). - Report best-fit parameters, 1 sigma uncertainties, R^2, adjusted R^2, RMSE.

4) Handling S (electron-equivalent scale) - If data are already in MeVee via Compton-edge calibration, fix S = 1 (hard) or apply a soft Gaussian prior on S near 1 (e.g., sigma 0.01–0.02). - This removes S–kB degeneracy and stabilizes extrapolation.

5) Building LUTs - Forward: compute L(E) on a dense E grid (e.g., up to 250 MeV). - Inverse: create a uniformly spaced L grid and tabulate E(L) with np.interp. - Save proton and carbon inverse LUTs separately. Use float32 for compact storage and fast lookup.

6) Uncertainty bands (optional) - Draw samples of [S, kB, C] from the multivariate normal defined by the fitted covariance. - For each sample, compute inverse E(L) onto the fixed L grid. - Take the 16th and 84th percentiles across samples at each L to form a 68 percent confidence band. - Store E_inv_lo and E_inv_hi alongside the central inverse LUT.

7) Plotting (optional) - Fit-quality figure: top panel shows data and model L(E); bottom panel shows percent residuals. - Inverse figure: E(L) central curve with 68 percent band for proton and carbon. - Carbon often appears highly quenched; use a zoomed L range (e.g., L < 8 MeVee) or annotate unreachable regions using elastic kinematic caps.

How to use the LUTs downstream
  • Load NPZ: L_inv, E_inv. Convert MeVee to recoil energy with Ep = np.interp(L_meas, L_inv, E_inv). Fast example drop-in code:
    pack = np.load("lut_M600_proton.npz", allow_pickle=True)
    L_inv = pack["L_inv"]; E_inv = pack["E_inv"]
    def Ep_from_L(L): return np.interp(L, L_inv, E_inv)
    
  • If uncertainty bands were exported: compute Ep_lo and Ep_hi via the same interpolation on E_inv_lo and E_inv_hi.
  • Use proton and carbon LUTs in parallel and let imaging logic choose between hypotheses or carry both with weights.
  • Optional: clip carbon solutions using an elastic kinematic ceiling given a neutron energy bound.
Configuration knobs
  • Model selection: use_Chou_C_term boolean to include the C term.
  • S handling: lock S exactly to 1 via lock_S_to_1 = True or via bounds, or set a soft prior on S with prior_sigma.
  • Grids: E_max, nE for forward integration; nL for inverse grid density.
  • Band sampling: number of parameter draws, sample filtering for monotonicity and stability.
Assumptions and caveats
  • Electron-equivalent calibration is already applied upstream; therefore S should be 1 or tightly constrained near 1.
  • The carbon LUT is more uncertain in practice without carbon-tagged calibration; use it as a conservative branch and apply kinematic caps where appropriate.
  • Extrapolation beyond the calibration Ep range is supported but rely on the band to communicate model uncertainty.
  • Monotonicity is required for E(L) inversion; pathological parameter draws are rejected.
Troubleshooting
  • Inverse interpolation error (requires at least two unique L points): occurs if a sampled parameter set produces nearly flat L(E). The sampler filters such draws; increase sample count or tighten priors if too many draws are rejected.
  • Large parameter uncertainties under Birks–Chou: usually indicates kB and C are highly correlated and C is weakly identifiable; prefer simple Birks unless low-energy data demand C.
  • Odd high-L divergence between Birks and Chou: typically due to unconstrained S; fix S via Compton calibration.
Dependencies
Files written (per scintillator and species)
  • basepath.npz: L_inv, E_inv, and optional E_inv_lo, E_inv_hi, plus metadata.
  • basepath.csv: plaintext columns L_inv_MeVee, E_inv_MeV.
  • basepath.meta.json: metadata (scintillator, species, model, parameters, density, fit stats, grid sizes, timestamp).
  • Figures: fit and inverse-band plots if saving is enabled.

This design yields a transparent, physics-backed model with fast and portable inverse LUTs for experimental imaging.

Birks_params module-attribute
Birks_params = {'M600': {'S': 1.0, 'kB': 14.4, 'kB_linear': 14.4 * 0.001 / density['M600'], 'C': 0, 'C_linear': 0}, 'OGS': {'S': 0.83, 'kB': 5.5, 'kB_linear': 5.5 * 0.001 / density['OGS'], 'C': 0, 'C_linear': 0}}

As a note, these files are those directly produced by SRIM. The "SRIM_*.dat" files Joey used have column 1 units of MeV and column 2 units of keV / (mg/cm^2) (or, equivalently, MeV / (g/cm^2)).

accumulate_light_from_steps
accumulate_light_from_steps(steps, dedx_fun, S, kB, C=0.0)

steps: iterable of dicts with keys {'dE', 'E_mid'} for a given recoil track returns total light for that track

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def accumulate_light_from_steps(steps, dedx_fun, S, kB, C=0.0):
    """
    steps: iterable of dicts with keys {'dE', 'E_mid'} for a given recoil track
    returns total light for that track
    """
    return sum(dL_from_step(st['dE'], st['E_mid'], dedx_fun, S, kB, C) for st in steps)
build_inverse_L_grid
build_inverse_L_grid(E_fine, L_fine, nL=60001)

Make a uniformly spaced grid in L (monotone), then tabulate E(L) with np.interp.

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def build_inverse_L_grid(E_fine, L_fine, nL=60001):
    """
    Make a uniformly spaced grid in L (monotone), then tabulate E(L) with np.interp.
    """
    L_min, L_max = float(L_fine[0]), float(L_fine[-1])
    L_inv = np.linspace(L_min, L_max, int(nL), dtype=np.float32)
    E_inv = np.interp(L_inv, L_fine, E_fine).astype(np.float32)
    return L_inv, E_inv
compute_inverse_band
compute_inverse_band(dedx_fun, params_samples, L_inv_ref, *, E_max=250.0, nE=125001)

Build 68% (16/84) percentile inverse E(L) on a fixed L grid (L_inv_ref) by sampling Birks params. Returns (E_inv_lo, E_inv_hi, E_inv_med). Any pathological samples (non-monotone L(E)) are skipped.

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def compute_inverse_band(dedx_fun, params_samples, L_inv_ref, *, E_max=250.0, nE=125001):
    """
    Build 68% (16/84) percentile inverse E(L) on a *fixed* L grid (L_inv_ref)
    by sampling Birks params. Returns (E_inv_lo, E_inv_hi, E_inv_med).
    Any pathological samples (non-monotone L(E)) are skipped.
    """
    E_inv_samples = []
    E_grid = np.linspace(0.0, E_max, int(nE))
    for (S_s, kB_s, C_s) in params_samples:
        # Forward L(E) for this draw
        L_grid = light_integral_grid(E_grid, dedx_fun, S_s, kB_s, C_s)
        # Must be monotone to invert
        dL = np.diff(L_grid)
        if not np.all(np.isfinite(L_grid)):
            continue
        if np.ptp(L_grid) < 1e-9:
            continue
        # allow tiny plateaus; reject if too many
        if np.count_nonzero(dL <= 1e-12) > 0.01 * len(dL):
            continue
        # Inverse onto fixed L grid
        E_inv_s = np.interp(L_inv_ref, L_grid, E_grid)
        E_inv_samples.append(E_inv_s)

    if len(E_inv_samples) == 0:
        return None, None, None

    E_inv_samples = np.asarray(E_inv_samples, dtype=np.float64)
    E_inv_lo  = np.percentile(E_inv_samples, 16, axis=0).astype(np.float32)
    E_inv_hi  = np.percentile(E_inv_samples, 84, axis=0).astype(np.float32)
    E_inv_med = np.percentile(E_inv_samples, 50, axis=0).astype(np.float32)
    return E_inv_lo, E_inv_hi, E_inv_med
fit_birks_params
fit_birks_params(E_data, L_data, dedx_func, init=(1.0, 0.01, 0.0), use_C=False, bounds=((0, 0, 0), (np.inf, np.inf, np.inf)), prior_S=None, prior_sigma=None)

Fit (S, kB [, C]) by minimizing residuals on L(E). E_data in MeV (proton energy), L_data in MeVee. init: (S, kB[, C]) - use Joey's numbers as initial values bounds: ((Smin, kBmin, Cmin), (Smax, kBmax, Cmax))

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def fit_birks_params(E_data, L_data, dedx_func, init=(1.0, 0.01, 0.0), use_C=False, bounds=((0, 0, 0), (np.inf, np.inf, np.inf)), prior_S=None, prior_sigma=None):
    """
    Fit (S, kB [, C]) by minimizing residuals on L(E).
    E_data in MeV (proton energy), L_data in MeVee.
    init: (S, kB[, C]) - use Joey's numbers as initial values
    bounds: ((Smin, kBmin, Cmin), (Smax, kBmax, Cmax))
    """
    E_data = np.asarray(E_data, dtype=float)
    L_data = np.asarray(L_data, dtype=float)
    E_grid = np.linspace(0.0, max(1.05*np.max(E_data), 20.0), 20001)  # ensure dense coverage

    def residuals(p):
        S, kB = p[0], p[1]
        C = p[2] if use_C else 0.0
        L_grid = light_integral_grid(E_grid, dedx_func, S, kB, C)
        L_model = np.interp(E_data, E_grid, L_grid)
        r = (L_model - L_data)
        if (prior_S is not None) and (prior_sigma is not None) and (prior_sigma > 0):
            r = np.concatenate([r, np.array([(S - prior_S)/prior_sigma])])
        return r

    p0 = np.array(init[:(3 if use_C else 2)], dtype=float)
    lower = np.array(bounds[0][:len(p0)], dtype=float)
    upper = np.array(bounds[1][:len(p0)], dtype=float)

    opt = least_squares(residuals, p0, bounds=(lower, upper), jac='2-point')
    # Covariance estimate from the Jacobian
    theta = opt.x
    r = opt.fun
    J = opt.jac            # (N x p) numerical Jacobian
    N = len(r)
    p = len(theta)
    dof = max(1, N - p)

    # SSE and residual variance
    SSE = float(np.dot(r, r))
    sigma2_hat = SSE / dof

    # Covariance via (J^T J)^(-1) scaled by sigma^2 (with SVD fallback)
    # Note: least_squares returns J at the solution for residuals, so J^T J is the GN Hessian approx.
    JTJ = J.T @ J
    try:
        cov_unscaled = np.linalg.inv(JTJ)
    except np.linalg.LinAlgError:
        # robust SVD-based pseudo-inverse if nearly singular
        U, s, VT = np.linalg.svd(J, full_matrices=False)
        cov_unscaled = VT.T @ np.diag(1.0 / (s**2)) @ VT
    cov = sigma2_hat * cov_unscaled

    # Standard errors
    se = np.sqrt(np.diag(cov))
    # 95% CIs using normal approx (or use t-dist if you prefer)
    ci95 = np.column_stack([theta - 1.96*se, theta + 1.96*se])

    # Simple goodness-of-fit metrics (unweighted)
    L_bar = float(np.mean(L_data))
    SST = float(np.sum((L_data - L_bar)**2))
    R2 = 1.0 - (SSE / SST) if SST > 0 else np.nan
    R2_adj = 1.0 - (1.0 - R2) * (N - 1) / dof
    RMSE = np.sqrt(sigma2_hat)
    '''
    # (Assumes residual variance ~ 1; rescale by sigma^2 if known)
    J = opt.jac
    _, s, VT = np.linalg.svd(J, full_matrices=False)
    threshold = np.finfo(float).eps * max(J.shape) * s[0]
    s = s[s > threshold]
    VT = VT[:len(s)]
    cov = VT.T @ np.diag(1/s**2) @ VT
    '''
    # Pack results
    if use_C:
        S_hat, kB_hat, C_hat = opt.x
        seS, sekB, seC = se
        cov_full = cov
    else:
        S_hat, kB_hat = opt.x
        C_hat = 0.0
        #cov = np.pad(cov, ((0,1),(0,1)), mode='constant')
        seS, sekB = se
        seC = 0.0
        # pad covariance to 3x3 for uniform handling elsewhere
        cov_full = np.zeros((3,3), float); cov_full[:2,:2] = cov
        ciC = (0.0, 0.0)
        ci95 = np.vstack([ci95, [0.0, 0.0]])
    return dict(x=np.array([S_hat, kB_hat, C_hat]),
                success=opt.success,
                cost=opt.cost,
                message=opt.message,
                nfev=opt.nfev,
                se=np.array([seS, sekB, seC]),
                cov=cov_full,
                ci95=ci95,
                stats=dict(
                    N=N, p=p, dof=dof, SSE=SSE, RMSE=RMSE, R2=R2, R2_adj=R2_adj, sigma2=sigma2_hat
                ),
                )
inverse_with_bands
inverse_with_bands(L_vals, E_of_L_central, E_of_L_samplers)

For each L, return median and central 68% interval of E across samplers.

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def inverse_with_bands(L_vals, E_of_L_central, E_of_L_samplers):
    """
    For each L, return median and central 68% interval of E across samplers.
    """
    L_vals = np.atleast_1d(L_vals)
    E_med = E_of_L_central(L_vals)
    if len(E_of_L_samplers) == 0:
        return E_med, None, None
    Es = np.vstack([s(L_vals) for s in E_of_L_samplers])
    lo, hi = np.nanpercentile(Es, [16, 84], axis=0)
    return E_med, lo, hi
light_integral_grid
light_integral_grid(E_grid, dedx_func, S, kB, C=0.0)

Returns L(E) tabulated on E_grid using cumulative trapezoid integration of dL/dE. dL/dE = S / (1 + kBdEdx + CdEdx^2)

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def light_integral_grid(E_grid, dedx_func, S, kB, C=0.0):
    """
    Returns L(E) tabulated on E_grid using cumulative trapezoid integration of dL/dE.
    dL/dE = S / (1 + kB*dEdx + C*dEdx^2)
    """
    dEdx_vals = dedx_func(E_grid)
    denom = 1.0 + kB * dEdx_vals + C * dEdx_vals**2
    integrand = S / denom
    L_grid = cumulative_trapezoid(integrand, E_grid, initial=0.0)
    return L_grid
make_forward_inverse_LUT
make_forward_inverse_LUT(dedx_func, S, kB, C=0.0, E_max=250.0, nE=125001)

Build dense forward LUT (E->L) and inverse (L->E) interpolants. nE large => smooth & accurate integrals + inversion.

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def make_forward_inverse_LUT(dedx_func, S, kB, C=0.0, E_max=250.0, nE=125001):
    """
    Build dense forward LUT (E->L) and inverse (L->E) interpolants.
    nE large => smooth & accurate integrals + inversion.
    """
    E_grid = np.linspace(0.0, E_max, nE)
    L_grid = light_integral_grid(E_grid, dedx_func, S, kB, C)
    # Ensure strict monotonicity for inversion (should be true physically)
    L_grid_mon, E_grid_mon = ensure_monotone_increasing(L_grid, E_grid)
    # Forward: E->L (fast linear or PCHIP)
    L_of_E = PchipInterpolator(E_grid, L_grid, extrapolate=False)
    # Inverse: L->E via PCHIP on swapped axes
    E_of_L = PchipInterpolator(L_grid_mon, E_grid_mon, extrapolate=False)
    return (E_grid, L_grid, L_of_E, E_of_L)
make_inverse_sampler
make_inverse_sampler(dedx_func, params_samples, E_max=250.0, nE=125001)

Build many inverse interpolants E(L) for uncertainty bands. Returns a list of callables E_of_L_samplers.

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def make_inverse_sampler(dedx_func, params_samples, E_max=250.0, nE=125001):
    """
    Build many inverse interpolants E(L) for uncertainty bands.
    Returns a list of callables E_of_L_samplers.
    """
    samplers = []
    for (S, kB, C) in params_samples:
        if not is_valid_params(S, kB, C, dedx_func, E_max=E_max, nE=min(40001, nE)):
            continue  # skip pathological draws
        _, _, _, E_of_L = make_forward_inverse_LUT(dedx_func, S, kB, C, E_max, nE)
        samplers.append(E_of_L)
    return samplers
pm_fmt
pm_fmt(val, err, unit=None, sig_figs=2)

Format 'val ± err' preserving significant digits, handling very small numbers (e.g., 0.00301 ± 0.00012).

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def pm_fmt(val, err, unit=None, sig_figs=2):
    """
    Format 'val ± err' preserving significant digits,
    handling very small numbers (e.g., 0.00301 ± 0.00012).
    """
    # Safety checks
    if err is None or not np.isfinite(err) or err <= 0:
        s = f"{val:.{sig_figs}g}"
        return f"{s} {unit}" if unit else s

    # Determine the order of magnitude of the uncertainty
    exp_err = int(np.floor(np.log10(err)))
    # Round to 'sig_figs' significant digits in the uncertainty
    rounded_err = round(err, -exp_err + (sig_figs - 1))
    # Match value rounding to same decimal place
    decimals = max(0, -(exp_err - (sig_figs - 1)))
    fmt = f"{{0:.{decimals}f}} ± {{1:.{decimals}f}}"
    s = fmt.format(val, rounded_err)

    # Handle very small values nicely (avoid scientific when not needed)
    if abs(val) < 1e-3 or abs(rounded_err) < 1e-3:
        s = f"{val:.{sig_figs}e} ± {err:.{sig_figs}e}"

    return f"{s} {unit}" if unit else s
read_SRIM_output
read_SRIM_output(path_to_SRIM_output)

Parses a SRIM output file, returning a dictionary object with particle energies in MeV and mass stopping powers in MeV / (g/cm^2).

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def read_SRIM_output(path_to_SRIM_output):
    '''
    Parses a SRIM output file, returning a dictionary object with particle energies in MeV and
    mass stopping powers in MeV / (g/cm^2).
    '''
    E_MeV = []
    dEdx_ele = []
    dEdx_nuc = []
    dEdx_tot = []
    E_to_MeV_mults = {'meV':1e-9, 'eV':1e-6, 'keV':1e-3, 'MeV':1, 'GeV':1e+3, 'TeV':1e+6}
    stopping_units = ''
    with open(path_to_SRIM_output, 'r') as f:
        lines = f.readlines()
        in_data_table_section = False
        for line in lines:
            if '  --------------  ---------- ---------- ----------  ----------  ----------' in line:
                in_data_table_section = True
                continue
            if '-----------------------------------------------------------' in line:
                in_data_table_section = False
                continue
            if "Stopping Units =" in line:
                stopping_units = line.split('=')[-1].strip() # should be 'MeV / (mg/cm2)', but good to check anyways
                if stopping_units != 'MeV / (mg/cm2)':
                    print(f'WARNING: Stopping units are {stopping_units}, not the expected "MeV / (mg/cm2)".')
            if not in_data_table_section: continue
            parts = line.split()
            E_unit = parts[1].strip()
            E_MeV.append(float(parts[0])*E_to_MeV_mults[E_unit])
            dEdx_ele.append(float(parts[2]))
            dEdx_nuc.append(float(parts[3]))
            dEdx_tot.append(dEdx_ele[-1]+dEdx_nuc[-1])
    # scale up mass topping powers to MeV / (g/cm^2)
    stopping_units_mult = 1000
    SRIM_output = {
        'E_MeV':np.array(E_MeV),
        'dEdx_units':'MeV / (g/cm^2)',
        'dEdx_units_TeX':r'MeV/(g/cm$^2$)',
        'dEdx_electronic':np.array(dEdx_ele)*stopping_units_mult,
        'dEdx_nuclear':np.array(dEdx_nuc)*stopping_units_mult,
        'dEdx_total':np.array(dEdx_tot)*stopping_units_mult,
    }
    return SRIM_output
read_light_response_file
read_light_response_file(path_to_light_output_data)

This function is for reading Joey's two-column light response data points files "LightOutput_*.dat". Column 1 is the recoil proton energy Ep / neutron energy lost dEn in MeV, and Column 2 is the light response in MeVee It returns a dictionary object where the Ep and L pairs are proerly ordered, ascending by Ep Blank lines delimit values taken from different source neutron energies

Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def read_light_response_file(path_to_light_output_data):
    '''
    This function is for reading Joey's two-column light response data points files "LightOutput_*.dat".
    Column 1 is the recoil proton energy Ep / neutron energy lost dEn in MeV, and
    Column 2 is the light response in MeVee
    It returns a dictionary object where the Ep and L pairs are proerly ordered, ascending by Ep
    Blank lines delimit values taken from different source neutron energies
    '''
    PTB_En_vals = ['14.8 MeV', '6.5 MeV', '2.5 MeV']
    En_index = 0
    PTB_En_indices = []
    Ep_MeV = []
    L_MeVee = []
    with open(path_to_light_output_data, 'r') as f:
        lines = f.readlines()
        for line in lines:
            if len(line.strip())==0:
                En_index += 1
                continue
            parts = line.split()
            Ep_MeV.append(float(parts[0]))
            L_MeVee.append(float(parts[1]))
            PTB_En_indices.append(En_index)
    # Now reorder by Ep
    #Ep_MeV, L_MeVee = zip(*sorted(zip(Ep_MeV, L_MeVee)))
    return {'Ep_MeV':np.array(Ep_MeV), 'L_MeVee':np.array(L_MeVee), 'En_strs':PTB_En_vals, 'En_indices':np.array(PTB_En_indices)}
save_lut_npz_csv
save_lut_npz_csv(basepath, L_inv, E_inv, meta)

Saves: - basepath + ".npz" (binary, fast) - basepath + ".csv" (plaintext, two columns: L_inv,E_inv) - basepath + ".meta.json" (small JSON metadata, human-readable)

Parameters:

Name Type Description Default
basepath str | Path

Path without extension (e.g., Path("results/lut_M600_proton")). The function appends .npz, .csv, and .meta.json automatically.

required
L_inv array - like

Inverse lookup arrays: L_inv (MeVee) and E_inv (MeV).

required
E_inv array - like

Inverse lookup arrays: L_inv (MeVee) and E_inv (MeV).

required
meta dict

Metadata dictionary describing the LUT contents.

required
Source code in ngimager/tools/generate_lut/NOVO_light_response_functions.py
def save_lut_npz_csv(basepath, L_inv, E_inv, meta):
    """
    Saves:
      - basepath + ".npz"  (binary, fast)
      - basepath + ".csv"  (plaintext, two columns: L_inv,E_inv)
      - basepath + ".meta.json" (small JSON metadata, human-readable)

    Parameters
    ----------
    basepath : str | Path
        Path *without* extension (e.g., Path("results/lut_M600_proton")).
        The function appends .npz, .csv, and .meta.json automatically.
    L_inv, E_inv : array-like
        Inverse lookup arrays: L_inv (MeVee) and E_inv (MeV).
    meta : dict
        Metadata dictionary describing the LUT contents.
    """
    basepath = Path(basepath)
    basepath.parent.mkdir(parents=True, exist_ok=True)
    npz_path  = basepath.with_suffix(".npz")
    csv_path  = basepath.with_suffix(".csv")
    json_path = basepath.with_suffix(".meta.json")
    # NPZ
    np.savez(npz_path, L_inv=L_inv, E_inv=E_inv, meta=np.array([meta], dtype=object))
    # CSV (plaintext)
    arr = np.column_stack([L_inv, E_inv])
    header = "L_inv_MeVee,E_inv_MeV"
    np.savetxt(csv_path, arr, delimiter=",", header=header, comments="", fmt="%.8g")
    # JSON meta (plaintext)
    with open(json_path, "w", encoding="utf-8") as f:
        json.dump(meta, f, indent=2)

hdf5_to_root

ngimager.tools.hdf5_to_root

This module provides a standalone HDF5 → ROOT converter for ngimager output files. Although it is distributed as part of the ngimager package, the module is intentionally self-contained and can be used independently — for example by downloading just this file from GitHub without installing ngimager.

Dependencies

Only two Python packages are required:

- h5py     (for reading the ngimager HDF5 file)
- uproot   (for writing the output ROOT file)

No part of ngimager's internal codebase is imported here; the converter makes no assumptions beyond the documented HDF5 file structure.

Standalone Usage (Command Line)

If you have Python along with h5py and uproot installed, you can run:

python hdf5_to_root.py my_run.h5
python hdf5_to_root.py my_run.h5 -o output.root
python hdf5_to_root.py my_run.h5 --overwrite

The script will write my_run.root (or the specified output path) containing ROOT TTrees for list-mode hits, cones, list-mode imaging pixel mappings, summed SBP images, and file/run metadata.

Standalone Usage (Python API)

You may also import and call the converter from a standalone script:

from pathlib import Path
from hdf5_to_root import convert_hdf5_to_root

convert_hdf5_to_root(Path("my_run.h5"), Path("my_run.root"), overwrite=True)
Running Under ngimager (Installed Package)

When installed as part of ngimager, the ng-hdf2root console entry point is registered automatically and can be invoked as:

ng-hdf2root my_run.h5

The behavior is identical to running main() from this module.

Purpose

The converter is designed for ROOT-centric analysis workflows. It flattens the HDF5 list-mode representation into ROOT-friendly TTrees that preserve all linkages between events, hits, cones, and (when present) list-mode imaging pixels. This enables fast, flexible histogramming and correlation analysis in ROOT with minimal dependence on the rest of ngimager.

convert_hdf5_to_root
convert_hdf5_to_root(hdf_path, root_path, overwrite=False)

Convert a single ngimager HDF5 output file into a ROOT file using uproot.

Parameters:

Name Type Description Default
hdf_path Path

Input HDF5 path produced by ngimager.

required
root_path Path

Output ROOT path to create.

required
overwrite bool

If False (default), refuse to overwrite an existing file.

False
Source code in ngimager/tools/hdf5_to_root.py
def convert_hdf5_to_root(
    hdf_path: Path,
    root_path: Path,
    overwrite: bool = False,
) -> None:
    """
    Convert a single ngimager HDF5 output file into a ROOT file using uproot.

    Parameters
    ----------
    hdf_path:
        Input HDF5 path produced by ngimager.
    root_path:
        Output ROOT path to create.
    overwrite:
        If False (default), refuse to overwrite an existing file.
    """
    if not hdf_path.exists():
        raise FileNotFoundError(f"HDF5 file not found: {hdf_path}")

    if root_path.exists() and not overwrite:
        raise FileExistsError(
            f"Refusing to overwrite existing ROOT file: {root_path}"
        )

    with h5py.File(hdf_path, "r") as f, uproot.recreate(root_path) as root_file:
        # Per-hit/per-event list-mode tree
        lm_arrays = _build_lm_tree(f)
        root_file.mktree("lm", lm_arrays)

        # Cones (including incident_energy_MeV)
        cones_arrays = _build_cones_tree(f)
        if cones_arrays is not None:
            root_file.mktree("cones", cones_arrays)

        # Cone→pixel mappings (list-mode imaging)
        cone_pix_arrays = _build_cone_pixels_tree(f)
        if cone_pix_arrays is not None:
            root_file.mktree("cone_pixels", cone_pix_arrays)

        # Summed images (optional)
        images_arrays = _build_images_summed_tree(f)
        if images_arrays is not None:
            root_file.mktree("images_summed", images_arrays)

        # File-level metadata
        meta_arrays = _build_file_meta_tree(f)
        root_file.mktree("file_meta", meta_arrays)

        # Free-form run metadata (optional)
        run_meta_arrays = _build_run_meta_tree(f)
        if run_meta_arrays is not None:
            root_file.mktree("run_meta", run_meta_arrays)

inspect_cone

inspect_cone.py

Helper for tracing a single cone through the ngimager HDF5 file.

Given an ngimager HDF5 output and either: * a cone index, * an event index, or * an "imaged" cone index (from /lm/event_imaged_cone_id),

this tool reconstructs the full chain

cone → (u, v) pixels → hits

and prints a human-readable summary. Optionally it can also plot the per-cone footprint on the imaging plane.

Usage
python -m ngimager.tools.inspect_cone path/to/file.h5 --cone-index 42
python -m ngimager.tools.inspect_cone path/to/file.h5 --event-index 10
python -m ngimager.tools.inspect_cone path/to/file.h5 --imaged-cone-index 7

# Show a quick imshow of the cone footprint:
python -m ngimager.tools.inspect_cone file.h5 --cone-index 42 --plot
Notes
  • Intended for list-mode ngimager outputs (run.list = true), but will gracefully degrade when list-mode pixel data are missing.
  • For large files, /lm/cone_pixel_indices can be big; this tool reads the whole dataset into memory, which is convenient but may be heavy for extremely large runs.
ConeTrace dataclass
ConeTrace(cone_index, event_index, is_gamma, cone_apex_cm, cone_axis_dir, cone_theta_deg, cone_incident_energy_MeV, cone_species_code, cone_recoil_code, gamma_hit_order, hits, pixels_available, flat_pixel_indices, u_idx, v_idx, image_uv)

In-memory representation of one cone and its provenance.

trace_cone_from_index
trace_cone_from_index(f, cone_index)

High-level helper: build a ConeTrace object for cone_index.

Source code in ngimager/tools/inspect_cone.py
def trace_cone_from_index(f: h5py.File, cone_index: int) -> ConeTrace:
    """
    High-level helper: build a ConeTrace object for cone_index.
    """
    cones_grp = f.get("/cones")
    if not isinstance(cones_grp, h5py.Group):
        raise RuntimeError("This file has no /cones group; did the pipeline reach stage 3?")

    cone_ids = cones_grp.get("cone_id")
    if cone_ids is None:
        raise RuntimeError("Missing /cones/cone_id dataset.")

    n_cones = int(cone_ids.shape[0])
    if cone_index < 0 or cone_index >= n_cones:
        raise IndexError(f"cone_index {cone_index} is out of range [0, {n_cones}).")

    # Cone-level quantities (dataset names follow hdf5.md and lm_store.py)
    event_index_ds = cones_grp.get("event_index")
    if event_index_ds is None:
        raise RuntimeError("Missing /cones/event_index dataset for cone→event mapping.")
    event_index = int(event_index_ds[cone_index])

    apex_cm = np.asarray(cones_grp["apex_xyz_cm"][cone_index], dtype=np.float64)
    axis_dir = np.asarray(cones_grp["axis_xyz"][cone_index], dtype=np.float64)
    theta_rad = float(cones_grp["theta_rad"][cone_index])
    theta_deg = float(np.degrees(theta_rad))

    incident_E = float(cones_grp["incident_energy_MeV"][cone_index])
    species_code = int(cones_grp["species"][cone_index])
    recoil_code = int(cones_grp["recoil_code"][cone_index])

    gamma_hit_order_ds = cones_grp.get("gamma_hit_order")
    gamma_hit_order_row: Optional[np.ndarray]
    if gamma_hit_order_ds is not None:
        row = np.asarray(gamma_hit_order_ds[cone_index], dtype=np.int8)
        # Treat rows with any non-negative entry as meaningful sequencing.
        if row.ndim == 1 and row.size == 3 and np.any(row >= 0):
            gamma_hit_order_row = row
        else:
            gamma_hit_order_row = None
    else:
        gamma_hit_order_row = None

    # Event- and hit-level quantities
    lm_grp = f.get("/lm")
    hits: List[Dict[str, Any]] = []
    is_gamma = (species_code == 1)

    if isinstance(lm_grp, h5py.Group):
        event_type_ds = lm_grp.get("event_type")
        if event_type_ds is not None and event_index < event_type_ds.shape[0]:
            event_type_code = int(event_type_ds[event_index])
            is_gamma = (event_type_code == 1)

        # Hit-level datasets are optional; populate hits only if present.
        hit_pos_ds = lm_grp.get("hit_pos_cm")
        hit_t_ds = lm_grp.get("hit_t_ns")
        hit_L_ds = lm_grp.get("hit_L_mevee")
        hit_det_ds = lm_grp.get("hit_det_id")
        hit_mat_ds = lm_grp.get("hit_material_id")

        have_hits = all(
            ds is not None
            for ds in (hit_pos_ds, hit_t_ds, hit_L_ds, hit_det_ds, hit_mat_ds)
        )

        if have_hits:
            hit_pos = np.asarray(hit_pos_ds[event_index], dtype=np.float64)  # (3, 3)
            hit_t = np.asarray(hit_t_ds[event_index], dtype=np.float64)      # (3,)
            hit_L = np.asarray(hit_L_ds[event_index], dtype=np.float64)      # (3,)
            hit_det = np.asarray(hit_det_ds[event_index], dtype=np.int32)    # (3,)
            hit_mat = np.asarray(hit_mat_ds[event_index], dtype=np.int16)    # (3,)

            mat_lookup = _material_lookup(lm_grp)

            for j in range(hit_pos.shape[0]):
                r = hit_pos[j]
                # Sentinel convention: NaN position or det_id < 0 means "no hit"
                if np.all(np.isnan(r)) and hit_det[j] < 0:
                    continue
                mat_id = int(hit_mat[j])
                mat_name = mat_lookup.get(mat_id, "UNK" if mat_id < 0 else f"id={mat_id}")
                hits.append(
                    {
                        "hit_index": j,
                        "r_cm": r,
                        "t_ns": float(hit_t[j]),
                        "L_mevee": float(hit_L[j]),
                        "det_id": int(hit_det[j]),
                        "material_id": mat_id,
                        "material": mat_name,
                    }
                )

    # Pixel footprint (list-mode imaging); graceful when missing
    try:
        flat_pixels, u_idx, v_idx, pixels_available = _extract_cone_pixels(f, cone_index)
        nv, nu = _infer_image_shape(f)
        img = _reconstruct_image(nv, nu, u_idx, v_idx)
    except Exception:
        # Either grid or list-mode pixels are unavailable; fall back to an
        # empty image/pixel set but keep cone+hit data.
        flat_pixels = np.zeros(0, dtype=np.uint32)
        u_idx = np.zeros(0, dtype=np.int64)
        v_idx = np.zeros(0, dtype=np.int64)
        pixels_available = False
        img = np.zeros((0, 0), dtype=np.int32)

    return ConeTrace(
        cone_index=cone_index,
        event_index=event_index,
        is_gamma=is_gamma,
        cone_apex_cm=apex_cm,
        cone_axis_dir=axis_dir,
        cone_theta_deg=theta_deg,
        cone_incident_energy_MeV=incident_E,
        cone_species_code=species_code,
        cone_recoil_code=recoil_code,
        gamma_hit_order=gamma_hit_order_row,
        hits=hits,
        pixels_available=pixels_available,
        flat_pixel_indices=flat_pixels,
        u_idx=u_idx,
        v_idx=v_idx,
        image_uv=img,
    )

inspect_root

inspect_root.py

Tiny helper for exploring ROOT files with uproot.

It prints: - Top-level keys and their class names - Directory structure (recursively) - For TTrees: branch names and types - Optionally: first few entries of a chosen TTree

Usage

Basic structure listing:

python inspect_root.py myfile.root

Show branches and a few entries from a specific tree:

python inspect_root.py myfile.root --tree image_tree --show-entries 5

If you're not sure of the tree name, just run without --tree first and look at the printed keys (TTrees are marked).

With real example file:

python src/ngimager/tools/inspect_root.py examples/imaging_datasets/NOVO_experiment_DT_at_PTB/autoSorted_coinc_detector_DT-14p8MeV_000041.root

python src/ngimager/tools/inspect_root.py examples/imaging_datasets/NOVO_experiment_DT_at_PTB/autoSorted_coinc_detector_DT-14p8MeV_000041.root --tree meta
Notes
  • Requires uproot (pip install uproot).
  • Works with uproot 4/5-style API.
describe_tree
describe_tree(tree, tree_path)

Print basic info about a TTree: number of entries and branch info.

Source code in ngimager/tools/inspect_root.py
def describe_tree(tree: uproot.behaviors.TTree.TTree, tree_path: str) -> None:
    """
    Print basic info about a TTree: number of entries and branch info.
    """
    print()
    print(f"=== TTree: {tree_path} ===")
    print(f"Entries: {tree.num_entries}")
    print("Branches:")
    for name, branch in tree.items():
        # branch.interpretation gives type-ish info
        try:
            interp = branch.interpretation
        except Exception:
            interp = "unknown"
        print(f"  - {name}: {interp}")
find_tree
find_tree(rootfile, tree_path)

Try to resolve a tree path like 'tree', 'dir/tree', etc.

Source code in ngimager/tools/inspect_root.py
def find_tree(
    rootfile: uproot.reading.ReadOnlyDirectory, tree_path: str
) -> Optional[uproot.behaviors.TTree.TTree]:
    """
    Try to resolve a tree path like 'tree', 'dir/tree', etc.
    """
    # Handle simple case first
    if tree_path in rootfile:
        obj = rootfile[tree_path]
        if isinstance(obj, uproot.behaviors.TTree.TTree):
            return obj

    # Try splitting directory-like paths
    parts = tree_path.split("/")
    current = rootfile
    for part in parts:
        if not part:
            continue
        if part not in current:
            return None
        current = current[part]
    if isinstance(current, uproot.behaviors.TTree.TTree):
        return current
    return None
show_entries
show_entries(tree, n)

Print the first n entries. If the tree has exactly one entry, pretty-print as key = value lines, which is great for metadata.

Source code in ngimager/tools/inspect_root.py
def show_entries(tree: uproot.behaviors.TTree.TTree, n: int) -> None:
    """
    Print the first n entries. If the tree has exactly one entry,
    pretty-print as key = value lines, which is great for metadata.
    """
    total = tree.num_entries
    n = min(n, total)
    if n <= 0:
        print("\n(No entries requested or available.)")
        return

    arrays = tree.arrays(entry_stop=n, library="np")

    if n == 1:
        print(f"\nSingle entry from tree '{tree.name}':")
        for branch_name, values in arrays.items():
            # values is a 1D array of length 1
            try:
                scalar = values[0]
            except Exception:
                scalar = values
            print(f"  {branch_name} = {scalar!r}")
    else:
        print(f"\nFirst {n} entries from tree '{tree.name}':")
        for branch_name, values in arrays.items():
            print(f"  {branch_name}: {values!r}")
walk_directory
walk_directory(directory, prefix='')

Recursively print keys and class names under a ROOT directory.

Source code in ngimager/tools/inspect_root.py
def walk_directory(directory: uproot.reading.ReadOnlyDirectory, prefix: str = "") -> None:
    """
    Recursively print keys and class names under a ROOT directory.
    """
    for key in directory.keys():
        # uproot keys include ";1" etc; keep the short name for readability
        short_name = key.split(";")[0]
        full_path = f"{prefix}/{short_name}" if prefix else short_name
        cls_name = directory.classname_of(key)

        print(f"[{full_path}]  ({cls_name})")

        # Subdirectories are also ReadOnlyDirectory instances
        try:
            obj = directory[short_name]
        except Exception:
            continue

        if isinstance(obj, uproot.reading.ReadOnlyDirectory):
            walk_directory(obj, prefix=full_path)

phits_legacy_2_usrdef

phits_legacy_2_usrdef.py

Convert a NOVO legacy imaging pickle file (containing neutron_records and gamma_records) into a PHITS usrdef-short style text file that can be parsed directly by the modern ng-imager PHITS adapter (parse_phits_usrdef_short / from_phits_usrdef).

This script is the structural inverse of phits_usrdef_2_legacy.py.

USAGE

Basic conversion:

$ python phits_legacy_2_usrdef.py legacy_imaging_records.pickle

This produces:

legacy_imaging_records_usrdef.out

in the same directory as the input file.

Specify an explicit output file:

$ python phits_legacy_2_usrdef.py legacy_imaging_records.pickle -o my_usrdef.out
INPUT FORMAT

The input must be a pickle file produced by the legacy imaging pipeline or by phits_usrdef_2_legacy.py. It must contain (at minimum) the keys:

'neutron_records'   -- numpy structured array of 2-hit neutron events
'gamma_records'     -- numpy structured array of 3-hit gamma events

The dtype layouts follow the legacy NOVO event record definitions.

OUTPUT FORMAT

The output is a plain-text file in PHITS usrdef-short format, where each line has the structure:

event_type  iomp  batch  hist  no  name  ;  reg Edep x y z t  ,  reg Edep x y z t  , ...

For example:

ne 0 0 0 12 0 ;  200 0.45 -3.5 16.2 0.4 4.70  ,  210 0.31 -3.3 16.8 0.6 4.78  ,

ge 0 0 0 7  0 ;  101 0.20  2.1 -3.0 0.9 7.50  ,  105 0.12 2.8 -3.3 1.1 7.56 ,
                     110 0.07  3.4 -4.1 1.4 7.63  ,

These lines are fully compatible with:

parse_phits_usrdef_short(path)
from_phits_usrdef(path)
PHITSAdapter(...).iter_raw_events(path)
NOTES

• PHITS bookkeeping fields (iomp, batch, hist, name) are assigned default placeholder values; feel free to extend the script to generate structured metadata.

• Legacy dE/Elong values are written as Edep(MeV); the ng-imager parser treats energy fields transparently.

• This converter is intended for analysis, debugging, and round-trip testing between the legacy pipeline and the modern ng-imager framework.

convert_legacy_to_usrdef
convert_legacy_to_usrdef(input_path, output_path)

Convert legacy pickle → usrdef-short compatible text.

Output rows follow the format:

event_type  iomp  batch  hist  no  name  ;  hit1  ,  hit2  , ...

where: • event_type = 'ne' or 'ge' • hit = 6 floating fields: reg Edep x_cm y_cm z_cm t_ns

Source code in ngimager/tools/phits_legacy_2_usrdef.py
def convert_legacy_to_usrdef(input_path: Path, output_path: Path) -> None:
    """
    Convert legacy pickle → usrdef-short compatible text.

    Output rows follow the format:

        event_type  iomp  batch  hist  no  name  ;  hit1  ,  hit2  , ...

    where:
      • event_type = 'ne' or 'ge'
      • hit = 6 floating fields: reg Edep x_cm y_cm z_cm t_ns
    """

    header = "!Required final counter 1 value =       2 ; Required final counter 2 value =       3\n" +\
        "!       #iomp    #batch  #history       #no     #name        #reg  EdepA(MeV)      xA(cm)      yA(cm)      zA(cm)      tA(ns)        #reg  EdepB(MeV)      xB(cm)      yB(cm)      zB(cm)      tB(ns)        #reg  EdepC(MeV)      xC(cm)      yC(cm)      zC(cm)      tC(ns)\n" +\
        "!ncol   Z   N jcl kcl nclsts\n" +\
        "!In/Out kf-code     E(MeV)      weight \n"

    with open(input_path, "rb") as f:
        data = pickle.load(f)

    n_rec = data.get("neutron_records", [])
    g_rec = data.get("gamma_records", [])

    # PHITS bookkeeping placeholders
    iomp = 0
    batch = 0
    hist = 0
    name = 0

    lines = []

    # -------------------------
    # Neutrons → "ne"
    # -------------------------
    for idx, ev in enumerate(n_rec):
        # Legacy dtype schema ensures x1,y1,z1,t1,dE1,det1,Elong1 exist
        # Combine dE1 and Elong1 possibility: use Elong1 as Edep(MeV) proxy
        hits = []

        # first scatter
        hits.append(format_hit(
            int(ev["det1"]),
            float(ev["Elong1"]),   # PHITS expects MeV; Elong is MeVee but this is acceptable for round-trip
            float(ev["x1"]),
            float(ev["y1"]),
            float(ev["z1"]),
            float(ev["t1"]),
        ))

        # second scatter
        hits.append(format_hit(
            int(ev["det2"]),
            float(ev["Elong2"]),
            float(ev["x2"]),
            float(ev["y2"]),
            float(ev["z2"]),
            float(ev["t2"]),
        ))

        line = f"ne {iomp} {batch} {hist} {idx} {name} ; " + " , ".join(hits) + " ,"
        lines.append(line)

    # -------------------------
    # Gammas → "ge"
    # -------------------------
    for idx, ev in enumerate(g_rec):
        hits = []

        try:
            d1 = int(ev["det1"])
        except:
            d1 = 1
        try:
            d2 = int(ev["det2"])
        except:
            d2 = 2
        try:
            d3 = int(ev["det3"])
        except:
            d3 = 3

        hits.append(format_hit(
            d1, float(ev["dE1"]),
            float(ev["x1"]), float(ev["y1"]), float(ev["z1"]),
            float(ev["t1"]),
        ))
        hits.append(format_hit(
            d2, float(ev["dE2"]),
            float(ev["x2"]), float(ev["y2"]), float(ev["z2"]),
            float(ev["t2"]),
        ))
        hits.append(format_hit(
            d3, float(ev["dE3"]),
            float(ev["x3"]), float(ev["y3"]), float(ev["z3"]),
            float(ev["t3"]),
        ))

        line = f"ge {iomp} {batch} {hist} {idx} {name} ; " + " , ".join(hits) + " ,"
        lines.append(line)

    # -------------------------
    # Write file
    # -------------------------
    with open(output_path, "w", encoding="utf-8") as f:
        f.write(header + "\n")
        for L in lines:
            f.write(L + "\n\n")

    print(f"usrdef-style file written: {output_path}")
format_hit
format_hit(reg, edep, x, y, z, t)

Return one PHITS hit group as: reg Edep(MeV) x(cm) y(cm) z(cm) t(ns) (Space-separated, no commas — the adapter ignores commas and semicolons anyway.)

Source code in ngimager/tools/phits_legacy_2_usrdef.py
def format_hit(reg: int, edep: float, x: float, y: float, z: float, t: float) -> str:
    """
    Return one PHITS hit group as:
        reg  Edep(MeV)  x(cm)  y(cm)  z(cm)  t(ns)
    (Space-separated, no commas — the adapter ignores commas and semicolons anyway.)
    """
    return f"{reg:d} {edep:.6g} {x:.6g} {y:.6g} {z:.6g} {t:.6g}"

phits_usrdef_2_legacy

phits_usrdef_2_legacy.py

Convert PHITS custom tally output into the event format expected by the legacy NOVO imaging code.

For now this is just a skeleton: it only parses CLI arguments and sets up a main() entry point.

convert_phits_to_legacy
convert_phits_to_legacy(input_path, output_path)

Stub for the actual conversion logic.

Parameters:

Name Type Description Default
input_path Path

PHITS custom tally output.

required
output_path Path

Destination file in the legacy imaging format.

required
Source code in ngimager/tools/phits_usrdef_2_legacy.py
def convert_phits_to_legacy(input_path: Path, output_path: Path) -> None:
    """
    Stub for the actual conversion logic.

    Parameters
    ----------
    input_path : Path
        PHITS custom tally output.
    output_path : Path
        Destination file in the legacy imaging format.
    """
    neutron_event_record_type = np.dtype([('type', 'S1'),
                                          ('x1', np.single), ('y1', np.single), ('z1', np.single), ('t1', np.single),
                                          ('dE1', np.single),
                                          ('x2', np.single), ('y2', np.single), ('z2', np.single), ('t2', np.single),
                                          ('det1', np.short), ('det2', np.short),
                                          ('psd1', np.single), ('psd2', np.single),
                                          ('Elong1', np.single), ('Elong2', np.single),
                                          ('protons_only', np.bool_), ('theta_MCtruth', np.single)])
    gamma_event_record_type = np.dtype([('type', 'S1'),
                                        ('x1', np.single), ('y1', np.single), ('z1', np.single), ('dE1', np.single),
                                        ('x2', np.single), ('y2', np.single), ('z2', np.single), ('dE2', np.single),
                                        ('x3', np.single), ('y3', np.single), ('z3', np.single), ('dE3', np.single),
                                        ('t1', np.single), ('t2', np.single), ('t3', np.single),
                                        ('det1', np.short), ('det2', np.short), ('det3', np.short),
                                        ('psd1', np.single), ('psd2', np.single), ('psd3', np.single),
                                        ('Elong1', np.single), ('Elong2', np.single), ('Elong3', np.single),
                                        ('theta1_MCtruth', np.single), ('theta2_MCtruth', np.single)])

    # First pass: count neutron and gamma events to allocate exact-size arrays
    n_neutron_events = 0
    n_gamma_events = 0
    with input_path.open("r", encoding="utf-8") as f:
        for li, line in enumerate(f):
            line = line.strip()
            if len(line) == 0: continue  # skip blank lines
            if li < 4: continue  # skip header lines
            line = line[:-1]  # exclude trailing comma
            line_info, _ = line.split(";")
            event_type, iomp, batch, history, phitsno, phitsname = line_info.split()
            if event_type == "ne":
                n_neutron_events += 1
            elif event_type == "ge":
                n_gamma_events += 1

    n_im_recs = np.empty((n_neutron_events), dtype=neutron_event_record_type)  # neutron imaging records
    n_im_recs_exp = np.empty((n_neutron_events), dtype=neutron_event_record_type)  # neutron imaging records, but with coordinates at bar centers
    i_nir = 0  # current index of neutron imaging records
    g_im_recs = np.empty((n_gamma_events), dtype=gamma_event_record_type)  # gamma imaging records
    g_im_recs_exp = np.empty((n_gamma_events), dtype=gamma_event_record_type)  # gamma imaging records, but with coordinates at bar centers
    i_gir = 0  # current index of gamma imaging records

    with input_path.open("r", encoding="utf-8") as f:
        for li, line in enumerate(f):
            line = line.strip()  
            if len(line)==0: continue  # skip blank lines
            if li < 4: continue  # skip header lines
            line = line[:-1]  # exclude trailing comma

            line_info, line_content = line.split(';')
            event_type, iomp, batch, history, phitsno, phitsname = line_info.split()
            #hits = line_content.split(',')
            hits = [h for h in line_content.split(',') if h.strip()]
            if event_type == 'ne':
                hits_iter = sorted(
                    hits,
                    key=lambda h: float(h.split()[-1])  # t is the last field
                )
            else:
                hits_iter = hits

            for ih, hit in enumerate(hits_iter, start=1):
                reg, edep, x, y, z, t = hit.split()
                if event_type == 'ne':  # neutron event
                    if ih > 2: continue  # code not prepared for >2x neutron coincs
                    if ih==1:
                        n_im_recs['type'][i_nir] = 'n'
                        n_im_recs[f'dE{ih}'][i_nir] = edep
                        n_im_recs['protons_only'][i_nir] = True  # we don't actually know this for short version of usrdef.out
                        n_im_recs['theta_MCtruth'][i_nir] = None  # not knowable since we don't have neutron origin location
                    n_im_recs[f'x{ih}'][i_nir] = x
                    n_im_recs[f'y{ih}'][i_nir] = y
                    n_im_recs[f'z{ih}'][i_nir] = z
                    n_im_recs[f't{ih}'][i_nir] = t
                    n_im_recs[f'det{ih}'][i_nir] = reg
                    n_im_recs[f'Elong{ih}'][i_nir] = edep
                    n_im_recs[f'psd{ih}'][i_nir] = None
                elif event_type == 'ge':
                    if ih > 3: continue  # code not prepared for >3x gamma coincs
                    if ih==1:
                        g_im_recs['type'][i_gir] = 'g'
                        g_im_recs['theta1_MCtruth'][i_gir] = None  # not knowable since we don't have neutron origin location
                        g_im_recs['theta2_MCtruth'][i_gir] = None  # not knowable since we don't have neutron origin location
                    g_im_recs[f'x{ih}'][i_gir] = x
                    g_im_recs[f'y{ih}'][i_gir] = y
                    g_im_recs[f'z{ih}'][i_gir] = z
                    g_im_recs[f't{ih}'][i_gir] = t
                    g_im_recs[f'dE{ih}'][i_gir] = edep
                    g_im_recs[f'det{ih}'][i_gir] = reg
                    g_im_recs[f'Elong{ih}'][i_gir] = edep
                    g_im_recs[f'psd{ih}'][i_gir] = None
            if event_type == 'ne':
                n_im_recs_exp[i_nir] = n_im_recs[i_nir]
                i_nir += 1
            elif event_type == 'ge':
                g_im_recs_exp[i_gir] = g_im_recs[i_gir]
                i_gir += 1

    with open(output_path, 'wb') as handle:
        to_be_pickled = {'neutron_records': n_im_recs, 'gamma_records': g_im_recs,
                         'neutron_records_exp': n_im_recs_exp, 'gamma_records_exp': g_im_recs_exp,
                         'sim_base_folder_name': input_path}
        pickle.dump(to_be_pickled, handle, protocol=pickle.HIGHEST_PROTOCOL)
        print('Pickle file written:', output_path, '\n')
determine_output_path
determine_output_path(input_path, output_path)

Determine the final output .pickle path.

Rules: - If output_path is None: same directory as input, filename = input basename + '_imaging_records.pickle' e.g. usrdef.out -> usrdef.out_imaging_records.pickle - If output_path is provided: ensure it ends with '.pickle'; if not, append '.pickle'.

Source code in ngimager/tools/phits_usrdef_2_legacy.py
def determine_output_path(input_path: Path, output_path: Path | None):
    """
    Determine the final output .pickle path.

    Rules:
    - If output_path is None:
        same directory as input, filename = input basename + '_imaging_records.pickle'
        e.g. usrdef.out -> usrdef.out_imaging_records.pickle
    - If output_path is provided:
        ensure it ends with '.pickle'; if not, append '.pickle'.
    """
    if output_path is None:
        #default_name = input_path.stem + "_imaging_records.pickle"
        default_name = "imaging_data_records.pickle"
        return input_path.parent / default_name

    p = output_path
    if not str(p).endswith(".pickle"):
        # Append .pickle rather than replacing existing extension(s)
        p = p.with_name(p.name + ".pickle")
    return p

vis

hdf

render_summed_images
render_summed_images(h5_path, species=('n', 'g', 'all'), filename_pattern='{species}_{stem}.{ext}', center_on_plane_center=True, flip_vertical=True, axis_units='cm', cmap='cividis', formats=('png',), projections=False, roi_u_min_cm=None, roi_u_max_cm=None, roi_v_min_cm=None, roi_v_max_cm=None, plot_label=None, metrics_source='auto', curve_mode='all+roi', annotate_summary='compact', show_metrics_panel=False, show_peak_markers=True, show_edge_markers=True, show_centroid_2d=False)

Render /images/summed/* datasets from an ng-imager HDF5 file to image files.

When projections=True, each figure shows: - the main 2D image (u vs v), - a 1D projection along u above the image, - a 1D projection along v to the left of the image, - an optional ROI rectangle (if roi_*_cm are provided), - an annotation of the number of cones contributing to that species. - and (when available) a run-level plot label drawn from [run].plot_label or from the plot_label argument.

Source code in ngimager/vis/hdf.py
 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
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
def render_summed_images(
    h5_path: str | Path,
    species: Sequence[str] = ("n", "g", "all"),
    filename_pattern: str = "{species}_{stem}.{ext}",
    center_on_plane_center: bool = True,
    flip_vertical: bool = True,
    axis_units: Literal["cm", "mm"] = "cm",
    cmap: str = "cividis",
    formats: Sequence[str] = ("png",),
    projections: bool = False,
    roi_u_min_cm: float | None = None,
    roi_u_max_cm: float | None = None,
    roi_v_min_cm: float | None = None,
    roi_v_max_cm: float | None = None,
    plot_label: str | None = None,
    metrics_source: str = "auto",
    curve_mode: str = "all+roi",
    annotate_summary: str = "compact",
    show_metrics_panel: bool = False,
    show_peak_markers: bool = True,
    show_edge_markers: bool = True,
    show_centroid_2d: bool = False,
) -> list[Path]:
    """
    Render `/images/summed/*` datasets from an ng-imager HDF5 file to image files.

    When `projections=True`, each figure shows:
      - the main 2D image (u vs v),
      - a 1D projection along u **above** the image,
      - a 1D projection along v **to the left** of the image,
      - an optional ROI rectangle (if roi_*_cm are provided),
      - an annotation of the number of cones contributing to that species.
      - and (when available) a run-level plot label drawn from [run].plot_label
        or from the `plot_label` argument.
    """

    h5_path = Path(h5_path)
    stem = h5_path.stem

    # Normalize species and formats
    species_list: list[str] = []
    for s in species:
        s = str(s).lower()
        if s in ("n", "g", "all") and s not in species_list:
            species_list.append(s)

    fmt_list: list[str] = []
    for fmt in formats:
        fmt = str(fmt).lower().lstrip(".")
        if fmt and fmt not in fmt_list:
            fmt_list.append(fmt)
    if not fmt_list:
        fmt_list = ["png"]

    # ROI rectangle in cm
    roi_cm: tuple[float, float, float, float] | None = None
    if (
        roi_u_min_cm is not None
        and roi_u_max_cm is not None
        and roi_v_min_cm is not None
        and roi_v_max_cm is not None
    ):
        roi_cm = (
            float(roi_u_min_cm),
            float(roi_u_max_cm),
            float(roi_v_min_cm),
            float(roi_v_max_cm),
        )

    out_paths: list[Path] = []

    with h5py.File(h5_path, "r") as f:
        if "images" not in f or "summed" not in f["images"]:
            return []

        summed_grp = f["images"]["summed"]
        meta_attrs = f["meta"].attrs

        # Default plot label: prefer explicit argument, then /meta attribute.
        stored_plot_label: str | None = None
        if "run_plot_label" in meta_attrs:
            try:
                stored_plot_label = str(meta_attrs["run_plot_label"])
            except Exception:
                stored_plot_label = None

        has_n = "n" in summed_grp
        has_g = "g" in summed_grp

        for sp in species_list:
            # Skip "all" if we don't have both species present.
            if sp == "all" and not (has_n and has_g):
                continue
            if sp not in summed_grp:
                continue

            img = np.array(summed_grp[sp], dtype=np.float32)
            nv, nu = img.shape

            # Axes / extent / pixel sizes from metadata
            extent, axis_labels, (du_plot, dv_plot), (
                u_min_cm,
                u_max_cm,
                v_min_cm,
                v_max_cm,
            ) = _axes_from_meta(meta_attrs, center_on_plane_center, axis_units)

            # Pixel centers in cm
            du_cm = float(meta_attrs["grid.du"])
            dv_cm = float(meta_attrs["grid.dv"])
            u_centers_cm = u_min_cm + (np.arange(nu) + 0.5) * du_cm
            v_centers_cm = v_min_cm + (np.arange(nv) + 0.5) * dv_cm

            # Global projections
            proj_u = img.sum(axis=0)  # shape (nu,)
            proj_v = img.sum(axis=1)  # shape (nv,)

            proj_u_roi = None
            proj_v_roi = None

            if roi_cm is not None:
                ru0, ru1, rv0, rv1 = roi_cm
                u_mask = (u_centers_cm >= ru0) & (u_centers_cm <= ru1)
                v_mask = (v_centers_cm >= rv0) & (v_centers_cm <= rv1)

                proj_u_roi = np.zeros_like(proj_u)
                proj_v_roi = np.zeros_like(proj_v)

                if np.any(u_mask) and np.any(v_mask):
                    block = img[np.ix_(v_mask, u_mask)]
                    proj_u_roi[u_mask] = block.sum(axis=0)
                    proj_v_roi[v_mask] = block.sum(axis=1)

            # Flags describing ROI / curves / metrics
            has_roi_projections = (proj_u_roi is not None) and (proj_v_roi is not None)
            draw_all_curve = True
            draw_roi_curve = False
            prefer_roi_metrics = False

            if projections:
                draw_all_curve, draw_roi_curve, prefer_roi_metrics = _resolve_projection_plot_prefs(
                    has_roi_projections,
                    metrics_source,
                    curve_mode,
                )

            # ------------------------------------------------------------------
            # Optional: read precomputed projection metrics (positions in cm)
            # ------------------------------------------------------------------
            peak_u_cm = peak_v_cm = None
            edge_low_u_cm = edge_high_u_cm = None
            edge_low_v_cm = edge_high_v_cm = None

            def _format_axis_summary(
                axis_label: str,
                metrics: Mapping[str, Any],
                mode: str,
            ) -> Optional[str]:
                """
                Build a small annotation string for one axis from its metrics.

                axis_label: "u" or "v"
                mode      : "off" | "compact" | "full"
                """
                mode = (mode or "off").lower()
                if mode == "off":
                    return None

                def _get(name: str) -> Optional[float]:
                    val = metrics.get(name)
                    try:
                        val = float(val)
                    except (TypeError, ValueError):
                        return None
                    if not np.isfinite(val):
                        return None
                    return val

                peak = _get("peak_pos_cm")
                width = _get("edge_width_cm")
                mean = _get("mean_cm")
                std = _get("std_cm")
                edge_low = _get("edge_low_cm")
                edge_high = _get("edge_high_cm")

                # Convert from cm to the chosen axis_units (cm or mm)
                scale = 10.0 if axis_units == "mm" else 1.0
                unit_label = axis_units

                def _conv(x: Optional[float]) -> Optional[float]:
                    if x is None:
                        return None
                    return x * scale

                peak = _conv(peak)
                width = _conv(width)
                mean = _conv(mean)
                std = _conv(std)
                edge_low = _conv(edge_low)
                edge_high = _conv(edge_high)

                parts: list[str] = []

                if mode == "compact":
                    # Prefer peak + width when available
                    if peak is not None:
                        parts.append(f"peak={peak:.2f} {unit_label}")
                    if width is not None:
                        parts.append(f"width={width:.2f} {unit_label}")
                    if not parts and mean is not None and std is not None:
                        parts.append(f"mean={mean:.2f}±{std:.2f} {unit_label}")
                else:  # "full"
                    if peak is not None:
                        parts.append(f"peak={peak:.2f} {unit_label}")
                    if mean is not None:
                        parts.append(f"mean={mean:.2f} {unit_label}")
                    if std is not None:
                        parts.append(f"std={std:.2f} {unit_label}")
                    if edge_low is not None and edge_high is not None:
                        parts.append(
                            f"edges=[{edge_low:.2f}, {edge_high:.2f}] {unit_label}"
                        )
                    elif width is not None:
                        parts.append(f"width={width:.2f} {unit_label}")

                if not parts:
                    return None

                lines = [f"{axis_label}:"]
                for p in parts:
                    lines.append(f"  {p}")
                return "\n".join(lines)

            def _render_metrics_panel(
                    ax_panel,
                    metrics: Mapping[str, Mapping[str, Mapping[str, Any]]],
            ) -> None:
                """
                Render a table of metrics into ax_panel.

                metrics[axis][source] -> dict of scalar values
                axis   in {"u", "v"}
                source in {"all", "roi"}
                """
                # Collect rows in a stable order
                rows: list[tuple[str, str, Mapping[str, Any]]] = []
                for axis_name in ("u", "v"):
                    axis_metrics = metrics.get(axis_name, {})
                    for src_name in ("all", "roi"):
                        m = axis_metrics.get(src_name)
                        if m:
                            rows.append((axis_name, src_name, m))

                ax_panel.set_axis_off()

                if not rows:
                    ax_panel.text(
                        0.0,
                        1.0,
                        "No projection metrics available",
                        transform=ax_panel.transAxes,
                        ha="left",
                        va="top",
                        fontsize=7,
                    )
                    return

                # Helper to fetch and convert from cm to chosen axis_units
                scale = 10.0 if axis_units == "mm" else 1.0
                unit_label = axis_units

                def _get(m: Mapping[str, Any], name: str) -> Optional[float]:
                    val = m.get(name)
                    try:
                        val = float(val)
                    except (TypeError, ValueError):
                        return None
                    if not np.isfinite(val):
                        return None
                    return val * scale

                # Build table rows:
                # [axis/source, peak, mean, median, std, low, high, width]
                table_rows: list[list[str]] = []
                for axis_name, src_name, m in rows:
                    peak = _get(m, "peak_pos_cm")
                    mean = _get(m, "mean_cm")
                    median = _get(m, "median_cm")
                    std = _get(m, "std_cm")
                    edge_low = _get(m, "edge_low_cm")
                    edge_high = _get(m, "edge_high_cm")
                    width = _get(m, "edge_width_cm")

                    def _fmt(x: Optional[float]) -> str:
                        return f"{x:.2f}" if x is not None else ""

                    row = [
                        f"{axis_name}/{src_name}",
                        _fmt(peak),
                        _fmt(mean),
                        _fmt(median),
                        _fmt(std),
                        _fmt(edge_low),
                        _fmt(edge_high),
                        _fmt(width),
                    ]
                    table_rows.append(row)

                col_labels = [
                    "axis/src",
                    f"peak [{unit_label}]",
                    f"mean [{unit_label}]",
                    f"median [{unit_label}]",
                    f"std [{unit_label}]",
                    f"low [{unit_label}]",
                    f"high [{unit_label}]",
                    f"width [{unit_label}]",
                ]

                table = ax_panel.table(
                    cellText=table_rows,
                    colLabels=col_labels,
                    loc="upper center",
                    cellLoc="center",
                    bbox=[0.0, -0.15, 1.0, 0.9],
                )
                table.auto_set_font_size(False)
                table.set_fontsize(7)
                table.scale(1.0, 1.2)

            metrics_sel: dict[str, dict[str, dict[str, Any]]] = {}

            if projections:
                # Load all available metrics for this species
                metrics_raw = _load_projection_metrics(summed_grp, sp)
                metrics_sel = _resolve_projection_metrics(metrics_raw, metrics_source)

                def _pick_axis_metrics(
                    axis_name: str,
                ) -> tuple[dict[str, Any] | None, Optional[str]]:
                    """
                    Return (metrics, source) for the given axis.

                    source ∈ {"all", "roi"} or None if no metrics available.
                    """
                    axis_metrics = metrics_sel.get(axis_name, {})
                    if not axis_metrics:
                        return None, None

                    has_all = "all" in axis_metrics and axis_metrics["all"]
                    has_roi = "roi" in axis_metrics and axis_metrics["roi"]

                    msrc = (metrics_source or "auto").lower()
                    if msrc == "all" and has_all:
                        return axis_metrics["all"], "all"
                    if msrc == "roi" and has_roi:
                        return axis_metrics["roi"], "roi"
                    if msrc == "both":
                        # For overlays, prefer ROI when both exist
                        if has_roi:
                            return axis_metrics["roi"], "roi"
                        if has_all:
                            return axis_metrics["all"], "all"
                        return None, None

                    # "auto" or unknown: prefer ROI, else all
                    if has_roi:
                        return axis_metrics["roi"], "roi"
                    if has_all:
                        return axis_metrics["all"], "all"
                    return None, None

                u_metrics, u_metrics_source = _pick_axis_metrics("u")
                v_metrics, v_metrics_source = _pick_axis_metrics("v")


                if u_metrics is not None:
                    peak_u_cm = u_metrics.get("peak_pos_cm")
                    edge_low_u_cm = u_metrics.get("edge_low_cm")
                    edge_high_u_cm = u_metrics.get("edge_high_cm")

                if v_metrics is not None:
                    peak_v_cm = v_metrics.get("peak_pos_cm")
                    edge_low_v_cm = v_metrics.get("edge_low_cm")
                    edge_high_v_cm = v_metrics.get("edge_high_cm")

                # -------------------------
                # Centroid metrics (cm)
                # Prefer ROI centroid if available, else ALL
                # -------------------------
                u_centroid_cm = None
                v_centroid_cm = None

                # ROI has priority
                if u_metrics_source == "roi":
                    u_centroid_cm = u_metrics.get("mean_cm")
                elif u_metrics_source == "all":
                    u_centroid_cm = u_metrics.get("mean_cm")
                else:
                    # "auto" case handled via u_metrics_source
                    if u_metrics is not None:
                        u_centroid_cm = u_metrics.get("mean_cm")

                if v_metrics_source == "roi":
                    v_centroid_cm = v_metrics.get("mean_cm")
                elif v_metrics_source == "all":
                    v_centroid_cm = v_metrics.get("mean_cm")
                else:
                    if v_metrics is not None:
                        v_centroid_cm = v_metrics.get("mean_cm")


                # Edge fractions (0–1) from metrics/u and metrics/v attributes
                edge_fracs: dict[str, tuple[Optional[float], Optional[float]]] = {}

                try:
                    proj_root = summed_grp.get("projections")
                    if isinstance(proj_root, h5py.Group):
                        sp_root = proj_root.get(sp)
                    else:
                        sp_root = None
                    if isinstance(sp_root, h5py.Group):
                        metrics_root = sp_root.get("metrics")
                    else:
                        metrics_root = None

                    if isinstance(metrics_root, h5py.Group):
                        for axis_name in ("u", "v"):
                            axis_grp = metrics_root.get(axis_name)
                            if not isinstance(axis_grp, h5py.Group):
                                continue
                            low = axis_grp.attrs.get("edge_low_frac", None)
                            high = axis_grp.attrs.get("edge_high_frac", None)
                            try:
                                low_f = float(low) if low is not None else None
                            except (TypeError, ValueError):
                                low_f = None
                            try:
                                high_f = float(high) if high is not None else None
                            except (TypeError, ValueError):
                                high_f = None
                            if low_f is not None or high_f is not None:
                                edge_fracs[axis_name] = (low_f, high_f)
                except Exception:
                    edge_fracs = {}



            # Convert centers to plot units (cm or mm) and apply centering
            u_mid_cm = 0.5 * (u_min_cm + u_max_cm)
            v_mid_cm = 0.5 * (v_min_cm + v_max_cm)
            unit_scale = 10.0 if axis_units == "mm" else 1.0

            if center_on_plane_center:
                u_centers_plot = (u_centers_cm - u_mid_cm) * unit_scale
                v_centers_plot = (v_centers_cm - v_mid_cm) * unit_scale
            else:
                u_centers_plot = u_centers_cm * unit_scale
                v_centers_plot = v_centers_cm * unit_scale

            # Convert metric positions (if any) into plot units
            peak_u_plot = edge_low_u_plot = edge_high_u_plot = None
            peak_v_plot = edge_low_v_plot = edge_high_v_plot = None
            u_centroid_plot = None
            v_centroid_plot = None

            def _cm_to_plot_u(x_cm: Optional[float]) -> Optional[float]:
                if x_cm is None:
                    return None
                if center_on_plane_center:
                    return (x_cm - u_mid_cm) * unit_scale
                return x_cm * unit_scale

            def _cm_to_plot_v(y_cm: Optional[float]) -> Optional[float]:
                if y_cm is None:
                    return None
                if center_on_plane_center:
                    return (y_cm - v_mid_cm) * unit_scale
                return y_cm * unit_scale

            if peak_u_cm is not None:
                peak_u_plot = _cm_to_plot_u(peak_u_cm)
            if edge_low_u_cm is not None:
                edge_low_u_plot = _cm_to_plot_u(edge_low_u_cm)
            if edge_high_u_cm is not None:
                edge_high_u_plot = _cm_to_plot_u(edge_high_u_cm)

            if peak_v_cm is not None:
                peak_v_plot = _cm_to_plot_v(peak_v_cm)
            if edge_low_v_cm is not None:
                edge_low_v_plot = _cm_to_plot_v(edge_low_v_cm)
            if edge_high_v_cm is not None:
                edge_high_v_plot = _cm_to_plot_v(edge_high_v_cm)

            # Convert centroids
            if u_centroid_cm is not None:
                u_centroid_plot = _cm_to_plot_u(u_centroid_cm)
            if v_centroid_cm is not None:
                v_centroid_plot = _cm_to_plot_v(v_centroid_cm)


            # ----------------- Figure layout -----------------
            if projections and (nu > 1 or nv > 1):
                # Decide whether a metrics panel makes sense for this species
                has_any_metrics = bool(
                    metrics_sel
                    and any(metrics_sel.get(ax) for ax in ("u", "v"))
                )
                want_metrics_panel = show_metrics_panel and has_any_metrics

                if want_metrics_panel:
                    # Layout with metrics panel as a wide bottom row:
                    #   row 0: [ legend | top u-proj | empty ]
                    #   row 1: [ left v-proj | image | colorbar ]
                    #   row 2: [       metrics table (spans all 3 columns)      ]
                    fig = plt.figure(figsize=(8.0, 8.5))
                    gs = GridSpec(
                        3,
                        3,
                        width_ratios=[1.3, 4.0, 0.4],
                        height_ratios=[1.3, 4.0, 0.9],  # shrink bottom row
                        wspace=0.08,
                        hspace=0.12,  # slightly reduced vertical spacing
                        figure=fig,
                    )

                    ax_legend = fig.add_subplot(gs[0, 0])
                    ax_top = fig.add_subplot(gs[0, 1])
                    ax_img = fig.add_subplot(gs[1, 1], sharex=ax_top)
                    ax_left = fig.add_subplot(gs[1, 0], sharey=ax_img)
                    ax_cb = fig.add_subplot(gs[1, 2])
                    ax_metrics = fig.add_subplot(gs[2, :])
                    ax_metrics.set_axis_off()
                else:
                    # Layout (no metrics panel):
                    #   row 0: [ legend | top u-proj | empty ]
                    #   row 1: [ left v-proj | image | colorbar ]
                    fig = plt.figure(figsize=(8.0, 8.0))
                    gs = GridSpec(
                        2,
                        3,
                        width_ratios=[1.3, 4.0, 0.4],
                        height_ratios=[1.3, 4.0],
                        wspace=0.08,
                        hspace=0.08,
                        figure=fig,
                    )

                    ax_legend = fig.add_subplot(gs[0, 0])
                    ax_top = fig.add_subplot(gs[0, 1])
                    ax_img = fig.add_subplot(gs[1, 1], sharex=ax_top)
                    ax_left = fig.add_subplot(gs[1, 0], sharey=ax_img)
                    ax_cb = fig.add_subplot(gs[1, 2])
                    ax_metrics = None


                # Global legend in the legend panel
                ax_legend.axis("off")
                legend_handles = [
                    Line2D([0], [0], label="all", **_PROJ_STYLE_ALL),
                    Line2D([0], [0], label="ROI", **_PROJ_STYLE_ROI),
                    Line2D([0], [0], label="peak", **_METRIC_STYLE_PEAK),
                    Line2D([0], [0], label="edges", **_METRIC_STYLE_EDGE),
                ]
                ax_legend.legend(
                    handles=legend_handles,
                    loc="center left",
                    bbox_to_anchor=(-0.1, 0.5),
                    borderaxespad=0.0,
                    frameon=False,
                    fontsize=8,
                )

                # Hide redundant tick labels
                plt.setp(ax_top.get_xticklabels(), visible=False)
                # We'll show v-ticks and the v-label on the LEFT projection,
                # and hide y tick labels on the main image to avoid overlap.
                plt.setp(ax_img.get_yticklabels(), visible=False)


            else:
                # Simple image + colorbar layout (no projections)
                fig = plt.figure(figsize=(6.0, 5.0))
                gs = GridSpec(1, 2, width_ratios=[12.0, 0.6], figure=fig)
                ax_img = fig.add_subplot(gs[0, 0])
                ax_cb = fig.add_subplot(gs[0, 1])
                ax_top = None
                ax_left = None

            # ----------------- Main image -----------------
            im = ax_img.imshow(
                img,
                origin="lower",
                extent=extent,
                cmap=cmap,
                aspect="auto",
            )
            if flip_vertical:
                ax_img.invert_yaxis()

            # Lock limits to image bounds (no white gaps)
            ax_img.set_xlim(extent[0], extent[1])
            ax_img.set_ylim(extent[2], extent[3])

            ax_img.set_xlabel(axis_labels[0])
            # In projections mode, the left panel owns the v-axis label.
            if projections and ax_left is not None:
                ax_img.set_ylabel("")
            else:
                ax_img.set_ylabel(axis_labels[1])

            # Colorbar
            cbar = fig.colorbar(im, cax=ax_cb)
            px_unit = axis_units
            cbar.set_label(
                f"counts per {du_plot:g} × {dv_plot:g} {px_unit}² pixel"
            )

            # ROI rectangle
            if projections and roi_cm is not None:
                ru0, ru1, rv0, rv1 = roi_cm
                if center_on_plane_center:
                    ru0_plot = (ru0 - u_mid_cm) * unit_scale
                    ru1_plot = (ru1 - u_mid_cm) * unit_scale
                    rv0_plot = (rv0 - v_mid_cm) * unit_scale
                    rv1_plot = (rv1 - v_mid_cm) * unit_scale
                else:
                    ru0_plot = ru0 * unit_scale
                    ru1_plot = ru1 * unit_scale
                    rv0_plot = rv0 * unit_scale
                    rv1_plot = rv1 * unit_scale

                rect = Rectangle(
                    (ru0_plot, rv0_plot),
                    ru1_plot - ru0_plot,
                    rv1_plot - rv0_plot,
                    fill=False,
                    linestyle="--",
                    linewidth=1.3,
                    edgecolor="white",
                    alpha=0.9,
                )
                ax_img.add_patch(rect)

            # 2D centroid crosshair overlay
            if projections and show_centroid_2d:
                if (u_centroid_plot is not None) and (v_centroid_plot is not None):

                    # Short crosshair arms (5% of image span)
                    u_span = extent[1] - extent[0]
                    v_span = extent[3] - extent[2]
                    hu = 0.05 * u_span
                    hv = 0.05 * v_span

                    # Horizontal line
                    ax_img.hlines(
                        v_centroid_plot,
                        u_centroid_plot - hu,
                        u_centroid_plot + hu,
                        colors="white",
                        linewidth=1.2,
                        alpha=0.9,
                    )
                    # Vertical line
                    ax_img.vlines(
                        u_centroid_plot,
                        v_centroid_plot - hv,
                        v_centroid_plot + hv,
                        colors="white",
                        linewidth=1.2,
                        alpha=0.9,
                    )

                    # Annotation (top-left inside image)
                    ax_img.text(
                        0.02, 0.98,
                        f"+  centroid = ({u_centroid_plot:.2f}, {v_centroid_plot:.2f}) {axis_units}",
                        transform=ax_img.transAxes,
                        ha="left",
                        va="top",
                        fontsize=8,
                        color="white",
                        bbox=dict(
                            boxstyle="round,pad=0.2",
                            facecolor="black",
                            edgecolor="none",
                            alpha=0.4,
                        ),
                    )


            # Cone-count annotation (above image, inside its axes coordinates)
            n_cones = _count_cones_for_species(f, sp)
            if n_cones is not None:
                if sp == "all":
                    txt = f"{n_cones} n+g event cones"
                elif sp == "n":
                    txt = f"{n_cones} neutron event cones"
                elif sp == "g":
                    txt = f"{n_cones} gamma event cones"
                else:
                    txt = f"{n_cones} event cones"
                ax_img.text(
                    0.5,
                    1.01,
                    txt,
                    transform=ax_img.transAxes,
                    ha="center",
                    va="bottom",
                    fontsize=10,
                )

            # ----------------- Projections -----------------
            if projections and ax_top is not None and ax_left is not None:
                # -------------------------
                # U-projection (top panel)
                # -------------------------
                ax_top.set_ylabel("Σ counts (over v)")
                ax_top.grid(alpha=0.2)

                line_all_u = None
                line_roi_u = None
                ax_top2 = None

                # Primary axis: draw "all" if enabled
                if draw_all_curve:
                    (line_all_u,) = ax_top.plot(
                        u_centers_plot,
                        proj_u,
                        label="all",
                        **_PROJ_STYLE_ALL,
                    )

                # ROI curve (if available and enabled)
                if draw_roi_curve and proj_u_roi is not None:
                    if draw_all_curve:
                        # Secondary y-axis: scaled to ROI only
                        ax_top2 = ax_top.twinx()
                        (line_roi_u,) = ax_top2.plot(
                            u_centers_plot,
                            proj_u_roi,
                            label="ROI",
                            **_PROJ_STYLE_ROI,
                        )

                        # Tight y-limits based on nonzero ROI values
                        nz = proj_u_roi[proj_u_roi > 0]
                        if nz.size > 0:
                            ymin, ymax = float(nz.min()), float(nz.max())
                            pad = 0.05 * (ymax - ymin) if ymax > ymin else max(
                                ymax * 0.1,
                                1.0,
                            )
                            ax_top2.set_ylim(ymin - pad, ymax + pad)
                        else:
                            ax_top2.set_ylim(0.0, 1.0)

                        # Inside ticks on the right, colored to match ROI curve
                        ax_top2.yaxis.set_label_position("right")
                        ax_top2.yaxis.tick_right()
                        ax_top2.tick_params(
                            axis="y",
                            direction="out",
                            pad=3,  # small positive: just inside the frame
                            colors=_PROJ_STYLE_ROI["color"],
                            labelcolor=_PROJ_STYLE_ROI["color"],
                        )
                        ax_top2.set_ylabel("")
                    else:
                        # ROI-only mode: single axis
                        (line_roi_u,) = ax_top.plot(
                            u_centers_plot,
                            proj_u_roi,
                            label="ROI",
                            **_PROJ_STYLE_ROI,
                        )

                        # Tight y-limits from ROI curve on the primary axis
                        nz = proj_u_roi[proj_u_roi > 0]
                        if nz.size > 0:
                            ymin, ymax = float(nz.min()), float(nz.max())
                            pad = 0.05 * (ymax - ymin) if ymax > ymin else max(
                                ymax * 0.1,
                                1.0,
                            )
                            ax_top.set_ylim(ymin - pad, ymax + pad)
                        else:
                            ax_top.set_ylim(0.0, 1.0)

                # Overlay u-axis metrics if available
                if peak_u_plot is not None and show_peak_markers:
                    ax_top.axvline(
                        peak_u_plot,
                        linewidth=1.0,
                        **_METRIC_STYLE_PEAK,
                    )
                if edge_low_u_plot is not None and show_edge_markers:
                    ax_top.axvline(
                        edge_low_u_plot,
                        linewidth=1.0,
                        **_METRIC_STYLE_EDGE,
                    )
                if edge_high_u_plot is not None and show_edge_markers:
                    ax_top.axvline(
                        edge_high_u_plot,
                        linewidth=1.0,
                        **_METRIC_STYLE_EDGE,
                    )

                # Tiny annotations showing edge_low_frac / edge_high_frac (in %)
                u_low_frac, u_high_frac = edge_fracs.get("u", (None, None))
                if edge_low_u_plot is not None and u_low_frac is not None and show_edge_markers:
                    ax_top.text(
                        edge_low_u_plot,
                        1.01,
                        f"{u_low_frac * 100:.0f}%",
                        transform=ax_top.get_xaxis_transform(),
                        ha="center",
                        va="bottom",
                        fontsize=5,
                        color=_METRIC_STYLE_EDGE["color"],
                    )
                if edge_high_u_plot is not None and u_high_frac is not None and show_edge_markers:
                    ax_top.text(
                        edge_high_u_plot,
                        1.01,
                        f"{u_high_frac * 100:.0f}%",
                        transform=ax_top.get_xaxis_transform(),
                        ha="center",
                        va="bottom",
                        fontsize=5,
                        color=_METRIC_STYLE_EDGE["color"],
                    )


                # Optional numeric summary annotation for u
                mode_summary = (annotate_summary or "off").lower()
                if mode_summary != "off" and u_metrics is not None:
                    label_u = "u"
                    if u_metrics_source in ("all", "roi"):
                        label_u = f"u/{u_metrics_source}"
                    txt_u = _format_axis_summary(label_u, u_metrics, mode_summary)
                    if txt_u:
                        ax_top.text(
                            0.02,
                            0.98,
                            txt_u,
                            transform=ax_top.transAxes,
                            ha="left",
                            va="top",
                            fontsize=7,
                            color="black",
                            bbox=dict(
                                boxstyle="round,pad=0.2",
                                facecolor="white",
                                edgecolor="none",
                                alpha=0.7,
                            ),
                        )

                # -------------------------
                # V-projection (left panel)
                # -------------------------
                ax_left.set_xlabel("Σ counts (over u)")
                ax_left.set_ylabel(axis_labels[1])  # v-label lives here
                ax_left.grid(alpha=0.2)

                line_all_v = None
                line_roi_v = None
                ax_left2 = None

                # Primary axis: draw "all" if enabled
                if draw_all_curve:
                    (line_all_v,) = ax_left.plot(
                        proj_v,
                        v_centers_plot,
                        label="all",
                        **_PROJ_STYLE_ALL,
                    )

                # ROI curve (if available and enabled)
                if draw_roi_curve and proj_v_roi is not None:
                    if draw_all_curve:
                        # Secondary x-axis (top): scaled to ROI only
                        ax_left2 = ax_left.twiny()
                        (line_roi_v,) = ax_left2.plot(
                            proj_v_roi,
                            v_centers_plot,
                            label="ROI",
                            **_PROJ_STYLE_ROI,
                        )

                        # Tight x-limits from nonzero ROI
                        nz = proj_v_roi[proj_v_roi > 0]
                        if nz.size > 0:
                            xmin, xmax = float(nz.min()), float(nz.max())
                            pad = 0.05 * (xmax - xmin) if xmax > xmin else max(
                                xmax * 0.1,
                                1.0,
                            )
                            ax_left2.set_xlim(xmin - pad, xmax + pad)
                        else:
                            ax_left2.set_xlim(0.0, 1.0)

                        # Keep "zero on the right" for secondary axis as well
                        ax_left2.invert_xaxis()

                        # Inside ticks at the top edge, in ROI color
                        ax_left2.xaxis.set_label_position("top")
                        ax_left2.xaxis.tick_top()
                        ax_left2.tick_params(
                            axis="x",
                            direction="out",
                            pad=3,  # small positive: just inside
                            colors=_PROJ_STYLE_ROI["color"],
                            labelcolor=_PROJ_STYLE_ROI["color"],
                        )
                        ax_left2.set_xlabel("")
                        ax_left2.set_ylabel("")
                    else:
                        # ROI-only mode: single axis
                        (line_roi_v,) = ax_left.plot(
                            proj_v_roi,
                            v_centers_plot,
                            label="ROI",
                            **_PROJ_STYLE_ROI,
                        )

                        # Tight x-limits from ROI curve on the primary axis
                        nz = proj_v_roi[proj_v_roi > 0]
                        if nz.size > 0:
                            xmin, xmax = float(nz.min()), float(nz.max())
                            pad = 0.05 * (xmax - xmin) if xmax > xmin else max(
                                xmax * 0.1,
                                1.0,
                            )
                            ax_left.set_xlim(xmin - pad, xmax + pad)
                        else:
                            ax_left.set_xlim(0.0, 1.0)

                # Overlay v-axis metrics if available
                if peak_v_plot is not None and show_peak_markers:
                    ax_left.axhline(
                        peak_v_plot,
                        linewidth=1.0,
                        **_METRIC_STYLE_PEAK,
                    )
                if edge_low_v_plot is not None and show_edge_markers:
                    ax_left.axhline(
                        edge_low_v_plot,
                        linewidth=1.0,
                        **_METRIC_STYLE_EDGE,
                    )
                if edge_high_v_plot is not None and show_edge_markers:
                    ax_left.axhline(
                        edge_high_v_plot,
                        linewidth=1.0,
                        **_METRIC_STYLE_EDGE,
                    )

                # Tiny annotations showing edge_low_frac / edge_high_frac (in %)
                v_low_frac, v_high_frac = edge_fracs.get("v", (None, None))
                if edge_low_v_plot is not None and v_low_frac is not None and show_edge_markers:
                    ax_left.text(
                        1.002,  # just to the right of the axis frame
                        edge_low_v_plot,
                        f"{v_low_frac * 100:.0f}%",
                        transform=ax_left.get_yaxis_transform(),
                        ha="left",
                        va="center",
                        fontsize=5,
                        color=_METRIC_STYLE_EDGE["color"],
                    )
                if edge_high_v_plot is not None and v_high_frac is not None and show_edge_markers:
                    ax_left.text(
                        1.002,
                        edge_high_v_plot,
                        f"{v_high_frac * 100:.0f}%",
                        transform=ax_left.get_yaxis_transform(),
                        ha="left",
                        va="center",
                        fontsize=5,
                        color=_METRIC_STYLE_EDGE["color"],
                    )

                # Optional numeric summary annotation for v
                mode_summary = (annotate_summary or "off").lower()
                if mode_summary != "off" and v_metrics is not None:
                    label_v = "v"
                    if v_metrics_source in ("all", "roi"):
                        label_v = f"v/{v_metrics_source}"
                    txt_v = _format_axis_summary(label_v, v_metrics, mode_summary)
                    if txt_v:
                        ax_left.text(
                            0.02,
                            0.98,
                            txt_v,
                            transform=ax_left.transAxes,
                            ha="left",
                            va="top",
                            fontsize=7,
                            color="black",
                            bbox=dict(
                                boxstyle="round,pad=0.2",
                                facecolor="white",
                                edgecolor="none",
                                alpha=0.7,
                            ),
                        )


                # Keep "zero on the right" for the primary v-projection axis as well
                ax_left.invert_xaxis()

                # Render the metrics panel, if present
                if projections and (nu > 1 or nv > 1) and ax_metrics is not None:
                    _render_metrics_panel(ax_metrics, metrics_sel)


            # Suptitle for the whole figure
            species_label = {
                "n": "n",
                "g": "g",
                "all": "n+g",
            }.get(sp, sp)

            effective_label = plot_label or stored_plot_label
            if effective_label:
                title = f"{effective_label}\n{stem} : {species_label} cones"
            else:
                title = f"{stem} : {species_label} cones"

            fig.suptitle(title, y=0.98)

            # Leave room for suptitle. When the metrics panel is present, we
            # pull the whole subplot region down a bit (smaller `top`) and
            # also extend it further toward the bottom (smaller `bottom`) so
            # that:
            #   - the u-annotation and % labels have more headroom under the title
            #   - the metrics table drops below the u-axis label instead of
            #     overlapping it, filling the remaining white space.
            if not show_metrics_panel:
                fig.subplots_adjust(top=0.93)
            else:
                fig.subplots_adjust(top=0.92, bottom=0.05)

            # ----------------------------------------------------------------------
            # Add ngimager footer annotation (version + hyperlink)
            # ----------------------------------------------------------------------

            # HDF5 attributes already loaded earlier in the function
            software = f.attrs.get("software", "")
            docs_url = f.attrs.get("docs_url", "")

            # Text formatting (subtle, italic, gray)
            footer_font = {
                'color': '#666666',
                'weight': 'normal',
                'size': 8,
                'style': 'italic'
            }

            # Build footer string (only include URL/version if present)
            footer_parts = ["Figure generated by ng-imager"]
            if docs_url:
                footer_parts.append(f{docs_url}")
            if software:
                footer_parts.append(f{software}")

            footer_text = " ".join(footer_parts)

            # Place text at bottom-left of figure
            # url=docs_url makes this a working hyperlink in PDF output
            fig.text(
                0.005, 0.005,
                footer_text,
                fontdict=footer_font,
                ha='left',
                va='bottom',
                url=docs_url if docs_url else None
            )

            # Save in all requested formats
            for fmt in fmt_list:
                out_name = filename_pattern.format(
                    stem=stem,
                    species=sp,
                    ext=fmt,
                )
                out_path = h5_path.with_name(out_name)
                dpi = 150 if fmt in ("png", "jpg", "jpeg") else None
                fig.savefig(out_path, dpi=dpi)
                out_paths.append(out_path)

            plt.close(fig)

    return out_paths