Skip to main content
Yanked

This release has been yanked by its maintainers, and will be ignored by installers, except when explicitly specified.
Consider using release 0.3.0a5 instead.

LightHit

LightHit computes the expected optical-module response to light propagating in a homogeneous scattering medium. It solves the time-dependent radiative transfer equation (RTE), including unscattered, once-scattered and multiply-scattered light, and returns expected photoelectrons per module and per time bin.

The medium, detector and source are ordinary Python objects. There is no global initialisation state, so several configurations can safely coexist in one program.

LightHit is currently an alpha release. Validate numerical settings for your medium and geometry before using its results in an analysis.

Installation

python -m pip install lighthit

Numba acceleration is strongly recommended for tracks, showers and large detector arrays:

python -m pip install 'lighthit[accelerate]'

For the installed synthetic demonstration, viewer and wheel self-tests:

python -m pip install 'lighthit[demo,test]'
lighthit-selftest

Python 3.11 or newer is required.

To run the detector-specific scripts in this repository, install the checkout into that environment first: python -m pip install -e '.[demo,test]'. A script under examples/ does not automatically import the adjacent src/ tree; an older PyPI installation in the active environment will otherwise be used.

Quick start

The example below defines a spectral medium, two optical modules and a monochromatic isotropic flash.

import numpy as np
import lighthit as lh

# Optical properties tabulated versus wavelength.
medium = lh.SpectralMedium(
    wavelength_nm=[400.0, 450.0, 500.0],
    absorption_per_m=[0.030, 0.020, 0.040],
    scattering_per_m=[0.030, 0.022, 0.017],
    phase_index=[1.344, 1.339, 1.336],
    group_index=[1.386, 1.374, 1.367],
    g=0.9,
)

# Response functions receive NumPy arrays and return arrays of the same shape.
def angular_acceptance(head_on_cosine):
    return np.ones_like(np.asarray(head_on_cosine, dtype=float))

def spectral_efficiency(wavelength_nm):
    return np.full_like(np.asarray(wavelength_nm, dtype=float), 0.20)

detector = lh.DetectorArray(
    positions_m=[[20.0, 0.0, 0.0], [35.0, 0.0, 0.0]],
    orientations=[[-1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]],
    effective_area_m2=0.05,
    angular_acceptance=angular_acceptance,
    spectral_efficiency=spectral_efficiency,
)

source = lh.IsotropicFlash.monochromatic(
    position_m=[0.0, 0.0, 0.0],
    photons=1.0e8,
    wavelength_nm=450.0,
)

# build() returns a reusable kernel and prepares only the tables this source
# needs. The same kernel can then transport more compatible events.
kernel = lh.build(medium, detector, source)
response = kernel.transport(source)

print(response.charge_pe)   # integrated expected photoelectrons, one per OM
print(response.bins_pe)     # expected photoelectrons, shape (OM, time bin)

The first build may take noticeably longer than later event calculations. Set KernelConfig(cache_directory=...) to reuse transport tables across processes.

Sources

The high-level dispatcher accepts:

flash = lh.IsotropicFlash.monochromatic(
    position_m=[0, 0, 0], photons=1e8, wavelength_nm=450
)

track = lh.CherenkovTrack(
    start_m=[0, 0, -20],
    direction=[0.2, 0.1, 0.97],
    length_m=40,
    beta=1.0,
)

shower = lh.G4Shower.from_hdf5("event.h5", event=0)

synthetic_shower = lh.SyntheticShower.gaussian(
    lh.SourcePose(
        position_m=[25.0, -10.0, -720.0],
        direction=[0.4, 0.2, -0.89442719],
        time_ns=0.0,
    ),
    charged_track_length_m=300.0,
)

For tracks and showers, kernel.transport(source) automatically selects the source engine and uses the detector's angular response. A compatible prepared wavelength-folded cache is used when present; set KernelConfig(spectral_folded_cache=False) for the original reference path.

Applications can select any calculated signal without using the CLI:

