Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9cecd5f
vlasov broadband forward
ManharGupta25 Jul 30, 2026
9b2c7bd
range support for random and working datamodel
ManharGupta25 Jul 31, 2026
d9002d7
Merge branch 'main' of https://github.com/ergodicio/adept into mg/vla…
ManharGupta25 Aug 9, 2026
fd697c5
Merge branch 'main' of https://github.com/ergodicio/adept into mg/vla…
ManharGupta25 Aug 11, 2026
738b45f
Merge branch 'main' of https://github.com/ergodicio/adept into mg/vla…
ManharGupta25 Aug 11, 2026
f251232
broadband driver spectrum plot
ManharGupta25 Aug 11, 2026
71f70e5
Shift to alpha_1D
ManharGupta25 Aug 12, 2026
fcc6125
pic1d shift to alpha_1D
ManharGupta25 Aug 12, 2026
e21e5f6
added missing defauly phase argument to absorbing_wave failing test
ManharGupta25 Aug 12, 2026
39e849c
Small changes for passing linting checks
ManharGupta25 Aug 13, 2026
3088199
Merge branch 'main' of https://github.com/ergodicio/adept into mg/vla…
ManharGupta25 Aug 20, 2026
4c45d6d
Backward-pass compatible broadband driver
ManharGupta25 Aug 21, 2026
d12dd5f
Merge branch 'main' of https://github.com/ergodicio/adept into mg/vla…
ManharGupta25 Aug 21, 2026
ea1a394
Initialization level changes for backward pass
ManharGupta25 Aug 21, 2026
88de80e
Updates for pre-commit and CI tests
ManharGupta25 Aug 22, 2026
64b330f
Merge branch 'main' of https://github.com/ergodicio/adept into mg/vla…
ManharGupta25 Aug 22, 2026
eb9447a
Merge branch 'main' of https://github.com/ergodicio/adept into mg/vla…
ManharGupta25 Aug 26, 2026
95a5515
Merge branch 'main' of https://github.com/ergodicio/adept into mg/vla…
ManharGupta25 Sep 9, 2026
e2a06d7
Fixed blocking issues and added tests
ManharGupta25 Sep 9, 2026
d18d244
Preserving physical laser intensity
ManharGupta25 Sep 9, 2026
ba9dea9
Import failures and minor adjustments
ManharGupta25 Sep 13, 2026
f43e172
Merging main branch into SG/vlasov-broadband branch
ManharGupta25 Oct 19, 2026
193b7ac
Test changes
ManharGupta25 Oct 19, 2026
cd58871
Merge branch 'main' of https://github.com/ergodicio/adept into mg/vla…
ManharGupta25 Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions adept/_pic1d/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ def _inverse_cdf_supergaussian(
sampling that exactly reproduces the requested moments in expectation.
"""
v_thermal = np.sqrt(T0 / mass)
alpha = np.sqrt(3.0 * gamma(3.0 / supergaussian_order) / gamma(5.0 / supergaussian_order))
# 1D width normalization: variance = T0/mass for every order m (matches the
# vlasov1d initializer; the 3D-isotropic sqrt(3*G(3/m)/G(5/m)) inflates the
# 1D variance by F(m), e.g. x1.24 at m=3)
alpha = np.sqrt(gamma(1.0 / supergaussian_order) / gamma(3.0 / supergaussian_order))

nv = max(8192, 8 * n_particles)
v_grid = np.linspace(-vmax, vmax, nv)
Expand All @@ -53,7 +56,10 @@ def _random_supergaussian(
) -> np.ndarray:
"""Random velocity sampling via rejection on a bounded supergaussian."""
v_thermal = np.sqrt(T0 / mass)
alpha = np.sqrt(3.0 * gamma(3.0 / supergaussian_order) / gamma(5.0 / supergaussian_order))
# 1D width normalization: variance = T0/mass for every order m (matches the
# vlasov1d initializer; the 3D-isotropic sqrt(3*G(3/m)/G(5/m)) inflates the
# 1D variance by F(m), e.g. x1.24 at m=3)
alpha = np.sqrt(gamma(1.0 / supergaussian_order) / gamma(3.0 / supergaussian_order))

out = np.empty(n_particles)
filled = 0
Expand Down
90 changes: 89 additions & 1 deletion adept/_vlasov1d/datamodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,80 @@ class IntensityWavelengthDriverConfig(BaseModel):
intensity: str
wavelength: str
leftgoing: bool = False
dw0: float = 0.0
phase: float = 0.0


class BroadbandIntensitiesConfig(BaseModel):
"""Per-line intensity weights of a broadband (multi-color) driver.

The weights ``w_j`` set the line *amplitudes* as
``a_j = a0 * (w0 / w_j) * sqrt(w_j / sum_k w_k)``, where ``a0`` is the monochromatic
amplitude for ``base_intensity``; hence ``sum_j (w_j a_j)^2 = (w0 a0)^2`` (same
time-averaged physical power as a single line at ``base_intensity``) and the
per-line intensity is ``I_j = base_intensity * w_j / sum_k w_k``.
"""

base_intensity: str # e.g. "2.378e+14 W/cm^2"; the total (monochromatic-equivalent) intensity
init: Literal["uniform", "random"] = "uniform"
seed: int | None = None # required for init: random
range: tuple[float, float] = (0.0, 2.0) # uniform draw bounds for init: random
Comment thread
ManharGupta25 marked this conversation as resolved.

@model_validator(mode="after")
def check_seed(self) -> "BroadbandIntensitiesConfig":
"""``init: random`` must be reproducible, so it needs a seed."""
if self.init == "random" and self.seed is None:
raise ValueError("intensities.init == 'random' requires intensities.seed")
return self

@model_validator(mode="after")
def check_range(self) -> "BroadbandIntensitiesConfig":
"""Weights feed ``a_j = a0 * sqrt(w_j / sum(w))``: the draw range must be
ordered and non-negative with positive support, or amplitudes go NaN."""
lo, hi = self.range
if not (0.0 <= lo < hi):
raise ValueError(
f"intensities.range must satisfy 0 <= lo < hi (got ({lo}, {hi})): weights are "
"square-rooted after normalization, so negative or zero-support ranges produce NaN amplitudes"
)
return self


class BroadbandPhasesConfig(BaseModel):
"""Per-line spectral phases of a broadband (multi-color) driver."""

init: Literal["uniform", "random"] = "random"
seed: int | None = None # required for init: random
range: tuple[float, float] = (0.0, 2.0 * 3.141592653589793) # uniform draw bounds, radians
base_phase: float = 0.0 # the common phase for init: uniform

@model_validator(mode="after")
def check_seed(self) -> "BroadbandPhasesConfig":
"""``init: random`` must be reproducible, so it needs a seed."""
if self.init == "random" and self.seed is None:
raise ValueError("phases.init == 'random' requires phases.seed")
return self


class BroadbandConfig(BaseModel):
"""Broadband (multi-color) laser driver: a comb of ``num_colors`` lines.

Line frequencies are ``w_j = w0 * (1 + d_j)`` with ``d_j`` spaced uniformly on
``[-delta_omega, +delta_omega]`` (``num_colors: 1`` puts the single line exactly at
``w0``). **``delta_omega`` is the HALF-width of the comb as a fraction of ``w0``;
the full bandwidth is ``2 * delta_omega``** -- a "0.25% bandwidth" run is
``delta_omega: 0.00125``. All lines share the carrier wavenumber ``k0`` of
``wavelength`` (an off-center line is not launched on its own dispersion branch),
which is accurate for a localized antenna (``source_type: point`` / a narrow
spatial envelope) and not for a source extended across the box.
"""

num_colors: int = Field(ge=1)
delta_omega: float = Field(ge=0.0, lt=1.0) # half-width, fraction of w0; < 1 keeps every line frequency positive

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Reject zero-width multi-line combs, or normalize their coherent sum. delta_omega: 0 is accepted with num_colors > 1, so all lines have exactly the same frequency. The new frequency-weighted normalization correctly fixes distinct-line powers, but sum_j (omega_j a_j)^2 is not the total power when frequencies coincide: cross terms never average away. On this head I built num_colors: 4, delta_omega: 0, uniform intensity weights, and phases: {init: uniform, base_phase: 0}. At identical base_intensity, the evaluated point source is exactly 2 times the monochromatic source, with squared amplitude 4 times the control; this persists in time, rather than being a finite-bandwidth recurrence peak. Thus a zero-bandwidth control silently changes pump strength. The smallest fix is a model-level check rejecting num_colors > 1 and delta_omega == 0 and directing users to num_colors: 1. Alternatively combine/normalize equal-frequency complex amplitudes, with an explicit policy for cancellation. Add a regression using the evaluated coherent source, not only the diagonal per-line power sum.

wavelength: str
intensities: BroadbandIntensitiesConfig
phases: BroadbandPhasesConfig
leftgoing: bool = False


class AKWDriverConfig(BaseModel):
Expand All @@ -136,6 +210,7 @@ class AKWDriverConfig(BaseModel):
k0: float | None = None
w0: float | None = None
dw0: float
phase: float = 0.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Honor the newly accepted scalar phase for Ex, or reject it there. AKWDriverConfig now explicitly accepts phase, the same schema is used under drivers.ex, and EMDriver.from_config retains it. However LongitudinalElectricFieldDriver._single_driver_field (solvers/pushers/field.py:22–27) still evaluates sin(k0*x - (w0 + dw0)*t) without driver.phase. I reproduced this through EMDriverSetConfig and EMDriverSet.from_config: changing a valid Ex deck from phase: 0 to phase: pi/2 produces exactly identical fields, rather than the requested phase shift. This can silently invalidate relative-phase forcing experiments. Please include + driver.phase in the longitudinal evaluator, or reject nonzero Ex phase if the new option is deliberately Ey-only; add a test against the analytic phase-shifted source. The same issue affects the new phase field in IntensityWavelengthDriverConfig.


@model_validator(mode="after")
def check_w_or_k(self) -> "AKWDriverConfig":
Expand All @@ -148,7 +223,7 @@ def check_w_or_k(self) -> "AKWDriverConfig":
class EMDriverConfig(BaseModel):
"""One electromagnetic driver with parameters, envelope, and source geometry."""

params: IntensityWavelengthDriverConfig | AKWDriverConfig
params: IntensityWavelengthDriverConfig | AKWDriverConfig | BroadbandConfig
envelope: SpaceTimeEnvelopeConfig
source_type: Literal["extended", "point"] = "extended"

Expand Down Expand Up @@ -196,6 +271,19 @@ class EMDriverSetConfig(BaseModel):
ey: dict[str, EMDriverConfig]
ex_stochastic: StochasticDriverConfig | None = None

@field_validator("ex")
@classmethod
def no_broadband_ex(cls, ex: dict[str, EMDriverConfig]) -> dict[str, EMDriverConfig]:
"""The broadband (multi-color) driver is an ey feature: the longitudinal
pusher evaluates scalar per-driver parameters and cannot evaluate a comb,
so reject it at validation instead of failing at runtime."""
for key, drv in ex.items():
if isinstance(drv.params, BroadbandConfig):
raise ValueError(
f"drivers.ex['{key}']: broadband (multi-color) params are only supported for ey drivers"
)
return ex


class HouLiFilterConfig(BaseModel):
"""Configuration for optional Hou-Li spectral filtering (configuration space only)."""
Expand Down
210 changes: 204 additions & 6 deletions adept/_vlasov1d/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,33 @@

# Copyright (c) Ergodic LLC 2023
# research@ergodic.io
import math
import os
from time import time

import equinox as eqx
import numpy as np
import xarray
from diffrax import Solution
from jax import numpy as jnp
from jax import tree_util as jtu
from scipy.special import gamma

from adept._vlasov1d.simulation import SubspeciesDistributionSpec, Vlasov1DSimulation
from adept._vlasov1d.storage import store_f, store_fields
from adept.normalization import PlasmaNormalization
from adept.normalization import UREG, PlasmaNormalization, normalize

# gamma_da = xarray.open_dataarray(os.path.join(os.path.dirname(__file__), "gamma_func_for_sg.nc"))
# m_ax = gamma_da.coords["m"].data
# g_3_m = np.squeeze(gamma_da.loc[{"gamma": "3/m"}].data)
# g_5_m = np.squeeze(gamma_da.loc[{"gamma": "5/m"}].data)


def gamma_1_over_m(m):
"""Evaluate Gamma(1 / m) for super-Gaussian normalization."""
return gamma(1.0 / m)


def gamma_3_over_m(m):
"""Evaluate Gamma(3 / m) for super-Gaussian normalization."""
return gamma(3.0 / m) # np.interp(m, m_ax, g_3_m)
Expand Down Expand Up @@ -69,11 +77,16 @@ def _initialize_supergaussian_distribution_(
# Thermal velocity: v_t = sqrt(T/m)
v_thermal = np.sqrt(T0 / mass)

# Alpha factor for supergaussian normalization. This fixes the moment ratio
# <v^4>/<v^2> = 3*T0/mass for every order m; the realized *variance* equals
# T0/mass only at m=2 (Maxwellian). For m>2 flat-tops the second-moment
# temperature exceeds T0 (x1.24 at m=3, x1.37 at m=4). See docs config.md.
alpha = np.sqrt(3.0 * gamma_3_over_m(supergaussian_order) / gamma_5_over_m(supergaussian_order))
# 1D super-Gaussian width normalization: alpha = sqrt(Gamma(1/m)/Gamma(3/m))
# makes the realized VARIANCE equal T0/mass for every order m, i.e. a species
# labeled T0 is at temperature T0 for any m (alpha = sqrt(2) at m=2, unchanged).
# This is also the convention the Krook target (variance T0/mass) and the
# SuperGaussianDougherty temperature relation D = beta^(-2/m)*G(3/m)/G(1/m)
# already use. The previous alpha = sqrt(3*G(3/m)/G(5/m)) is the 3D-isotropic
# (Matte/DLM) normalization -- it fixes <v^2>_3D = 3*T0/mass -- and on a 1D
# axis it inflates the variance by F(m) = 3*G(3/m)^2/(G(5/m)*G(1/m))
# (x1.24 at m=3, x1.37 at m=4). See docs config.md.
alpha = np.sqrt(gamma_1_over_m(supergaussian_order) / gamma_3_over_m(supergaussian_order))

single_dist = -(np.power(np.abs((vax[None, :] - v0) / (alpha * v_thermal)), supergaussian_order))

Expand Down Expand Up @@ -158,6 +171,184 @@ def _initialize_total_distribution_(cfg, simulation: Vlasov1DSimulation):
return species_distributions


def get_akw_from_intensity_wavelength(intensity, wavelength, leftgoing, norm: PlasmaNormalization | None = None):
"""getting amplitude (a), wave number (k) and angular frequency (w)
from intensity and wavelength (passed in as args to this function) defined in
'intensity_wavelength' type of configs"""

intensity = UREG.Quantity(intensity).to("W/m^2")
wavelength = UREG.Quantity(wavelength).to("nm")

e = UREG.e
m_e = UREG.m_e
eps0 = UREG.epsilon_0
c = UREG.c

# Standard a0 = eE0/(m_e c w0) — identical to HermiteSRS1D formula
a0_std = ((e * wavelength / (m_e * math.pi)) * (intensity / (2 * eps0 * c**5)) ** 0.5).to("").magnitude
# Vlasov normalization: a0_vlasov = a0_std / β (β = v0/c)
a0 = a0_std * norm.speed_of_light_norm()

# k0 in Debye-length units: k0_vlasov = k_phys x v0/wp0
k0_phys = (2 * math.pi / wavelength).to("1/m")
k_sign = -1.0 if leftgoing else 1.0
k0 = k_sign * float((k0_phys * norm.L0).to("").magnitude)

# w0 normalized to wp0 (same normalization as Hermite)
w0_phys = (2 * math.pi * c / wavelength).to("1/s")
w0 = float((w0_phys * norm.tau).to("").magnitude)

return a0, k0, w0


def plot_driver_spectra(cfg: dict, td: str, args: dict):
"""Per-line intensity and phase vs frequency offset, for each multi-line driver.

Reads the LIVE driver objects out of `args["drivers"]` rather than re-deriving
the line set from the config's init/seed. That matters twice over: the plot cannot
drift if BroadbandDriver's construction changes, and it shows optimized line sets
from a backward pass (which never appear in the config) automatically. A
`BroadbandDriver` contributes its per-line arrays (`amplitudes`/`delta_omega`/
`phases`); plain `EMDriver`s contribute their scalars, so a hand-built list of
mono drivers still plots.

Per-line intensity needs no normalization constant. Line j's physical field has
amplitude w_j * A_j, so

I_j / I_base = (w_j A_j)^2 / sum_k (w_k A_k)^2

exactly, independent of a0 and of the plasma normalization. `base_intensity` is
read from the config only to put an absolute scale on the axis.

Panels: I_j (linear), log10 I_j (dynamic range -- the informative one once an
optimizer produces structure, since I ~ A^2 doubles the span), and phi_j.
Single-line (monochromatic) drivers are skipped.
"""
from matplotlib import pyplot as plt

drivers = (args or {}).get("drivers")
if drivers is None:
return
out_dir = os.path.join(td, "plots", "drivers")

for field in ("ex", "ey"):
dlist = getattr(drivers, field, None)
if not dlist:
continue

amp_list, dw_list, phase_list = [], [], []
for d in dlist:
if hasattr(d, "amplitudes"): # BroadbandDriver: (N,) array leaves
amp_list.extend(np.asarray(d.amplitudes, dtype=float))
dw_list.extend(np.asarray(d.delta_omega, dtype=float) / float(d.w0))
phase_list.extend(np.asarray(d.phases, dtype=float))
else: # plain EMDriver: scalar leaves
amp_list.append(float(d.a0))
dw_list.append(float(d.dw0) / float(d.w0))
phase_list.append(float(d.phase))
if len(amp_list) < 2:
continue # monochromatic -> no spectrum to show

amp = np.asarray(amp_list)
dw = np.asarray(dw_list) # dw_j/w0
phases = np.asarray(phase_list)

# physical per-line power goes as (w_j * a_j)^2, i.e. (1 + dw_j/w0)^2 * a_j^2
power = ((1.0 + dw) * amp) ** 2
frac = power / power.sum() if power.sum() > 0 else power
# absolute scale: base_intensity of the broadband driver in this field (whichever
# key it was given under -- a driver keyed '1' must not lose the axis scale)
base = None
for dcfg in (cfg.get("drivers", {}).get(field, {}) or {}).values():
ints = ((dcfg or {}).get("params", {}) or {}).get("intensities")
if isinstance(ints, dict) and ints.get("base_intensity") is not None:
base = ints["base_intensity"]
break

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nonblocking diagnostic follow-up — don't pool multiple drivers under the first intensity. All lines from the field are concatenated and normalized together, but their absolute scale is taken from only the first broadband entry. I exercised two four-line combs at the same carrier with disjoint frequency sets (delta_omega 0.0025 and 0.00125), each at 2.378e14 W/cm^2: matplotlib received eight intensities summing to 2.378e14, rather than 4.756e14 W/cm^2. The title also says 2 lines because it uses len(dlist) instead of the flattened line count. Plot each configured driver separately or retain per-driver normalization while combining; if carriers differ, retain actual omega_j too, since (1 + dw/w0)^2 * a^2 drops different factors of w0^2. This does not affect the single-comb forward solve.


I_j, unit = frac, ""
if isinstance(base, str) and base.split(): # "2.378e+14 W/cm^2"
val, _, u = base.partition(" ")
try:
I_j, unit = frac * float(val), u.strip()
except ValueError:
pass
elif isinstance(base, (int, float)):
I_j = frac * float(base)

order = np.argsort(dw)
dw, I_j, phases = dw[order], I_j[order], phases[order]

# constrained_layout sizes the suptitle band to the actual text; do NOT pair
# it with tight_layout(rect=...) + suptitle(y=...), which reserve a fixed band
# and leave a gap when the title is shorter than the reservation.
fig, axes = plt.subplots(3, 1, figsize=(7.2, 8.6), sharex=True, constrained_layout=True)
bw_pct = (dw.max() - dw.min()) * 100.0
spacing = float(np.diff(np.sort(dw)).mean()) if len(dw) > 1 else 0.0
run_name = ((cfg.get("mlflow") or {}).get("run")) or ""

title = f"{field} driver — broadband line spectrum"
if run_name:
title += f"\n{run_name}"
title += (
f"\n{len(dlist)} lines | full width $\\Delta\\omega/\\omega_0$ = "
f"{bw_pct:.3g}% | spacing $\\delta\\omega/\\omega_0$ = {spacing:.3g}"
)
if base is not None:
title += f"\n$I_{{base}}$ = {base}"
if unit:
title += f" | $I_j$ = {I_j.mean():.4g} {unit} mean per line"
fig.suptitle(title, fontsize=9.5, linespacing=1.4)

axes[0].plot(dw, I_j, "o", ms=4, color="#1f77b4")
axes[0].set_ylabel("line intensity $I_j$" + (f" [{unit}]" if unit else " [$I_j/I_{base}$]"))
axes[0].set_ylim(bottom=0)
axes[0].annotate(
rf"$\Sigma_j I_j$ = {I_j.sum():.4g}", xy=(0.02, 0.06), xycoords="axes fraction", fontsize=8, color="0.35"
)

pos = I_j > 0
if pos.any():
axes[1].plot(dw[pos], np.log10(I_j[pos]), "o", ms=4, color="#d62728")
lo_, hi_ = np.log10(I_j[pos]).min(), np.log10(I_j[pos]).max()
if hi_ - lo_ < 0.1:
axes[1].set_ylim(lo_ - 0.5, hi_ + 0.5)
axes[1].annotate(
f"dynamic range: {10 ** (hi_ - lo_):.3g}x",
xy=(0.02, 0.88),
xycoords="axes fraction",
fontsize=8,
color="0.35",
)
if (~pos).any(): # an optimizer can drive lines to zero; don't hide them
floor = np.log10(I_j[pos]).min() if pos.any() else 0.0
axes[1].plot(dw[~pos], np.full(int((~pos).sum()), floor), "x", ms=6, color="0.5")
axes[1].annotate(
f"{int((~pos).sum())} line(s) at I=0 (x)",
xy=(0.02, 0.06),
xycoords="axes fraction",
fontsize=8,
color="0.4",
)
axes[1].set_ylabel(r"$\log_{10} I_j$")

axes[2].plot(dw, phases, "o", ms=4, color="#2ca02c")
axes[2].set_ylabel(r"phase $\phi_j$ [rad]")
axes[2].set_xlabel(r"$\delta\omega_j/\omega_0$")
axes[2].set_ylim(-0.25, 2 * np.pi + 0.25)
axes[2].set_yticks([0, np.pi / 2, np.pi, 3 * np.pi / 2, 2 * np.pi])
axes[2].set_yticklabels(["0", r"$\pi/2$", r"$\pi$", r"$3\pi/2$", r"$2\pi$"])

for ax in axes:
ax.grid(alpha=0.3)
ax.axvline(0.0, color="0.6", lw=0.8, ls="--")

os.makedirs(out_dir, exist_ok=True)
# no tight_layout here — constrained_layout (set on the figure) already sized
# the title band; calling both would re-reserve a fixed strip and reopen the gap
fig.savefig(os.path.join(out_dir, f"{field}-lines.png"), bbox_inches="tight", dpi=150)
plt.close(fig)


def post_process(result: Solution, cfg: dict, td: str, args: dict):
"""Write binary output and diagnostic plots from a completed Vlasov-1D solve."""
from matplotlib import pyplot as plt
Expand All @@ -166,6 +357,13 @@ def post_process(result: Solution, cfg: dict, td: str, args: dict):

t0 = time()

# Driver line spectra (multi-line drivers only). Guarded: a diagnostics failure
# here must never cost a completed solve its binary output and field/dist plots.
try:
plot_driver_spectra(cfg, td, args)
except Exception as exc:
print(f"[post_process] driver spectrum plot skipped: {type(exc).__name__}: {exc}", flush=True)

# Get species names for directory creation
species_names = list(cfg["grid"]["species_grids"].keys())

Expand Down
Loading
Loading