Doc: https://arnaudon.github.io/netSALT/
This code simulates network lasers with the SALT approximation and quantum graphs.
This is the code accompanying the following publications:
Sensitivity and spectral control of network lasers
Dhruv Saxena, Alexis Arnaudon, Oscar Cipolato, Michele Gaio, Alain Quentel, Sophia Yaliraki, Dario Pisignano, Andrea Camposeo, Mauricio Barahona, Riccardo Sapienza
available on arxiv: https://arxiv.org/abs/2203.16974 and Nat. Comm.: https://www.nature.com/articles/s41467-022-34073-3
We recommend uv for managing the environment. From a checkout:
uv venv
uv pip install -e .or from pypi:
uv pip install netsaltPlain pip still works if you prefer:
pip install -e .
# or
pip install netsaltThe code is accessible directly, or via a plain-Python pipeline
(netsalt.pipeline) driven by YAML config files, see examples/.
The object API. QuantumGraph is a thin networkx.Graph subclass that
carries the quantum-graph state and exposes the build/solve helpers as methods,
so you set up physics and build matrices on one object instead of threading a
bare graph through free functions:
import networkx as nx
import numpy as np
from netsalt import QuantumGraph
from netsalt.physics import dispersion_relation_dielectric
g = nx.path_graph(20)
positions = np.array([[float(i), 0.0] for i in range(20)])
qg = QuantumGraph.from_networkx(
g,
params={
"open_model": "open",
"c": 1.0,
"dielectric_params": {
"method": "uniform",
"inner_value": 4.0,
"loss": 0.0,
"outer_value": 1.0,
},
},
positions=positions,
)
qg.set_dispersion_relation(dispersion_relation_dielectric)
qg.set_dielectric_constant()
L = qg.laplacian(2.0 + 0.0j) # quantum Laplacian L(k)
quality = qg.mode_quality([2.0, 0.0]) # how close [Re(k), -Im(k)] is to a mode
pumped = qg.with_pump(0.7) # a copy at pump D0 = 0.7, original untouchedEvery method delegates to the matching module-level function (construct_laplacian,
mode_quality, …), so the procedural API keeps working unchanged and the class is
purely opt-in. load_graph("graph.json", as_class=True) returns a QuantumGraph.
The pipeline. For a full passive/lasing/controllability run, drive the pipeline from a YAML config:
python -m netsalt lasing config.yaml # passive | lasing | controllabilityEach step caches to disk and is skipped when its output exists (pass --force
to recompute). See examples/ for ready-to-run configs and MIGRATION.md if
you are coming from the old Luigi workflow.
Modes inside a complex-k rectangle can be located three different
ways. The pipeline picks one via
params["mode_search_method"] (default "contour"); direct callers
can pick by importing the function they want.
| entry point | when to use | wired into the pipeline? |
|---|---|---|
find_modes_contour |
the production search; takes an n_k × n_alpha cell layout |
yes — used by find_passive_modes(method="contour"), the pipeline default |
find_modes_contour_adaptive |
parameter discovery — you don't know the mode count yet | no — direct API only |
tune_contour_parameters |
batches of similar-density randomized graphs | no — direct API only |
find_modes_contour is the recommended production entry point.
find_modes_contour_adaptive is intended for exploration: its
saturation-driven recursion can drop a small fraction of modes near
cell edges (1–5% on 300–500-mode workloads in the benchmarks). For
runs where every mode matters, use find_modes_contour with an
explicit n_k, picked either by hand from the rule
n_k ≥ ⌈expected_modes / (0.65 · probe_dim)⌉ or by
tune_contour_parameters.
config.yaml has mode_search_method = contour, which routes
step_find_passive_modes (netsalt/pipeline.py) through
netsalt.find_passive_modes(method="contour") →
find_modes_contour. Sensible defaults are picked from
graph.graph["params"]:
n_k≈round(k_max - k_min)(one cell per unitk— fine for the small ranges netsalt typically scans)n_alpha = 2n_quad = 80probe_dimdefaults tomin(40, n_nodes)
The legacy mode_search_method = grid path is still available;
it runs scan_frequencies + peak_local_max + per-mode
refine_mode_root and ignores the contour helpers.
When you don't know how many modes are in your rectangle, the
adaptive variant subdivides on demand: each cell is bisected when
it returns either ≥ saturation_factor · probe_dim modes (genuine
saturation) or 0 modes at low depth (over-capacity collapse,
empirically the dominant failure mode at high mode count). It does
not need a manual n_k.
from netsalt import find_modes_contour_adaptive
modes = find_modes_contour_adaptive(
graph,
bounds=(k_min, k_max, alpha_min, alpha_max),
n_quad=200,
probe_dim=40, # or None for min(40, n_nodes)
max_depth=6, # 8 for >300 modes
saturation_factor=0.7,
rng=np.random.default_rng(0),
)Caveat — coverage is not guaranteed. On the 500-mode stress
benchmark, adaptive recovers 472–502 modes depending on probe_dim
and max_depth; the saturation heuristic plus boundary dedup at
deep recursion can drop modes near cell edges. If 100% coverage
matters, use find_modes_contour with an explicit n_k instead —
preferably one that tune_contour_parameters picked for you.
The adaptive search is not currently wired into
step_find_passive_modes; if you want it inside a pipeline run, call
it directly and write the resulting modes to the passive-modes output.
If you have many graphs of the same topology and density (for
instance a sweep over RNG seeds), tune_contour_parameters runs
the adaptive search once on a representative graph, then returns
parameter values sized for the (more reliable) non-adaptive path:
from netsalt import tune_contour_parameters, find_modes_contour
# Step 1: tune once on a representative graph.
params, info = tune_contour_parameters(
representative_graph,
bounds=bounds,
probe_dim=40,
n_quad=200,
)
# info["discovered_modes"] = 150
# params = {"n_k": 9, "n_alpha": 1, "n_quad": 200, "probe_dim": 40}
# Step 2: batch-process similar graphs — full coverage on each.
for seed in seeds:
g = make_graph(seed)
modes = find_modes_contour(g, bounds=bounds, **params)tune_contour_parameters picks n_k so each cell has at most
0.65 · probe_dim / safety_factor modes (default
safety_factor=1.5 leaves 50% headroom for instance variation).
The safety_factor matters: even if adaptive's discovery
under-counts a few modes, the non-adaptive call sized with the
recommended n_k recovers them all (the stress workload in
benchmark/bench_contour.py is a worked example at ~300 modes).
- Default pipeline run (
mode_search_method = contour) is fine for most workloads — it picksn_k ≈ k_rangeand the netsalt scan rectangles are small enough that this is well-sized. - Use
tune_contour_parameterswhen running the same search over many randomized similar graphs, or when you want to lock in fast and reliable non-adaptive parameters for a production run. - Use
find_modes_contour_adaptivedirectly only as a one-shot exploration tool on an unfamiliar graph; do not rely on it for production where every mode matters.
See benchmark/README.md for the empirical study behind
n_k-vs-probe_dim sizing.
refine_mode exposes two algorithms: "root" (default,
MINPACK hybr) and "brownian" (legacy ratchet). Pick via
params["refine_method"]. root is 4–5× faster than brownian
across every graph size benchmarked; brownian stays available for
reproducibility with pre-2026 results.