response = kernel.transport(track)
prompt = response.select("0+1")  # unscattered plus once-scattered
print(prompt.charge_pe, prompt.bins_pe, prompt.rate_pe_per_ns)
# Other selectors: 0, 1, ">=2", "all".

This selects stored components; it does not skip the multiply-scattered solve or make the transport itself faster.

For a shower, transport_hybrid instead computes orders 0 and 1 with the direct physical-time prompt solver and order >=2 with the directional RTE. It requires the Numba accelerator and a prepared directional cache. With the standard 321-frequency cache, only 65 selected nodes are applied by default:

hybrid = kernel.transport_hybrid(shower, frequency_nodes=65)
print(hybrid.charge_pe, hybrid.bins_pe)

HybridTransportResponse.spectrum_ge2_pe contains only the >=2 spectrum on the computed frequency grid. Prompt orders have exact time bins but no stored nonzero-frequency spectrum. The active OMs receive the full prompt calculation; the integrated order-1 charge for screened, inactive OMs is a coarse estimate and is identified by metadata["prompt_order1_level"]. For a screening control, pass prompt_config=lh.PromptConfig(screen=False); this evaluates direct order 1 for every OM and costs more time.

To reduce the cache itself, build it on 65 frequencies and specify a separate 321-node readout grid. The readout grid is used only after the expensive solve:

config = lh.KernelConfig(
    omega_per_ns=np.linspace(0.0, 1.2, 65),
    readout_omega_per_ns=np.linspace(0.0, 1.2, 321),
    cache_directory="hybrid-cache",
    cache_policy="require",
)
kernel = lh.TransportKernel(medium, detector, config)
hybrid = kernel.transport_hybrid(shower, frequency_nodes=65)

Shower basis for order >=2

The order >=2 source of a shower can instead be represented on an axial-slice basis: cubic slices along the shower axis (0.25 m), a five-point transverse moment stencil and the exact angular harmonics of the kernel configuration. For compact showers, orders 0+1, the RTE cache, the active-OM rule and the readout are unchanged; only the order >=2 source differs:

basis = lh.ShowerBasis()          # cell_m=0.25, cubic slices, stencil
hybrid = kernel.transport_hybrid(shower, frequency_nodes=65, ge2_basis=basis)

The prepared order >=2 radial splines are kept on the kernel and reused by every later event. To start a job without rebuilding them, save them once and memory-map them afterwards:

kernel.ge2_kernel_store().save("ge2-pack")      # after the first event
kernel.ge2_kernel_store("ge2-pack")             # in a later process

On one BGVD cluster and a 100 GeV electron a warm event took 14 s instead of 628 s on the same 4-core machine (1.9 instead of 5.6 GiB), with the same active OMs, order >=2 charge within 2e-4 and total time-bin L1 within 0.2 % of the default hybrid; the basis source is closer to a converged source reference than the default one. See docs/research/shower-basis.md for the measured errors, including 1 TeV electrons and muons with showers.

For a long G4 muon source, the basis now uses a separate 3–300 m source window for each OM and sums the zero-frequency charge before selecting active modules. An explicitly selected nearly straight track can use the axial m=0 channel while its secondaries keep the shower basis:

hybrid = kernel.transport_hybrid(
    muon_event_source, frequency_nodes=65,
    ge2_basis=lh.ShowerBasis(track_uid=primary_muon_uid))

The track option is an approximation and remains opt-in. See docs/research/long-muon-radial-windows.md for the NTSim checks, cutoff comparison, timing, memory and current limitations.

The same ShowerBasis also supports cone_model="spectral": it applies the Cherenkov threshold and cone angle separately at every wavelength node. The existing RTE cache and prepared kernel pack are reused. On the checked 100 GeV NTSim electron it took 12.2 s versus 116 s for the spectral hybrid, with 26 matching active OMs. See docs/research/spectral-shower-basis.md for the formula, per-OM accuracy and NTSim run commands. The explicitly selected spectral muon route can additionally fuse wavelength bands and use 33 of the prepared 65 frequencies; on two checked 100 GeV G4 muons this preserved the active OMs and kept order >=2 time-bin L1 differences below 3% on bright OMs. See docs/research/muon-fast-spectral.md.

Configuring the calculation

Numerical settings are collected in KernelConfig:

config = lh.KernelConfig(
    wavelength_nodes=9,
    threshold_pe=0.01,
    cache_directory="lighthit-cache",
)

kernel = lh.build(medium, detector, track, config=config)
response = kernel.transport(track)

For production jobs, prepare the cache explicitly and forbid event processing from starting an expensive build unexpectedly:

build_config = lh.KernelConfig(cache_directory="lighthit-cache")
kernel = lh.TransportKernel(medium, detector, build_config)
kernel.build(source=track, progress="console")
print(kernel.last_build_report.as_dict())

run_config = lh.KernelConfig(
    cache_directory="lighthit-cache",
    cache_policy="require",
)
kernel = lh.TransportKernel(medium, detector, run_config)
response = kernel.transport(track)

Repeated build calls reuse compatible tables and report that there is nothing to do. Pass force=True only for an intentional rebuild.

For many independent Cherenkov events on the same medium and OM spectral response, an optional second build folds the wavelength quadrature into two directional kernels (one for a beta-one straight track). This avoids preparing nine large radial splines during each event:

kernel.build_folded_directional()
fast_config = lh.KernelConfig(
    cache_directory="lighthit-cache", cache_policy="require",
    spectral_folded_cache=True,
)
fast_kernel = lh.TransportKernel(medium, detector, fast_config)
response = fast_kernel.transport(track)

Use the same numerical configuration in both kernels. Once a compatible derived cache exists, it is selected automatically; set spectral_folded_cache=False to force the original nine-wavelength reference. Without a derived cache the original path remains available. The shared radial interpolation is a numerical approximation, so compare representative events against the reference before analysis. On the BGVD 321-frequency profile the three prepared arrays occupy about 10 GiB on disk.

Detector coordinates can be inspected without detector-specific assumptions:

geometry = lh.describe_geometry(detector)
print(geometry.centroid_m)
print(geometry.bounds_min_m, geometry.bounds_max_m)

For a point laser, cover every source-to-module distance when preparing its cache. This matters for a bright source: modules that could be safely skipped at low photon count may contribute above threshold_pe at high photon count.

laser_range = lh.point_source_radial_range(detector, flash.position_m)
laser_config = lh.KernelConfig(
    radial_range_m=laser_range,
    k_panel_per_m=min(0.04, 2 * np.pi / laser_range[1]),
    cache_directory="laser-cache",
)
laser_kernel = lh.build(medium, detector, flash, config=laser_config)

The returned range guarantees geometric coverage. The spatial panel rule resolves oscillations at the farthest OM better than the default panel; check convergence in k_panel_per_m, radial_nodes and the frequency grid for the medium and geometry being analysed. For several laser positions, choose one shared radial range that covers the signals of interest; the cache contains a radial transport kernel, not a source-position-specific result.

Important inputs are explicit:

  • SpectralMedium contains absorption and scattering coefficients in m⁻¹, phase and group refractive indices, and the Henyey–Greenstein parameter g.
  • DetectorArray contains OM positions, orientations, effective areas, angular acceptance and wavelength-dependent detection efficiency.
  • A detector orientation points from the OM toward a head-on source; angular_acceptance(+1) is the head-on response.
  • spectral_efficiency should include all wavelength-dependent detection factors required by the application, such as quantum efficiency and optical transmission.

Reading the response

kernel.transport(...) returns a TransportResponse.

Attribute Meaning
charge_pe Integrated expected photoelectrons, shape (OM,)
bins_pe Expected photoelectrons per relative time bin, shape (OM, bin)
charge_components_pe Integrated contributions [ballistic, one, two-or-more]
components_pe The same three components per time bin
active OMs for which the full time spectrum was evaluated
relative_time_edges_ns Bin edges relative to each OM's time origin
time_origin_ns One absolute time origin per OM
metadata Method, backend and numerical diagnostics

Absolute bin edges for every module are

absolute_edges_ns = (
    response.time_origin_ns[:, None]
    + response.relative_time_edges_ns[None, :]
)

The integrated charge is evaluated independently at zero frequency. A finite time window and a finite frequency grid mean that bins_pe.sum(axis=1) need not equal charge_pe exactly. Signed ringing is reported rather than silently clipped or renormalised.

Responses can be stored as compressed NumPy files:

response.save("response.npz")

Interactive viewer

Install the optional viewer dependency:

python -m pip install 'lighthit[viewer]'

Turn a response into a standalone interactive HTML file:

payload = lh.viewer_payload(
    response,
    source=source,
    event_id="flash-450nm",
    label="450 nm calibration flash",
)

viewer_path = lh.write_event_viewer(payload, "lighthit-viewer.html")
print(viewer_path)

Compatible event payloads can share one viewer:

combined = lh.merge_event_viewers(laser_payload, track_payload, shower_payload)
lh.write_event_viewer(combined, "multi-event-viewer.html")

Open lighthit-viewer.html in a web browser. It contains the detector geometry, integrated charge by scattering order, a selectable per-OM time histogram and a time animation. A portable lighthit-viewer.json companion is written beside the HTML file. The Display selector offers raw bins, a 3 ns Gaussian view, or both overlaid on the OM waveform. Each scattering order and 0+1 has its own raw and smoothed curve. The Gaussian view redistributes already binned integrals under a uniform-within-bin assumption; it is for display and does not replace an exact detector timing response or modify saved raw data.

For tracks and showers the animation also shows the source itself: on the default detector clock a gold head runs along the muon track, or the shower spindle grows along its axis, in step with the time slider, while each OM shows its bin at time_origin + relative time. The Per-OM origin clock keeps the older view in which every OM is shown at its own time zero. The source timing is an optional timing block of an axis source (lighthit.viewer.source_timing builds it from light elements); payloads without it stay valid and static.

Installed synthetic demonstration

The wheel includes one public-data-only Baikal-like example. Its geometry and OM response are illustrative and are not a Baikal calibration:

lighthit-demo geometry
lighthit-demo cache --profile quick --cache ./demo-cache --sources all
lighthit-demo run --profile quick --cache ./demo-cache \
  --output ./demo-output --sources all

The repository, but not the wheel, contains detector-specific laser, track, multi-track and G4-shower examples with explicit source positions and directions.

Scope

The current model assumes a homogeneous, unbounded medium with elastic Henyey–Greenstein scattering. It does not include boundaries, layered media, structural shadowing, polarisation, detector electronics, trigger or noise.

License

LightHit is distributed under the BSD 3-Clause License.

Metadata

Release files for lighthit 0.2.0a13

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for lighthit 0.2.0a13
File Size Uploaded Provenance Actions
lighthit-0.2.0a13.tar.gz 203.6 kB Without provenance Details for lighthit-0.2.0a13.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for lighthit 0.2.0a13
File Interpreter ABI Platform Actions
lighthit-0.2.0a13-py3-none-any.whl Python 3 none any Details for lighthit-0.2.0a13-py3-none-any.whl

Total release size: 417.5 kB

Release files / lighthit-0.2.0a13.tar.gz

Download URL lighthit-0.2.0a13.tar.gz
Size 203.6 kB
Tags Source
SHA-256 checksum
How to use checksums
882e3de12600434559ab7a1a5c2574f283588250465b68f35e59f0b270ed1253
BLAKE2b-256 checksum
How to use checksums
a7dd9aab858256023877395eac0d59ca172e50a9d2f446a37e507014c6eef372
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.5

Release files / lighthit-0.2.0a13-py3-none-any.whl

Download URL lighthit-0.2.0a13-py3-none-any.whl
Size 213.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c926794a26e618da75e6952a927441bf1c0b7e4565c2c60732320525fdbd10e5
BLAKE2b-256 checksum
How to use checksums
b2d743aee78683889497b5840a40eb88398847e866d314eccd765331066024e9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.5

Security for lighthit 0.2.0a13

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page