Simulation#

Circuit-level model hierarchy#

SAX simulations expand unmodeled assemblies until they reach cells with declared models. Model declarations therefore define hierarchy boundaries instead of relying on a fixed depth.

Coupled resonators declare their own models so the coupling is preserved. Chip assemblies remain unmodeled and expand to these boundaries and other modeled primitives.

For new components, attach the compact model through schematic_function and keep its port_order consistent with the layout ports.

Simulation utilities: shared layout preparation and solver inputs.

prepare_metal_layout() folds a QPDK mask into positive metal geometry for a field solver. This package also holds QPDK-specific inputs for gplugins.comsol.

Ansys AEDT (HFSS, Q3D Extractor, Q2D): use gplugins.ansys directly. Activate QPDK first (qpdk.PDK.activate()); importing qpdk does not. Pass layer_stack=qpdk.tech.LAYER_STACK explicitly. The PDK default is a mask preview with an implicit ground plane, not a simulation stack. Also pass material_properties=qpdk.tech.material_properties to use QPDK’s material table instead of the gplugins default:

  1. Prepare a component with prepare_metal_layout()

  2. Wrap a PyAEDT Hfss, Q3d or Q2d app in gplugins.ansys.HFSS, Q3D or Q2D and import the component with import_component

  3. Configure the setup through PyAEDT, solve, and read the results through the gplugins helpers. For a CPW cross-section in Q2D, pass the width and gap from get_cpw_dimensions() to Q2D.create_2d_cpw with substrate_level="Substrate" and conductor_level="M1".

Palace workflow:

Run uv run --script datasets/plate_capacitor.py from a checkout. The script uses gsim’s electrostatic APIs and writes a self-describing dataset for lookup.

COMSOL workflow:

The COMSOL models come from gplugins.comsol; import them from there. qpdk.simulation.comsol adds only the QPDK-specific inputs:

  1. Extract metal polygons and optional feed ports with qpdk.simulation.comsol.prepare_comsol_layout()

  2. Build a model with gplugins.comsol.COMSOL, passing SILICON_RELATIVE_PERMITTIVITY and AIR_RELATIVE_PERMITTIVITY for the sheet dielectrics, then add a study and mesh it with its methods

Note

gplugins.ansys requires uv sync --extra hfss. The COMSOL module requires uv sync --extra comsol and, to build and solve models, a local COMSOL installation. It is not imported by this package, so qpdk.simulation stays importable without the extra.

The VACASK netlist classes, runner and raw-file reader need only the base install and the external vacask binary. The model-conversion helpers, vector_fit() and cpw_tline_params(), also need uv sync --extra models.

VACASK workflow:

  1. Write the circuit and its analyses in VACASK’s own netlist language and pass the blocks to a Netlist, which fills them into a Jinja2 template. Loading josephson_junction.va or squid.va uses the models shipped with qpdk.

  2. Run it with run(), which calls the external vacask binary and returns one RawPlot per analysis.

  3. Use run_record() with a work directory to retain decks, logs, raw results and input/binary/compiler hashes.

  4. Optionally embed any linear SAX model as a subcircuit: fit it with vector_fit() and emit the fit with vector_fit_subckt().

Example

>>> from ansys.aedt.core import Hfss
>>> from gplugins.ansys import HFSS
>>> from qpdk import PDK
>>> from qpdk.cells import resonator
>>> from qpdk.simulation.layout import prepare_metal_layout
>>> from qpdk.tech import LAYER_STACK, material_properties
>>> PDK.activate()
>>> comp = prepare_metal_layout(resonator(length=4000, meanders=4))
>>> hfss_app = Hfss(project="resonator_sim", solution_type="Eigenmode")
>>> hfss_sim = HFSS(
...     hfss_app, layer_stack=LAYER_STACK, material_properties=material_properties
... )
>>> hfss_sim.import_component(comp)

References

FEM#

Volumetric-FEM helpers for qpdk layouts.

Converts the PDK’s subtractive etch masks into the explicit conductor and dielectric regions that full-wave 3-D solvers (Palace through gsim, Meep, …) expect, and provides the matching layer stacks. Install gsim with the palace dependency group on Python 3.12. Functions that need it import it lazily.

qpdk.simulation.fem.flip_chip_stack(substrate_thickness=500.0, bump_thickness=10.0)[source]#

Return a gsim layer stack for a face-to-face qpdk flip-chip pair.

The bottom chip carries M1 with the substrate below it; the top chip is flipped so its M2 faces down across the indium-bump gap, with its substrate above. Both metals become perfect electric conductors and the bumps conductive vias; the substrate uses the qpdk microwave silicon, so FEM and analytical models describe the same chips.

Parameters:
  • substrate_thickness (float) – Silicon thickness of each chip in μm.

  • bump_thickness (float) – Gap height between the chips (bump height) in μm.

Returns:

A gsim.common.stack.LayerStack (requires the palace dependency group on Python 3.12).

Return type:

Any

qpdk.simulation.fem.single_chip_stack(substrate_thickness=500.0, vacuum_thickness=500.0)[source]#

Return a gsim layer stack for a single qpdk silicon chip.

The substrate uses the microwave silicon from the qpdk material properties (\(\varepsilon_r = 11.45\), \(\tan\delta = 2.7 \times 10^{-6}\)), so FEM and analytical CPW models describe the same chip. The zero-thickness niobium conductor becomes a perfect electric conductor in the solver; its material entry only names the layer.

Parameters:
  • substrate_thickness (float) – Silicon thickness in μm.

  • vacuum_thickness (float) – Air height above the chip in μm.

Returns:

A gsim.common.stack.LayerStack (requires the palace dependency group on Python 3.12).

Return type:

Any

qpdk.simulation.fem.to_fem_regions(component)[source]#

Convert an etch-based qpdk layout into explicit FEM regions.

The PDK draws metal subtractively: M1_ETCH marks where metal is removed and additive shapes on M1_DRAW punch through the etch mask (the conductor is SIM_AREA - (M1_ETCH - M1_DRAW)), matching get_layer_stack()’s M1 definition. The conductor, substrate and vacuum regions are copied onto the layer numbering of FEM_LAYERS, with the zero-thickness conductor later becoming a perfect-electric-conductor boundary in the solver. Junction masks are omitted because lumped ports represent them. Other fabrication layers outside M1 are rejected.

Parameters:

component (Component) – Layout with a SIM_AREA layer and simulation ports.

Returns:

Component carrying the SUBSTRATE, SUPERCONDUCTOR and VACUUM regions plus the input ports.

Raises:

ValueError – If unsupported fabrication layers are present, or the component carries no SIM_AREA shapes or the simulation area is fully etched away, either of which would silently produce an empty FEM model.

Return type:

Component

qpdk.simulation.fem.to_flip_chip_regions(component)[source]#

Convert a two-metal-layer qpdk layout into explicit flip-chip regions.

Both metal levels follow the same subtractive convention as to_fem_regions() (conductor is SIM_AREA - (ETCH - DRAW), per LAYER_STACK_FLIP_CHIP), and the indium bump layer is copied to its own region. The regions land on the layer numbering of FLIP_CHIP_FEM_LAYERS: two substrate slabs, two zero-thickness metal sheets, the vacuum gap, and the bumps. Junction masks are omitted because lumped ports represent them. Other fabrication layers are rejected.

Parameters:

component (Component) – Flip-chip layout with a SIM_AREA layer, M1/M2 metal and IND bump shapes, plus simulation ports.

Returns:

Component carrying the flip-chip regions plus the input ports.

Raises:

ValueError – If unsupported fabrication layers are present, the component carries no SIM_AREA shapes, a metal level has neither etch nor draw shapes (which the subtractive convention would turn into a solid metal plane shorting the model), is fully etched away, or there are no indium bumps to connect the two chips.

Return type:

Component

qpdk.simulation.fem.to_tsv_regions(component, chip='bottom')[source]#

Convert a double-sided qpdk layout into explicit TSV FEM regions.

The front (M1) and back (MB) metal levels both follow the subtractive convention of to_fem_regions() (conductor is SIM_AREA - (ETCH - DRAW), per LAYER_STACK_BACKSIDE), and the TSV layer is copied to its own region. Backside shapes are drawn in the front-side frame, as in tsv_transition_double_sided(), so no mirroring is applied. The regions land on the layer numbering of TSV_FEM_LAYERS. With chip="top" the flip-chip top chip is converted instead, reading M2, MB2 and TSV2 into the same regions.

Parameters:
  • component (Component) – Layout with a SIM_AREA layer, front and back metal and TSV shapes of chip, plus simulation ports.

  • chip (Literal['bottom', 'top']) – Chip to convert, "bottom" or "top", see CHIP_LAYERS.

Returns:

Component carrying the TSV regions plus the input ports.

Raises:

ValueError – If the component carries no SIM_AREA shapes, a metal level has neither etch nor draw shapes, or there are no TSVs inside the simulation area to connect the two faces.

Return type:

Component

qpdk.simulation.fem.tsv_stack(substrate_thickness=200.0, vacuum_thickness=500.0, tsv_conductivity=1000000.0)[source]#

Return a gsim layer stack for a chip with metal on both faces.

The front metal M1 sits at z = 0 on top of the silicon and the backside metal MB at z = -substrate_thickness below it; both become perfect electric conductors. The TSVs are conductive vias spanning the full substrate. Air fills vacuum_thickness above and below the chip.

The default substrate thickness follows the 200 µm TSV depth of [MYR+21].

Parameters:
  • substrate_thickness (float) – Silicon thickness (TSV depth) in μm.

  • vacuum_thickness (float) – Air height above and below the chip in μm.

  • tsv_conductivity (float) – Normal-state conductivity of the via lining in S/m. The lining is superconducting at operating temperature; any finite value large enough to make the via a good conductor works here. Without a conductivity gsim degrades the via to a 2-D PEC sheet at its base, which no longer bridges the faces.

Returns:

A gsim.common.stack.LayerStack (requires gsim from the models extra).

Return type:

Any

From etch layers to simulation regions#

The PDK and a volumetric solver describe metal in opposite ways, and every FEM workflow in qpdk starts by converting between them.

qpdk draws metal subtractively: M1_ETCH marks where metal is removed from a full sheet, and additive shapes on M1_DRAW punch back through it, so the conductor is SIM_AREA - (M1_ETCH - M1_DRAW). This matches get_layer_stack()’s M1 definition and is how the transmon pads are drawn.

A volumetric solver needs the positive form instead: explicit conductor and dielectric bodies. to_fem_regions() performs that conversion and copies conductor, substrate and vacuum onto dedicated GDS layers that the FEM layer stack maps to materials. The zero-thickness conductor sheet becomes a perfect-electric-conductor boundary.

Two consequences are worth seeing rather than reading: everything the etch mask covers turns into vacuum, and an additive shape sitting inside an etched gap survives as an isolated metal island.

(Source code)

The same rule applies per metal level for flip-chip stacks, where to_flip_chip_regions() converts both levels and copies the indium bump layer to its own region.

Batched layout optimisation and cluster execution#

The Palace optimisation notebook defines its transmon layout and loss objective. The generic runner keeps trials in flight and sends that callable to Slurm or Ray workers. gsim provides single-node local execution and text-result loading; these helpers add multi-node MPI launching, domain-energy requests and port-connectivity checks.

Add Palace domain-energy output and run meshed directories with MPI.

gsim already provides single-node run_local() and Palace text-result loading. This module covers Slurm multi-node MPI, domain-energy output, and port-connectivity checks. The latter two could move to gsim later.

qpdk.simulation.palace_run.add_domain_energy_postprocessing(sim_dir)[source]#

Ask Palace to report the field energy in every dielectric domain.

gsim writes the domain materials but leaves the postprocessing list empty, so the domain attributes are read back out of the written config and requested one by one. Palace then reports the electric and magnetic energy in each, which is what the participation ratios come from.

Parameters:

sim_dir (Path) – Directory holding the written config.json.

Returns:

The domain attribute integers, in the order Palace indexes them.

Return type:

list[int]

qpdk.simulation.palace_run.domain_loss_tangents(sim_dir)[source]#

Map each domain’s postprocessing index to its loss tangent.

Parameters:

sim_dir (Path) – Simulation directory holding the written config.json.

Returns:

Loss tangent keyed by the index Palace uses in domain-E.csv.

Return type:

dict[int, float]

qpdk.simulation.palace_run.palace_command(*, ranks, launcher_args=None)[source]#

Return the command that solves a simulation directory in place.

The native Palace launcher starts MPI with the requested ranks. Set QPDK_PALACE_SIF to an Apptainer image to run the solver from a container, which is how a compute node usually has it; otherwise palace is taken from PATH.

Parameters:
  • ranks (int) – MPI ranks to give Palace.

  • launcher_args (list[str] | None) – Site-specific MPI options, such as a Slurm hostfile.

Returns:

The argument vector, ready for subprocess.run.

Return type:

list[str]

qpdk.simulation.palace_run.solve(sim_dir, *, ranks, timeout=1800.0)[source]#

Run the solver over an already-meshed simulation directory.

Parameters:
  • sim_dir (Path) – Directory holding config.json and palace.msh.

  • ranks (int) – MPI ranks to give Palace.

  • timeout (float) – Seconds before the solve is abandoned.

Raises:

RuntimeError – If Palace exits non-zero.

Return type:

None

qpdk.simulation.palace_run.verify_port_connectivity(sim_dir)[source]#

Check that every lumped port is attached to metal in the mesh.

A port rectangle that overhangs thin conductor features makes gsim’s boolean pipeline delete them, which can leave the port with nothing to drive. Nothing downstream complains on its own: the mesh validates, Palace runs, and the modes it returns are port-local artifacts whose frequency tracks the port inductance rather than the geometry. A sweep would inherit that for every trial, so this runs once per built directory.

The check is deliberately narrow: it verifies each port shares mesh nodes with some conductor. It cannot tell which conductor, so a port that reaches the ground plane but has lost the island it was meant to drive still passes. Treat it as a floor, not a proof.

Parameters:

sim_dir (Path) – Directory holding the written config.json and palace.msh.

Returns:

Shared node counts keyed by port group name.

Raises:

ValueError – If the config has no lumped port or no conductor surfaces at all, if a port’s mesh group is missing, or if a port shares no nodes with any conductor.

Return type:

dict[str, int]

Drive an Optuna study whose trials are solved somewhere else.

A geometry sweep is a queue of independent solver runs, so the only real questions are where a trial runs and how many run at once. Those are separate concerns here: a runner knows how to start one trial and how to collect it, and run_study() keeps a fixed number of them in flight while feeding results back to the sampler.

Keeping trials in flight rather than evaluating fixed batches matters twice over. A batch can only advance when its slowest trial finishes, so a wave of twelve solves idles most of its allocation waiting for one big mesh; and the sampler learns nothing until the whole wave lands, when it could have been told about each result as it arrived.

Two runners come with the module and the difference between them is only where the solve happens:

  • SlurmRunner submits one job per trial. A small job starts on any node with room, so the sweep fills whatever the cluster has free.

  • RayRunner dispatches a Ray task per trial, for when the study already owns an allocation.

class qpdk.simulation.study.RayRunner(run_root, ranks, evaluator)[source]#

Bases: object

Run each trial as a Ray task inside an allocation the study owns.

Parameters:
run_root#

Shared directory the trials are written under.

Type:

Path

ranks#

MPI ranks to give each solve, and the CPUs each task reserves.

Type:

int

evaluator#

Callable serialized by Ray or an importable module:function name.

Type:

str | Callable[[…], dict[str, Any]]

static collect(handle)[source]#

Return a task’s row if it has finished.

Parameters:

handle (Any) – The object reference from start().

Returns:

The row, or None while the task is still running.

Return type:

dict[str, Any] | None

start(name, params)[source]#

Dispatch one Ray task for params.

Parameters:
  • name (str) – Unique trial name, used for the trial’s directory.

  • params (dict[str, float]) – The geometry to evaluate.

Returns:

The Ray object reference for the task.

Raises:

RuntimeError – If RAY_ADDRESS is unset, i.e. this is not running inside a Ray cluster.

Return type:

Any

class qpdk.simulation.study.SlurmRunner(cluster, run_root, ranks, evaluator, timeout=1800.0)[source]#

Bases: object

Run each trial as its own Slurm job.

One script serves the whole study: Slurm forwards the arguments after the script name to the script, so each submission differs only in the parameter file it is given.

Parameters:
cluster#

Site settings the job is sized and submitted with.

Type:

SlurmCluster

run_root#

Shared directory the trials are written under.

Type:

Path

ranks#

MPI ranks to give each solve.

Type:

int

evaluator#

Callable to serialize for worker jobs, or an importable module:function name.

Type:

str | Callable[[…], dict[str, Any]]

timeout#

Seconds before a solve is abandoned. Keep it under the job’s own wall clock, or Slurm kills the job first and the trial reports nothing.

Type:

float

collect(handle)[source]#

Read a finished job’s result, if it has one yet.

Parameters:

handle (tuple[str, Path]) – The job id and parameter path from start().

Returns:

The row, or None while the job is still queued or running.

Raises:

SlurmJobError – If the scheduler query fails after ten spaced retries.

Return type:

dict[str, Any] | None

start(name, params)[source]#

Submit one job for params.

Parameters:
  • name (str) – Unique trial name, used for the parameter file.

  • params (dict[str, float]) – The geometry to evaluate.

Returns:

The Slurm job id and the parameter file’s path.

Return type:

tuple[str, Path]

class qpdk.simulation.study.TrialRunner(*args, **kwargs)[source]#

Bases: Protocol

Starts one trial somewhere and collects it when it is done.

collect(handle)[source]#

Return a trial’s row, or None while it is still running.

Parameters:

handle (Any) – The value start() returned.

Returns:

The result row, carrying error if the trial failed, or None if it has not finished yet.

Return type:

dict[str, Any] | None

start(name, params)[source]#

Begin evaluating params and return a handle to it.

Parameters:
  • name (str) – Unique name for the trial, usable as a directory name.

  • params (dict[str, float]) – The geometry to evaluate.

Returns:

A handle to pass back to collect().

Return type:

Any

qpdk.simulation.study.run_study(study, runner, *, suggest, objectives, n_trials, max_in_flight, poll_seconds=20.0)[source]#

Run study to n_trials, keeping trials in flight as they finish.

The sampler is asked for a new trial as soon as one comes back rather than at a batch boundary, so no part of the study waits on its slowest solve and every result informs the next suggestion.

Parameters:
  • study (Study) – The study to advance. Its sampler decides what to try next.

  • runner (TrialRunner) – Starts and collects a trial.

  • suggest (Callable[[Trial], None]) – Fills a trial’s parameters, usually with trial.suggest_*.

  • objectives (Callable[[dict[str, Any]], Sequence[float]]) – Maps a result row to the values study was created for.

  • n_trials (int) – Total trials to evaluate.

  • max_in_flight (int) – How many trials may be running at once. On Slurm this is the real concurrency; under Ray the cluster’s cores also bound it.

  • poll_seconds (float) – Seconds between checks for finished trials.

Returns:

One row per successful trial, carrying its number and parameters.

Raises:
Return type:

list[dict[str, Any]]

Evaluate one trial of a parameter sweep, as its own process.

A sweep is a queue of independent solver runs, and the cheapest way to put that queue on a cluster is one job per trial: a small allocation starts on any node with room, so the sweep fills whatever is free. This module is what such a job runs. It reads the trial’s parameters from a JSON file, hands them to an evaluator, and writes the result beside them, so the driver only has to read files back.

Nothing here knows what kind of simulation the evaluator runs. The job loads either an importable module:function or a callable serialized by the notebook driver. Both take (params, run_root) plus keyword arguments and return a row of results.

Running it by hand is the same call the scheduler makes:

python -m qpdk.simulation.trial params.json --run-root /scratch/run
    --ranks 8 --evaluator mysweep:evaluate_layout
qpdk.simulation.trial.main(argv=None)[source]#

Evaluate one trial and write its result next to its parameters.

Parameters:

argv (list[str] | None) – Command-line arguments, defaulting to sys.argv[1:].

Returns:

0 on success. A failed trial writes the error to the result file and still exits non-zero, so Slurm records the task as failed while the driver can report what went wrong.

Return type:

int

Launch simulation work on a Slurm cluster.

A geometry sweep is a queue of independent solver runs, and this module renders the sbatch scripts for the two ways of handing that queue to Slurm:

  • One job per trial, submitted as the study goes. Each asks for a few ranks and a memory limit on each requested node. The driver keeps a fixed number in flight.

  • A Ray cluster inside one allocation, one worker per node. A good fit when the job owns its nodes, but it can only start once a whole node’s worth of cores is free.

This module only turns settings into scripts and submits them; the loop that decides what to submit lives in qpdk.simulation.study.

Every site-specific value is a plain string, so adapting the scripts to a given cluster means filling in the fields rather than editing the templates.

class qpdk.simulation.cluster.SlurmCluster(*, partition='batch', scratch='.', setup='', account=None, nodes=1, cores_per_node=16, mem_per_node='32G', time_limit='04:00:00', job_name='qpdk-ray', log_dir='slurm-logs', solver_cores=8, trial_nodes=1, mem_per_task='24G', driver_time_limit='24:00:00')[source]#

Bases: object

Site settings for running batched simulation work on Slurm.

The same settings describe both arrangements: one job per trial, and a Ray cluster inside a single multi-node allocation.

Parameters:
  • partition (str)

  • scratch (str)

  • setup (str)

  • account (str | None)

  • nodes (int)

  • cores_per_node (int)

  • mem_per_node (str)

  • time_limit (str)

  • job_name (str)

  • log_dir (str)

  • solver_cores (int)

  • trial_nodes (int)

  • mem_per_task (str)

  • driver_time_limit (str)

partition#

Partition to request, or a comma-separated list of them. Naming several lets the scheduler start the job wherever it can begin earliest, which usually beats pinning one fast partition.

Type:

str

scratch#

Shared filesystem the job runs from, visible on every node. The driver writes results there and Ray stages its session under it.

Type:

str

setup#

Shell snippet run on every node before Ray starts, for loading environment modules, activating a virtual environment and redirecting package caches onto scratch.

Type:

str

account#

Slurm billing account. Optional because on many sites the default account is the only one the general partitions accept.

Type:

str | None

nodes#

Number of nodes to request.

Type:

int

cores_per_node#

Cores per node, which also bounds how many solver ranks a node can run at once.

Type:

int

mem_per_node#

Memory per node. Sites commonly default to a few hundred megabytes per core, so this is worth setting explicitly.

Type:

str

time_limit#

Wall-clock limit, HH:MM:SS.

Type:

str

job_name#

Slurm job name, reused in the log file names.

Type:

str

log_dir#

Directory for Slurm’s job output. Relative paths are resolved when the script is rendered.

Type:

str

solver_cores#

Cores one solver invocation may use, and so the size of one trial’s job. Under Ray the driver asks for this many CPUs per trial.

Type:

int

trial_nodes#

Nodes per independent trial job. MPI ranks are split evenly.

Type:

int

mem_per_task#

Memory per trial node. Separate from mem_per_node because Ray uses its own allocation.

Type:

str

driver_time_limit#

Wall-clock limit for the driver job. The driver only submits and waits, so it needs one core but usually outlives each trial by a long way.

Type:

str

driver_sbatch_script(driver)[source]#

Render the script for the driver job.

The driver asks the optimizer for trials and submits a job per trial; the solving happens in those jobs, so this one needs a single core and a wall clock longer than the whole study.

Parameters:

driver (str) – Command the driver job runs.

Returns:

The script as a string.

Return type:

str

sbatch_script(driver)[source]#

Render the script that runs driver on a Ray cluster.

Parameters:

driver (str) – Command to run on the head node once the cluster is up.

Returns:

The script as a string.

Raises:

ValueError – The Ray worker needs more cores than one node provides.

Return type:

str

task_sbatch_script(command)[source]#

Render the script that solves one trial.

Submitted once per trial. Slurm forwards any arguments after the script name to the script itself, so sbatch trial.sbatch params.json gives the command its parameter file as $1 and one script serves the whole study.

Parameters:

command (str) – Command the job runs, which can use "$1".

Returns:

The script as a string.

Return type:

str

write_driver_script(path, driver)[source]#

Write the driver script to path.

Parameters:
  • path (str | Path) – Destination for the script.

  • driver (str) – Command the driver job runs.

Returns:

The written path.

Return type:

Path

write_sbatch_script(path, driver)[source]#

Write the Ray cluster script to path.

Parameters:
  • path (str | Path) – Destination for the script.

  • driver (str) – Command to run on the head node.

Returns:

The written path.

Return type:

Path

write_task_script(path, command)[source]#

Write the one-trial script to path.

Parameters:
  • path (str | Path) – Destination for the script.

  • command (str) – Command the job runs.

Returns:

The written path.

Return type:

Path

exception qpdk.simulation.cluster.SlurmJobError[source]#

Bases: RuntimeError

Raised when a Slurm command fails.

qpdk.simulation.cluster.job_state(job_id)[source]#

Return a job’s Slurm state, or None once it has left the queue.

Parameters:

job_id (str) – A Slurm job id.

Returns:

The state Slurm reports, or None if the job is no longer queued.

Raises:

SlurmJobError – If squeue itself fails, which is not the same as the job having left the queue.

Return type:

str | None

qpdk.simulation.cluster.submit(script, *args)[source]#

Submit script to Slurm and return the job id.

Parameters:
  • script (str | Path) – Path to the sbatch script.

  • args (str) – Arguments passed to the script itself. Slurm forwards anything after the script name, so one script can serve many submissions that differ only in what they are given.

Returns:

The job id Slurm assigned.

Raises:

SlurmJobError – If sbatch fails.

Return type:

str

qpdk.simulation.cluster.wait(job_id, *, poll_seconds=20.0, max_query_failures=10)[source]#

Block until job_id leaves the queue.

A job can be briefly absent from squeue right after submission, so a single miss is not treated as completion.

Parameters:
  • job_id (str) – A Slurm job id.

  • poll_seconds (float) – Seconds between polls.

  • max_query_failures (int) – Consecutive squeue failures tolerated before giving up.

Raises:

SlurmJobError – If squeue keeps failing, rather than silently reporting the job as finished.

Return type:

None

Common#

Neutral geometry preparation shared by the AEDT and COMSOL exporters.

This module is pure gdsfactory geometry: it turns a QPDK mask into the physical metal a field solver actually sees. It has no PyAEDT, MPh, or COMSOL imports so both exporters can depend on it.

qpdk.simulation.layout.prepare_metal_layout(component, margin_draw=0.0, margin_etch=0.0, *, name=None)[source]#

Fold a QPDK mask into positive metal geometry for a field solver.

Applies the additive metals to the etch layer, inverts the mask polarity around the component bounding box, grows the ground by margin_draw, drops the etch layers, and re-adds the original ports.

Parameters:
  • component (Component) – The component to prepare.

  • margin_draw (float) – Margin in µm added to the M1/M2 draw layers.

  • margin_etch (float) – Margin in µm added to the M1/M2 etch layers before inversion.

  • name (str | None) – Name for the initial preparation cell. Defaults to f"{component.name}_metal".

Returns:

A copy of the component prepared for simulation.

Return type:

Component

Ansys HFSS, Q3D and Q2D#

QPDK does not wrap Ansys AEDT. Use gplugins.ansys (installed by the hfss extra) directly. Pass layer_stack=qpdk.tech.LAYER_STACK explicitly to its HFSS, Q3D and Q2D classes. The PDK default is LAYER_STACK_3D, which adds an implicit ground plane for mask previews and must not be used for simulations. Pass material_properties=qpdk.tech.material_properties to use QPDK’s material table, and prepare components with prepare_metal_layout(). The HFSS notebooks show the full workflow.

VACASK#

The external simulator runs Lumped Josephson parametric amplifiers with VACASK and CPW parametric amplifiers: layout and circuit models with released VACASK. Native cold-network and subprocess checks also use the release. The TWPA notebook, Josephson traveling-wave parametric amplifiers with JosephsonCircuits.jl, uses JosephsonCircuits.jl instead.

Run the VACASK circuit simulator as a subprocess and read its results.

VACASK is an analog circuit simulator with multi-tone harmonic balance (hb) and small-signal analysis around a pumped operating point (hbac). Devices are written in Verilog-A and compiled on the fly by OpenVAF-Reloaded (openvaf-r).

VACASK is AGPL-3.0 licensed. This package only calls the vacask binary as a separate process and parses the SPICE raw files it writes; it does not import, link or copy any VACASK code. The VACASK maintainer reads out-of-process use as staying outside the AGPL’s combined work rule (issue #161).

Circuits and analyses are written directly in VACASK’s netlist syntax. The package only adds what is awkward to do by hand:

  • Netlist fills raw VACASK text into a Jinja2 template and copies the Verilog-A models shipped with qpdk next to it.

  • Vacask runs a netlist and returns one RawPlot per analysis.

  • RawFile reads the SPICE raw files that VACASK writes.

  • vector_fit() and vector_fit_subckt() turn S-parameters into a VACASK subcircuit. They need the models extra.

Example

>>> from qpdk.simulation.vacask import Netlist, Vacask
>>> netlist = Netlist(
...     "JJ resonance",
...     loads=["josephson_junction.va", "capacitor.osdi"],
...     circuit='''
...         model isource isource
...         model capacitor capacitor
...         model jj jj
...         iin (0 1) isource dc=0 mag=1n
...         j1 (1 0) jj ic=1u
...         c1 (1 0) capacitor c=2p
...     ''',
...     control='analysis ac1 ac from=1G to=10G mode="lin" points=1000',
... )
>>> results = Vacask.find().run(netlist)
>>> results["ac1"]["1"]  # complex node voltage
class qpdk.simulation.vacask.Netlist(title, circuit='', control='', loads=(), subckts=(), files=<factory>)[source]#

A VACASK netlist, rendered from vacask_templates/netlist.sim.j2.

circuit, control and subckts are VACASK netlist text and are inserted as-is, after removing their common indentation. The control block always starts with abort always, so that a failed analysis gives a nonzero exit code.

Parameters:
title#

First line of the netlist.

Type:

str

circuit#

Models and instances, in VACASK syntax.

Type:

str

control#

Contents of the control block: options, sweeps and analyses. Each analysis <name> ... line produces a result.

Type:

str

loads#

Files to load, e.g. "capacitor.osdi" from the VACASK installation or "josephson_junction.va". Verilog-A models shipped with qpdk are copied next to the netlist automatically.

Type:

Sequence[str]

subckts#

Subcircuit definitions, e.g. from vector_fit_subckt(). Each one is dedented on its own, so they can come from different sources.

Type:

Sequence[str]

files#

Extra files to place next to the netlist, as {name: content}. A Path value is copied; a string is written as text.

Type:

Mapping[str, str | Path]

all_files()[source]#

Files to write next to the netlist, including loaded qpdk models.

Return type:

dict[str, str | Path]

property analyses: list[str]#

Names of the analyses in control, in order.

render()[source]#

Return the netlist text.

Return type:

str

class qpdk.simulation.vacask.RawFile(path)[source]#

A SPICE raw file (binary or ASCII) as written by VACASK.

The file may hold several plots back to back. Binary data is little-endian float64 (complex128 for complex plots), stored point-major.

path#

Path of the file.

plots#

The plots in file order.

Raises:

VacaskError – If the file is truncated or malformed.

Parameters:

path (str | Path)

class qpdk.simulation.vacask.RawPlot(title, plotname, names, data)[source]#

One plot of a SPICE raw file.

Parameters:
title#

Circuit title.

Type:

str

plotname#

Analysis description written by the simulator.

Type:

str

names#

Variable names, in column order.

Type:

tuple[str, …]

data#

Array of shape (points, variables); complex for complex plots.

Type:

ndarray

split(name)[source]#

Split a swept plot into one plot per value of the sweep variable name.

A new group starts wherever the sweep column changes value.

Returns:

One plot per sweep point, in order.

Parameters:

name (str)

Return type:

list[RawPlot]

class qpdk.simulation.vacask.SimulationResult(*, plots, output, elapsed, binary_sha256)[source]#

Plots and diagnostics from one simulator invocation.

With an explicit workdir, the deck, model files, raw results, vacask.log and run.json remain available after success or failure. A successful process exit does not establish harmonic convergence or pump stability.

Parameters:
class qpdk.simulation.vacask.Vacask(binary, openvaf=None)[source]#

The VACASK simulator, run as a separate process.

Parameters:
binary#

Path to the vacask binary.

Type:

Path

openvaf#

Path to the Verilog-A compiler, passed to VACASK as SIM_OPENVAF unless that is already set.

Type:

Path | None

classmethod find()[source]#

Locate VACASK and OpenVAF.

Checks the VACASK and QPDK_VACASK environment variables, then PATH, for vacask, and OPENVAF then PATH for openvaf-r.

Returns:

A runner for the binaries found.

Raises:

VacaskError – If no vacask binary is found.

Return type:

Self

run(netlist, *, workdir=None, timeout=None)[source]#

Run a netlist and return the results of every analysis.

The netlist and its files are written to a scratch directory, which is also where VACASK compiles Verilog-A sources.

Parameters:
  • netlist (Netlist) – The circuit and analyses.

  • workdir (str | Path | None) – Directory to run in. Defaults to a temporary directory that is removed afterwards.

  • timeout (float | None) – Timeout in seconds.

Returns:

{analysis name: plot}, read from <analysis name>.raw. Swept analyses hold all sweep points; see RawPlot.split().

Return type:

dict[str, RawPlot]

See run_record() for diagnostics and failure conditions.

run_record(netlist, *, workdir=None, timeout=None)[source]#

Run a netlist with captured diagnostics and optional retained artifacts.

Expected raw outputs from a previous invocation are removed before the process starts, so a reused directory cannot supply stale results. run.json records input and binary hashes, elapsed seconds and exit status. Compiler overrides and module paths are recorded from the effective environment; they can refer to external, unbundled files.

Returns:

Plots, captured output, elapsed seconds and the simulator hash.

Raises:

VacaskError – If analyses have duplicate or invalid names, the simulator cannot start, fails or times out, or an output is missing.

Parameters:
Return type:

SimulationResult

exception qpdk.simulation.vacask.VacaskError[source]#

Raised when VACASK is missing, a simulation fails, or a raw file is malformed.

qpdk.simulation.vacask.cpw_tline_params(length, cross_section='cpw')[source]#

Characteristic impedance and delay of a lossless CPW for tline_ideal.

Uses cpw_parameters() with zero loss tangent, so the result matches VACASK’s lossless tline_ideal device. Needs the models extra.

Parameters:
  • length (float) – Line length in µm.

  • cross_section (CrossSectionSpec) – CPW cross-section.

Returns:

characteristic impedance in Ω and propagation delay in s.

Return type:

(z0, td)

Raises:

ImportError – If the models extra is not installed.

qpdk.simulation.vacask.format_value(value)[source]#

Format a Python value as a VACASK parameter value.

Useful for writing long vectors into netlist text, e.g. f"values={format_value(frequencies)}". Strings are quoted, numbers use the shortest exact representation, sequences become vectors [a, b] and sequences of sequences become lists of vectors {[a],[b]}, as used by spur and outspur.

Returns:

The value as VACASK netlist text.

Raises:

TypeError – If the value has no VACASK representation.

Parameters:

value (Any)

Return type:

str

qpdk.simulation.vacask.vector_fit(sdict, f, *, ports=None, z0=50.0, n_poles_real=1, n_poles_cmplx=10, enforce_passivity=True, n_samples=1000)[source]#

Fit S-parameters with skrf.vectorFitting.VectorFitting.

A thin wrapper that builds the skrf.Network from an SDict and checks that the result can be used as a circuit: every pole stable and the model passive. Evaluate the fit with get_model_response() and turn it into a VACASK subcircuit with vector_fit_subckt().

Parameters:
  • sdict (Mapping[tuple[str, str], ArrayLike]) – S-parameters on f, referenced to z0. Missing entries of a sparse SDict are zero.

  • f (ArrayLike) – Frequencies in Hz. Cover every frequency the circuit will see, e.g. up to nharm times the pump frequency for harmonic balance.

  • ports (Sequence[str] | None) – Port order. Defaults to the order in which sdict names them. Stored as the network’s port_names.

  • z0 (float) – Reference impedance in Ω.

  • n_poles_real (int) – Number of initial real poles.

  • n_poles_cmplx (int) – Number of initial complex-conjugate pole pairs.

  • enforce_passivity (bool) – Make the model passive if the fit is not.

  • n_samples (int) – Frequency samples for the passivity enforcement.

Returns:

The fitted VectorFitting object.

Raises:
  • ImportError – If the models extra is not installed.

  • ValueError – If a port is missing from sdict, the fit has an unstable pole, or it is still not passive after enforcement.

Return type:

VectorFitting

qpdk.simulation.vacask.vector_fit_subckt(vf, name)[source]#

Emit a vector fit as a VACASK subcircuit.

The fitted S-matrix is

\[S(s) = D + \sum_k \frac{R_k}{s - p_k},\]

where each complex pole stands for itself and its conjugate.

Each port is a resistor \(Z_0\) to ground in parallel with a current source \(2 b / \sqrt{Z_0}\), which gives the port voltage \(\sqrt{Z_0}(a + b)\). The incident waves \(a\) drive one first-order state per real pole and two per complex pair, and the reflected waves \(b\) are weighted sums of the states. Waves and states are node voltages scaled by \(\sqrt{Z_0}\); each state capacitor is \(1/|p_k|\), so all conductances and gains are of order one.

The subcircuit uses the builtin vccs and the resistor and capacitor models, so the netlist must load resistor.osdi and capacitor.osdi and define models of those names.

Parameters:
  • vf (VectorFitting) – A fit from vector_fit(), with a constant term and no proportional term, referenced to one real impedance.

  • name (str) – Subcircuit name.

Returns:

The subcircuit text, with terminals p1, p2, … in port order.

Raises:

ValueError – If the fit has a proportional term, an unstable pole, or port-dependent or complex reference impedances.

Return type:

str

COMSOL#

Build COMSOL models with gplugins.comsol directly: its COMSOL class subclasses mph.Model, holds the layout, and chains the sheet or metal builder, the RF or electrostatic study, and the mesh helpers. Plotting and result helpers live in gplugins.comsol.plotting and gplugins.comsol.results.

qpdk.simulation.comsol adds only what is specific to QPDK: prepare_comsol_layout(), which inverts the QPDK M1_ETCH mask into metal with a ground margin before the gplugins extraction, and the technology permittivities SILICON_RELATIVE_PERMITTIVITY and AIR_RELATIVE_PERMITTIVITY, which are passed explicitly to COMSOL.create_sheet.

The module needs the comsol extra, which installs gplugins[comsol]; building and solving a model also needs COMSOL. qpdk.simulation does not import it, so the rest of the package works without the extra.

QPDK-specific inputs for the COMSOL models of gplugins.comsol.

The COMSOL geometry builders, studies, mesh, plotting, and result helpers, and the chainable gplugins.comsol.COMSOL model live in gplugins.comsol; use them from there. This module holds only what depends on the QPDK technology:

  • prepare_comsol_layout() validates the M1_ETCH mask, inverts it into physical M1_DRAW metal with a ground margin, rejects layers the extraction would silently drop, and defaults to the coupling_o1/coupling_o2 feeds, before handing the metal to gplugins.comsol.layout.prepare_comsol_layout().

  • SILICON_RELATIVE_PERMITTIVITY and AIR_RELATIVE_PERMITTIVITY are the QPDK technology permittivities. The gplugins builders default to their own values, so pass these explicitly.

Note

Needs the optional comsol extra (uv sync --extra comsol), which installs gplugins[comsol]. Building and solving models also needs a local COMSOL installation.

Example

>>> from gplugins.comsol import COMSOL
>>> from qpdk.simulation.comsol import (
...     AIR_RELATIVE_PERMITTIVITY,
...     SILICON_RELATIVE_PERMITTIVITY,
...     prepare_comsol_layout,
... )
>>> layout = prepare_comsol_layout(component)
>>> model = COMSOL.create_sheet(
...     client,
...     layout,
...     "resonator",
...     silicon_relative_permittivity=SILICON_RELATIVE_PERMITTIVITY,
...     air_relative_permittivity=AIR_RELATIVE_PERMITTIVITY,
... )
qpdk.simulation.comsol.AIR_RELATIVE_PERMITTIVITY = 1#

Relative permittivity of the air above the chip, from the material of the QPDK layer stack’s vacuum level.

qpdk.simulation.comsol.SILICON_RELATIVE_PERMITTIVITY = 11.45#

Relative permittivity of the silicon substrate, from the material of the QPDK layer stack’s substrate level.

qpdk.simulation.comsol.prepare_comsol_layout(component, feed_ports=('coupling_o1', 'coupling_o2'), ground_margin=100.0, *, crop_to_feed_ports=False)[source]#

Extract M1_DRAW metal polygons and optional feed ports from a component.

The M1_ETCH mask is inverted into positive M1_DRAW metal around the component bounding box grown by ground_margin, with prepare_metal_layout(), and the result is extracted with gplugins.comsol.layout.prepare_comsol_layout().

Parameters:
  • component (Component) – The gdsfactory component to extract. It must contain an M1_ETCH mask; positive M1_DRAW shapes alone do not define the gaps. The original ports are preserved.

  • feed_ports (tuple[str, str] | None) – Names of exactly two distinct ports to expose as feeds, or None for an unfed layout, in which case the result carries no feed ports. None fits layouts with no CPW feedline, e.g. an eigenmode qubit cell.

  • ground_margin (float) – Positive margin in µm added around the component bounding box to form the ground plane.

  • crop_to_feed_ports (bool) – When True, cut the prepared metal back to the two feed-port planes so each external face shows an open CPW cross section instead of the extended ground. Requires two feed ports that are axis-aligned, opposite, outward-facing, and share their transverse coordinate (left/right along x, or bottom/top along y). Cropping is refused unless every M1_DRAW and M1_ETCH polygon of the input component lies between the planes and the etch gaps reach both planes, so no resonator metal is cut and no face is shorted. The planes snap outwards to the database grid, so the returned bbox can exceed the port span by less than one database unit. Defaults to False, which leaves the prepared ground untouched.

Returns:

A gplugins.comsol.layout.ComsolLayout with metal polygons (holes preserved), the selected feed ports (empty when feed_ports is None), and the prepared bounding box, all in µm.

Raises:

ValueError – If ground_margin is not positive and finite, if feed_ports is supplied but does not name two distinct available ports, if a feed port is not cardinal or has non-finite coordinates or width, if the component has no M1_ETCH mask or carries geometry on unsupported fabrication layers, if no M1_DRAW metal remains, or if crop_to_feed_ports is set but the feeds are not a valid opposite pair or the component does not fit between the planes.

Return type:

ComsolLayout

FEM datasets#

A dataset is nothing but its files: Parquet parts, or a Delta Lake table, each carrying the dataset metadata (units, terminals, quantities, provenance) inside it. Lookups read the table with Polars and return jittable SAX models. Requires the models extra. See FEM Dataset Lookup in SAX Models for a walkthrough.

Curated datasets ship in qpdk/models/datasets/data/ as Parquet in Git LFS. From a checkout, fetch only these with git lfs pull --include="qpdk/models/datasets/data/**". Wheels on PyPI ship the resolved Parquet files, so installed users need no Git LFS.

Results can also live outside Git, in a Delta Lake table on a cloud bucket (gs://, s3://, az://) or a local path, optionally pinned to a table version. Credentials are passed when opening the dataset and never stored in it. Requires the delta extra.

from qpdk.models.datasets import Dataset

options = {"google_service_account": "key.json"}
dataset = Dataset("gs://my-bucket/plate_capacitor", delta=True, storage_options=options)
dataset.append(new_rows)  # one atomic Delta commit

pinned = Dataset(
    "gs://my-bucket/plate_capacitor", delta=True, version=3, storage_options=options
)

Datasets and models#

Every model below takes geometry in µm and frequency f in Hz. Values outside the stored grid return NaN, an omitted geometry argument falls back to the model’s stored default, usually the middle grid coordinate, and discrete variants such as the turn count are fixed when the model is created. The generation recipe for each dataset lives in qpdk/models/datasets/recipes/.

Model

Dataset

Gives you

Geometry inputs

Notes

flipmon_unbonded_model()

flipmon_unbonded_palace

Maxwell matrix, ports inner_pad, outer_ring, top_pad

chip_gap, alignment, inner_radius, top_radius

Separate electrodes between two 300 µm chips, no junction or bonding bump

flipmon_ideal_bond_model(inductance, junction_capacitance=0.0)

flipmon_unbonded_palace

Ports inner_pad and outer_ring with a linearized junction

same as above

Ideal wire from the inner pad to the top pad; no bump field or inductance

double_pad_transmon_capacitance()

double_pad_transmon_palace

Differential pad capacitance \(C_\text{diff}\) in F

pad_width, pad_height, pad_gap, ground_clearance

Junction excluded; add \(C_\text{J}\) in parallel before computing \(E_\text{C} = e^2/[2(C_\text{diff}+C_\text{J})]\)

double_pad_transmon_fem_model(inductance, junction_capacitance=0.0)

double_pad_transmon_palace

Ports left_pad and right_pad with a linearized junction

same as above

Both ports use the coupon’s finite 300 µm ground frame; no readout, package or backside ground

gatemon_capacitances()

gatemon_palace

\(C_\Sigma\) in F, which sets \(E_\text{C}\); \(C_\text{g}\), which sets \(n_\text{g} = C_\text{g}V_\text{g}/2e\); \(C_\text{c}\)

island_size, pocket_gap, pocket_gap_top, gate_tip_gap

Only M1 sheets, so \(C_\text{g}\) is the M1 feed coupling, not the nanotube gate lever arm. \(C_\Sigma\) already includes both branches

gatemon_fem_model(delta_ghz=50.0, transmissions=(0.8,))

gatemon_palace

Ports island, gate, readout, with the ABS junction linearized between island and ground

same as above, plus delta_ghz, transmissions, junction_capacitance

Electrostatic solve, since \(E_\text{J}\) is gate-tuned rather than set by the geometry. readout is open, so do not add its self-capacitance to a distributed resonator

flipmon_readout_coupon_model()

flipmon_readout_coupon_palace

Ports island_a, island_b, readout, feedline, top_pad

pad_length, pad_offset, feedline_gap, coupling_length

Feedline cut open and the whole conductor at one potential, so this is not a resonator mode. Needs RF validation

transmon_readout_coupon_model()

transmon_readout_coupon_palace

Ports island_a, island_b, readout, feedline

same as above

Same caveat. Feedline gaps of 14 to 20 µm retain the ground strip

plate_capacitor(), registered as qpdk.models.plate_capacitor

coupling_pad_palace

Ports o1 and o2, with both pad-to-ground branches and the coupling between the pads

length, width, gap, offset

Matches qpdk.cells.plate_capacitor and its finite ground frame. Connect the pair directly without adding the ground branches again. plate_capacitor_analytical is the closed form without them

meander_inductor_ground_capacitance(turn_length=200, wire_width=2, wire_gap=4, n_turns=3)

meander_ground_capacitance_palace

Winding-to-ground capacitance in F, from 54 extractions

turn_length, wire_width, wire_gap

Both winding ends at one potential, so this is common mode only: no differential parasitics, inductance or self-resonance

meander_inductor_fullwave_model(n_turns=5)

meander_sparameters_palace

Complex S-parameters on ports o1 and o2, 0.5 to 20 GHz, 50 ohm, no de-embedding, from 243 sweeps

turn_length (100 to 300), wire_width (1 to 2), wire_gap (2 to 8)

Fixed 0.4 pH per square on both winding and frame, no material loss, radiation allowed. Use meander_inductor for an analytical model with adjustable film properties

qpdk.models.couplers.cpw_coupling_model()

cpw_coupling_ground_strip_palace by default, cpw_coupling_palace for a fully etched gap

Four-port quasi-TEM coupled section of a uniform slice

width, cpw_gap, and gap or ground_strip_width

Interpolates logarithmically once a ground strip is present. coupler_straight uses the same lookup

drum_capacitances()

drum_lc_resonator_palace

Coupling \(C_\text{c}\), parasitic \(C_\text{p}\) and drum \(C_\text{d}\) in F, from 54 electrostatic solves

coupling_gap, coupling_pad_size, radius, turn_length

The other cell arguments are fixed at their defaults, including 75 meander runs. Values outside the grid return NaN

drum_lc_resonator_fem_model()

drum_lc_resonator_palace

Two-port lumped SAX model with the extracted capacitances in place of the analytical ones

same as above, plus gap and sheet_inductance

The extracted \(C_\text{d}\) replaces the analytical value at the stack gap, so gap still moves only the analytical parallel-plate term

flipmon_ideal_bond_model and double_pad_transmon_fem_model build on junction_model(), which documents the circuit convention. The winding and ground frame of the full-wave coupon share its sheet inductance, so the terminal response includes both. For a straight strip with nearly uniform current, sheet inductance times length over width estimates only the strip’s contribution; compare against a control solve with a PEC ground return, and keep the frame’s sheet inductance on when generating a device dataset.

For the hybrid process, use qpdk.tech.LAYER_STACK_GATEMON and measure the flake thickness before meshing a local electrostatics solve.

Gatemon validation#

The bundled gatemon_palace dataset holds all 144 geometries and 3024 records, including the complete three-terminal Maxwell matrices and solver diagnostics. A representative convergence study re-solved four M1 island geometries on a finer 0.6 µm / 40 µm mesh with fourth-order elements and a larger domain: \(C_\Sigma\), \(C_\text{g}\) and \(C_\text{c}\) moved by at most 0.74%, inside their 1%, 5% and 1% tolerances. Three independent holdout solves interpolate to within 2.61%, inside the 3%, 10% and 5% limits. Every matrix gives a finite, reciprocal, lossless response over 1 to 10 GHz. At the default geometry the M1 values are 51.93 fF, 1.582 fF and 5.731 fF, for the finite extraction coupon without the hybrid process’s local capacitances. Full-grid coverage and interpolation are checked separately.

Both reports sit in each Parquet part’s qpdk.dataset metadata under provenance.validation, so read them through the dataset API:

from qpdk.models.datasets import Dataset

validation = Dataset("gatemon_palace").metadata.provenance["validation"]
convergence = validation["convergence"]
verification = validation["verification"]

The verification report hashes the result rows independently of the metadata. Its source provenance retains the original solver-file hashes, while Git LFS identifies the published Parquet file including the embedded reports.

The gatemon test chip#

The gatemon_test_chip sample follows Fig. 1a of [RAP+25]: two independent transmission resonators with series coupling capacitors at both ends, both targeting device A’s 5.056 GHz bare-cavity frequency. A capacitor-to-capacitor CPW path is a 600 µm coupling straight, a 9946.135 µm meander and a 1400 µm tail, giving about 5.14 fF of end capacitance each. The capacitor geometry and the 10 µm / 6 µm CPW width and gap are QPDK design choices, not published dimensions. The targets are \(E_\text{C}/h = 260\,\text{MHz}\) and \(g \approx 50\,\text{MHz}\) for device A, and 330 MHz and about 120 MHz for device B, which stay targets for electromagnetic calibration of the island and readout gap.

gatemon_coupler models the coupling CPW with a lumped 5.4 fF branch to the island, its third port referencing the island edge across the etched gap without adding metal there. gatemon_island adds the grounded Josephson inductance, a 49.1 fF island shunt, 20 fF island-to-gate and 35 fF gate-to-ground. Each contact overlaps the top gate by 18 µm², and 30 nm hBN of relative permittivity 3.4 puts a parallel-plate contact near 18 fF. These are nominal hybrid estimates that still need a measured flake thickness and a full-stack extraction.

The junction is linearized, so the simulation gives the qubit’s loading and the gate-tuned readout response but not the nonlinear spectrum or decoherence. The gate ports use a 50 ohm reference, so a matched gate loads the qubit; the paper’s bias tee grounds its RF branch and acts as a reflective filter, so use an ideal RF-grounded gate for frequency and coupling checks. The nonlinear ABS Hamiltonian instead gives an isolated \(f_{01}\) near 4.447 GHz for the nominal charging energy, and reproducing the paper’s quantum-dot parity transitions needs its extended Hamiltonian [RAP+25]. Broad sweeps can miss the maxima of narrow resonances, so use a local frequency sweep before interpreting their heights.

Drum LC resonator#

drum_capacitances returns, in F, the coupling \(C_\text{c}\), the parasitic \(C_\text{p}\) and the drum capacitance \(C_\text{d}\) of a drum_lc_resonator layout, from coupling_gap, coupling_pad_size, radius and turn_length in um. drum_lc_resonator_fem_model() returns the lumped SAX model with these values in place of its coupling_capacitance and parasitic_capacitance inputs. The extracted \(C_\text{d}\) replaces the analytical one at the stack gap, and the gap argument still tunes the analytical parallel-plate term. The bundled drum_lc_resonator_palace dataset holds 54 Palace solves over coupling_gap, coupling_pad_size, radius and turn_length, and both functions interpolate it.

The recipe qpdk/models/datasets/recipes/drum.py is a 3D electrostatic solve of the whole cell with its feedline in a finite coplanar ground. Its terminals are the node (pad, wiring and bottom electrode), the VG_TOP membrane one vacuum gap above it, the feedline cut open beyond the ports, and seventeen blocks of meander runs separated by 1 um slits. The recipe weights the Maxwell matrix with the node at 1, the membrane and feed at 0 and each meander block at its mean position along the wire: the feed and membrane charges give \(C_\text{c}\) and \(C_\text{d}\), and the remaining energy gives \(C_\text{p}\). The full matrix is stored so other weightings can be applied. A 2D sheet solve cannot hold the membrane, so the recipe is 3D. Conductors are zero-thickness sheets by default; conductors="solid" extrudes M1 and the membrane. A gmsh box field sets 0.14 um elements in the plane of the 70 nm gap. The 1 um near-metal and 60 um far-field sizes, 600 um padding and third-order basis functions are the published settings. At the default geometry the solve gives 5.05 fF, 82.46 fF and 26.82 fF. With the analytical meander those values put the lumped transmission zero near 4.0 GHz, below the 6.7 GHz that the analytical defaults predict with the paper’s 16 fF parasitic.

The embedded convergence study compares that setting with a 0.07 um gap mesh, a 2 um near-metal mesh, fourth-order elements and a 1200 um domain, all at the default geometry and the published segmentation. The coupling moves by 0.05 %, 1.33 %, 0.55 % and less than 0.01 % respectively, inside the 2 % tolerance, and the parasitics by at most 0.73 %, inside their 1 % tolerance. The segmentation is the largest approximation: block midpoint weights form a staircase of the linear voltage profile, \(C_\text{c}\) rises 1.92 % from five blocks to nine and 0.33 % from thirteen to seventeen, so the published seventeen still sit about 0.7 % under the continuous profile while the other quantities stay within 0.1 %. Solid conductors are a different model rather than a refinement, and they move \(C_\text{d}\) by -6.4 %, a difference a 0.14 um mesh cannot resolve through a 120 nm membrane. Three holdout solves at off-grid points interpolate high by 4.6 to 5.1 % in \(C_\text{c}\), the chord error of a doubling gap axis on an exponentially decaying coupling, and by at most 3.3 % in \(C_\text{d}\).

Run uv run --script qpdk/models/datasets/recipes/drum.py convergence to reproduce the study at the default geometry. The linear voltage profile is a lumped approximation: the default meander is about 23 mm of wire, a substantial fraction of a guided wavelength near 7 GHz, so the driven meander recipe is the reference for its distributed response.

Generating Palace datasets#

The bundled plate_capacitor_palace and cpw_coupling_palace datasets contain real Palace electrostatic extractions. Their standalone scripts in qpdk/models/datasets/recipes/ define the grid, settings, metadata and solver function. Inline dependencies select the Python 3.12 environment required by gsim; generation scripts are excluded from the installed package.

Preview or run the bundled 27-point sweep from a checkout:

uv run --script qpdk/models/datasets/recipes/plate_capacitor.py --dry-run
uv run --script qpdk/models/datasets/recipes/plate_capacitor.py --processes 4
uv run --script qpdk/models/datasets/recipes/plate_capacitor.py --sif /path/to/palace.sif

Copy the script beside the original in qpdk/models/datasets/recipes/ and edit GRID and SETTINGS for another experiment. Meshwell meshes labelled conductor sheets and the surrounding air and silicon volumes. The shared experiment helper uses gsim to write the Palace configuration, run ElectrostaticSim and load capacitance matrices. Lookup models need none of these simulation dependencies.

Each geometry retains its inputs, mesh, config, solver log and results under --workdir. Rerunning reuses matching completed solves. Extend the grid while retaining the work directory to solve only new points. --output is replaced only after the sweep succeeds. The output Parquet files contain all dataset metadata, so lookups need no generator or solver. Container runs fingerprint the complete SIF; native runs fingerprint only the supplied executable.

Set save_fields=True for ParaView outputs. Compare representative geometries at finer near_mesh sizes and larger domain_pad before relying on small differences. Every solve stores solver_relative_residual, solver_iterations and fem_error_indicator_norm alongside its capacitance. Missing diagnostics and unconverged terminal solves prevent publication. The FEM indicator is Palace’s energy-normalized recovered-flux estimate, not a capacitance error bound. Scan and rank it to select refinement candidates. The CPW script also refines every geometry until all raw matrix entries change by at most --mesh-tolerance (default 1%) and the identical traces agree within 1%. It stores mesh_relative_change, mesh_refinement_level and the accepted mesh_near_size / mesh_far_size in SI units. Exhausting --max-refinements blocks publication. This refinement measures mesh sensitivity only; check larger domains and held-out-geometry interpolation separately. The notebook demonstrates a lazy diagnostic scan and outlier flag.

The plate example has a separate ground frame. The CPW example is a uniform slice of two identical traces with outer ground rails, sweeping trace width, outer slot width and inter-trace gap. Use Palace 0.17 or newer for its two-dimensional transverse mesh. Model.Lc sets the implicit depth to slice_length_um, so the stored capacitances remain in F. Divide by the depth in meters for capacitance per length.

Select --topology as-drawn to retain the ground strip of width max(gap - 2 * cpw_gap, 0) left between the component’s CPW etch masks. This topology produces cpw_coupling_ground_strip_palace, used by coupler_straight and the default distributed model. The default fully-etched experiment produces cpw_coupling_palace for comparison with the unshielded analytical ECCPW formula. The two geometries agree when the CPW slots touch or overlap. Above that gap, their difference measures the shielding from the intervening ground strip. The grounded sweep uses ground_strip_width = gap - 2 * cpw_gap as its third axis, with logarithmic sampling from 0.01 to 248 µm. The CPW models interpolate coordinates and capacitance magnitudes logarithmically in this regime and use the fully etched grid where the slots touch or overlap. Positive strips narrower than the sampled minimum return NaN. cpw_coupling_model() converts its capacitance lookup into a jittable four-port quasi-TEM SAX model; see the notebook for geometry heatmaps and S-parameters.

Both scripts support disjoint rectangular shards with --shard INDEX --shards COUNT. Use separate output and work directories for each worker, then merge after all succeed. For the smaller CPW development grid, use two workers with the generic Slurm helper:

generator=qpdk/models/datasets/recipes/cpw_coupling.py
sbatch --array=0-1 --cpus-per-task=4 qpdk/models/datasets/recipes/slurm_array.sh "$generator" build/cpw-shards build/cpw-runs --sif /path/to/palace.sif
shards=()
for i in {0..1}; do shards+=(--merge-shards "build/cpw-shards/shard-$i"); done
uv run --script "$generator" "${shards[@]}" --output build/datasets/cpw_coupling_palace

Supply your scheduler’s partition and resource options to sbatch. Merging checks matching provenance, successful results and exact coverage before publishing. Keep the experiment, runtime and array size fixed when resuming.

Without Slurm, run the generator directly to solve the full grid sequentially. The notebook also shows a local shard loop with the same merge step. Each Palace solve defaults to four MPI ranks; the Slurm helper reserves four CPUs and sets one thread per rank. Supply the array range explicitly at submission, starting at zero.

qpdk.models.datasets.s_parameters_model loads a stored complex scattering matrix as a jittable SAX model with any number of labelled ports. capacitance_model converts a real Maxwell matrix into a lumped N-port model, including capacitances to ground. Distributed CPW physics lives in qpdk.models.couplers.cpw_coupling_model; coupler_straight uses the same Palace mutual-capacitance lookup through cpw_cpw_coupling_capacitance.

For a full-wave dataset, copy meander_inductor.py and use the shared _driven.Recipe and _driven.run_cli helpers. Supply your geometry grid, frequencies in Hz and port names in matrix order. The prepare function builds and meshes a driven simulation, sets an independent excitation at each port, writes the config, and returns the simulation. Record the reference impedances, reference planes and materials in the recipe provenance.

The helper runs the complete frequency sweep once per geometry, retains all complex matrix entries and resumes completed solves. Geometry shards retain the full frequency axis. Missing ports, frequencies or failed solves cannot be merged into a complete dataset. Passivity and optional reciprocity checks apply to any number of ports. These checks do not replace mesh refinement or held-out interpolation checks. For nonreciprocal media, set reciprocal=False in the recipe.

Load the results with s_parameters_model(path, **variants). The returned SAX model interpolates frequency and continuous geometry axes together and returns NaN outside the stored domain. Select discrete variants when loading. Complex linear interpolation can introduce apparent loss between sample points, so check withheld frequencies near resonances before choosing the frequency spacing.

Dataset API#

Reusable FEM extraction datasets and their JAX lookup.

A dataset is a directory of Parquet parts (Git LFS for curated datasets) or a Delta Lake table, with its DatasetMetadata stored inside the files. See qpdk.models.datasets.table for the table layout, qpdk.models.datasets.generate for producing one, and qpdk.models.datasets.store for where it lives.

Requires the models extra; Delta Lake also needs the delta extra.

Metadata describing the physical meaning of a FEM extraction dataset.

The metadata travels inside the results files, so a dataset is nothing but its Parquet files (or Delta table): each Parquet part stores it as JSON under the key-value metadata key METADATA_KEY, and a Delta table stores it as metadata of the value column. Parameter grids are not repeated here; they are read from the data.

The metadata holds what a row of the table cannot carry on its own: the units of the axes, the meaning of each quantity, the terminal order and reference ground of matrix quantities, and free-form provenance (recipe, geometry, layer stack, solver, accuracy).

class qpdk.models.datasets.metadata.Axis(*, name, unit, validated=None)[source]#

A continuous parameter axis; its grid values come from the data.

Parameters:
model_config = {'extra': 'forbid', 'frozen': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class qpdk.models.datasets.metadata.DatasetMetadata(*, name, description='', synthetic=False, axes, variants=(), quantities, terminals=(), reference_ground='ground', provenance=<factory>, schema_version=1)[source]#

Metadata stored inside the results files of a dataset.

Parameters:
  • name (str) – Dataset name.

  • description (str) – One-line description.

  • synthetic (bool) – True when the values are not real solver output; such data must not be used for design.

  • axes (tuple[Axis, ...]) – Continuous parameters, in tensor order.

  • variants (tuple[str, ...]) – Discrete parameters (string columns), selected and never interpolated.

  • quantities (tuple[Quantity, ...]) – Stored quantities.

  • terminals (tuple[str, ...]) – Ordered terminal labels; defines matrix row and column order.

  • reference_ground (str) – Conductor all potentials are referenced to; not a matrix terminal.

  • provenance (dict[str, Any]) – Free-form recipe, geometry, layer stack, solver, and accuracy information.

  • schema_version (int) – Schema version; must equal SCHEMA_VERSION.

classmethod from_json(text)[source]#

Parse and validate metadata read from a file.

Returns:

The validated metadata.

Parameters:

text (str)

Return type:

Self

model_config = {'extra': 'forbid', 'frozen': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

quantity(name)[source]#

Return the quantity called name.

Raises:

KeyError – if there is no such quantity.

Parameters:

name (str)

Return type:

Quantity

to_json()[source]#

Serialize for the file metadata.

Returns:

Compact JSON.

Return type:

str

qpdk.models.datasets.metadata.METADATA_KEY = 'qpdk.dataset'#

Parquet key-value (and Delta column) metadata key holding the JSON metadata.

class qpdk.models.datasets.metadata.Quantity(*, name, kind, unit, complex=False)[source]#

A quantity stored in the results table.

Every kind except QuantityKind.CIRCUIT_PARAMETER is a matrix over the dataset terminals.

Parameters:
property matrix: bool#

Whether rows carry row/col terminals.

model_config = {'extra': 'forbid', 'frozen': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class qpdk.models.datasets.metadata.QuantityKind(*values)[source]#

Physical meaning of an extracted quantity.

The Maxwell capacitance matrix, the mutual (SPICE-style) capacitance matrix, and reduced circuit parameters are different quantities and are never mixed.

CIRCUIT_PARAMETER = 'circuit_parameter'#

Scalar circuit parameter or numerical diagnostic, e.g. coupling capacitance or a solver residual.

INDUCTANCE = 'inductance'#

Inductance matrix between terminal loops.

MAXWELL_CAPACITANCE = 'maxwell_capacitance'#

positive diagonal, non-positive off-diagonal, w.r.t. the reference ground.

Type:

Maxwell matrix

MUTUAL_CAPACITANCE = 'mutual_capacitance'#

non-negative branch capacitances, diagonal entries to the reference ground.

Type:

Mutual matrix

S_PARAMETERS = 's_parameters'#

Complex scattering matrix at the reference planes recorded in the provenance.

qpdk.models.datasets.metadata.RESERVED_COLUMNS = ('run_id', 'status', 'quantity', 'row', 'col', 'value', 'value_imag', 'unit')#

Results-table columns that are not sweep parameters.

qpdk.models.datasets.metadata.SCHEMA_VERSION = 1#

Version of the metadata and results-table schema implemented here.

Polars results table of a FEM extraction dataset.

The table is long-format: one row per parameter point, quantity, and matrix entry.

Column

Type

Meaning

run_id

String

Identity of the solver run that produced the row.

status

String

ok, failed, or not_converged (see RunStatus).

<axis>

Float64

One column per metadata axis, in the axis’ unit.

<variant>

String

One column per metadata variant.

quantity

String

Name of a metadata quantity.

row

String

Row terminal label; null for scalar quantities.

col

String

Column terminal label; null for scalar quantities.

value

Float64

Value (real part for complex quantities); null unless ok.

value_imag

Float64

Imaginary part; null for real quantities.

unit

String

SI unit; must equal the metadata unit of the quantity.

The DatasetMetadata is stored inside the files (see qpdk.models.datasets.store), so a dataset is self-describing and nothing but its Parquet parts or Delta table.

        flowchart LR
    A["Python generator: metadata + grid"] --> B["Palace solves"]
    B --> C["Validated Parquet parts or Delta table"]
    C --> D["Lazy scan: filter + select"]
    D --> E["Dense grid for one quantity and variant"]
    E --> F["JAX interpolation"]
    F --> G["SAX circuit model"]
    
qpdk.models.datasets.table.DATASETS_PATH = PosixPath('/home/runner/work/quantum-rf-pdk/quantum-rf-pdk/qpdk/models/datasets/data')#

Curated datasets shipped with qpdk.

class qpdk.models.datasets.table.Dataset(location, metadata=None, *, delta=False, version=None, storage_options=None)[source]#

A FEM extraction dataset: Parquet parts or a Delta table with its metadata.

Parameters:
  • location (Path | str) – Directory of Parquet parts, the name of a dataset shipped in DATASETS_PATH, or with delta=True a Delta table path or object-store URI such as gs://bucket/dataset.

  • metadata (DatasetMetadata) – Metadata for a new dataset. For an existing one it is read from the files and, if given, must match.

  • delta (bool) – Store results as a Delta Lake table (delta extra).

  • version (int | None) – Delta table version to read; a pinned dataset is read-only.

  • storage_options (dict[str, str] | None) – Object-store options (credentials, endpoints) for a Delta URI. Never written to the dataset.

Raises:
append(frame, *, part=None)[source]#

Validate frame together with the stored results, then store it.

Stored rows are never rewritten. Duplicates against earlier results are rejected, so re-running a sweep cannot silently double a point. Parquet parts take one writer at a time; a Delta append is one atomic commit and the first append creates the table.

Returns:

a part path, or a Delta URI and version.

Return type:

What was written

Raises:

DatasetError – If rows duplicate existing entries or mix run identities.

Parameters:
  • frame (pl.DataFrame)

  • part (str | None)

grid(quantity, *, allow_missing=False, **variant)[source]#

Arrange quantity on its grid; see to_grid().

Returns:

The dense grid of quantity.

Raises:

DatasetError – If the selected rows are invalid or variants are ambiguous.

Parameters:
  • quantity (str)

  • allow_missing (bool)

  • variant (str)

Return type:

Grid

scan()[source]#

Lazily scan the results with the metadata schema.

Returns:

Lazy frame over all results; not validated.

Return type:

pl.LazyFrame

property table: pl.DataFrame#

The full results table, validated against the metadata.

Raises:

DatasetError – if the stored results do not match the schema.

exception qpdk.models.datasets.table.DatasetError(dataset, problems)[source]#

The dataset is inconsistent with its metadata or cannot be gridded.

Parameters:
Return type:

None

class qpdk.models.datasets.table.Grid(dataset, quantity, axes, coords, terminals, variant, values)[source]#

A quantity on a complete rectilinear grid, ready for interpolation.

values has shape (*axis sizes, *component shape) where the component shape is (n_terminals, n_terminals) for matrix quantities and () for scalars. Axes follow metadata order with ascending coords, terminals follow terminals. Missing or failed points are NaN, never zero.

Parameters:
property axis_names: tuple[str, ...]#

Axis names in tensor order.

property domain: dict[str, tuple[float, float]]#

Validated domain of each axis; defaults to the span of its grid values.

class qpdk.models.datasets.table.RunStatus(*values)[source]#

Outcome of a solver run.

qpdk.models.datasets.table.schema(metadata)[source]#

Column schema of the results table for metadata.

Returns:

The Polars schema.

Parameters:

metadata (DatasetMetadata)

Return type:

pl.Schema

qpdk.models.datasets.table.to_grid(frame, metadata, quantity, *, allow_missing=False, **variant)[source]#

Arrange one quantity of one variant on its complete rectilinear grid.

The grid of each axis is the sorted set of values in the selected rows, and every combination of them must be present. The layout is deterministic: axes in metadata order with ascending values, then row and column terminals in metadata order.

Parameters:
  • frame (pl.DataFrame) – Validated results table.

  • metadata (DatasetMetadata) – Dataset metadata.

  • quantity (str) – Quantity name.

  • allow_missing (bool) – Return NaN for missing or failed points instead of raising.

  • **variant (str) – Value for every metadata variant; discrete variants are selected explicitly and never interpolated.

Returns:

The dense grid of quantity.

Raises:

DatasetError – if the selection is ambiguous or empty, a validated domain leaves the grid, or the grid is incomplete.

Return type:

Grid

qpdk.models.datasets.table.validate(frame, metadata)[source]#

Check a results table against its metadata.

Detects wrong columns, unknown quantities or statuses, unit mismatches, terminals outside the declared ones, non-finite values, inconsistent runs, duplicate points, and missing or spurious values. Grid completeness is checked per selection by to_grid().

Raises:

DatasetError – listing every problem found.

Parameters:
Return type:

None

Generate a dataset by sweeping a solver over a rectilinear parameter grid.

Every dataset, synthetic or produced by a FEM solver, is built the same way: a solve function maps one parameter point to its quantities, and sweep() runs it over the grid and lays the results out as the long-format table of qpdk.models.datasets.table. write() then stores the table with its metadata.

type qpdk.models.datasets.generate.Solve = Callable[[...], Mapping[str, Array | ndarray | bool | number | bool | int | float | complex | TypedNdArray] | None]#

Solver for one point: keyword axis and variant values in, quantities out.

Matrix quantities are (n, n) arrays in terminal order, scalars are numbers, and None records the run as failed.

qpdk.models.datasets.generate.merge(parts, output, *, grid, variants=None)[source]#

Publish independently generated shards after checking the entire sweep.

Workers write separate dataset directories. Only this single coordinator writes the final directory. Metadata must match, points must occur exactly once, and every requested point and quantity must have successful results.

Returns:

The complete published dataset.

Raises:

ValueError – If shards are absent, disagree, overlap, fail, or do not cover the requested grid and variants.

Parameters:
Return type:

Dataset

qpdk.models.datasets.generate.partition_grid(grid, *, shard, shards)[source]#

Select one disjoint rectangular shard of a parameter grid.

Split the longest axis in round-robin order. Each shard remains a complete Cartesian grid, so it can be generated and checked independently. Use the same grid and shard count for every worker; shard indices start at zero. Scheduling does not change geometry, solver inputs or run identities.

Returns:

A grid for one worker, without modifying the input.

Raises:

ValueError – If the grid is empty, an axis is empty, or shard settings would create an empty worker or an invalid index.

Parameters:
Return type:

dict[str, list[float]]

qpdk.models.datasets.generate.sweep(metadata, solve, grid, variants=None)[source]#

Run solve at every point of grid × variants.

Each point gets a run_id derived from the dataset name and the point, so re-running a sweep reproduces the same identities.

Parameters:
Returns:

Long-format results table, not yet validated or stored.

Raises:

ValueError – If the grid or variants differ from metadata, a quantity has the wrong shape, or a real quantity contains imaginary values.

Return type:

pl.DataFrame

qpdk.models.datasets.generate.write(location, metadata, frame)[source]#

Replace the dataset at location with frame as one part.

Meant for regenerating a curated dataset from scratch; use append() to grow an existing one. The new part is written to a sibling staging directory and swapped in only once it is complete, so a failure leaves the existing dataset untouched.

Returns:

The written dataset.

Raises:
  • FileExistsError – if location holds anything other than dataset parts.

  • OSError – if publishing fails; the existing dataset is then kept.

Parameters:
Return type:

Dataset

Where the results table of a dataset is stored.

The heavy lifting is left to the dependencies: Polars reads and writes Parquet, including its key-value metadata, and deltalake provides atomic, versioned, concurrent appends on local disks and object stores. The two stores here only add what those do not: carrying the DatasetMetadata in the files, and a readable error for an unresolved Git LFS pointer.

ParquetParts

A local directory of append-only *.parquet parts, each carrying the metadata in its footer. The default, used for curated datasets in Git LFS. One writer at a time; use a Delta table for concurrent writers.

DeltaStore

A Delta Lake table at a local path or an object-store URI such as gs://, s3://, or az://. Each append is one atomic commit and a new table version, so a team can write sweep results straight to a cloud bucket and pin a model to an exact version. The metadata is kept on the value column of the table schema, since Delta tables accept no free-form table properties. Requires the delta extra.

Credentials are passed as storage_options, in the keys Polars and deltalake accept (for example aws_endpoint_url, google_service_account, azure_storage_account_name), or come from the environment and application-default credentials of the cloud SDKs.

class qpdk.models.datasets.store.DeltaStore(uri, *, storage_options=None, version=None)[source]#

Results stored as a Delta Lake table.

Every append is one atomic commit and creates a new table version. Reading a pinned version reproduces exactly the rows a model was built from.

Concurrent appends are committed atomically on local disks, Google Cloud Storage, and Azure. On S3, pass {"conditional_put": "etag"} (S3 and most S3-compatible stores support it) or configure a deltalake locking provider. Validation runs before each commit and again on every read, so a duplicate committed by a racing writer is reported rather than silently used.

Parameters:
  • uri (str | Path) – Local path or object-store URI of the table, e.g. gs://bucket/dataset.

  • storage_options (dict[str, str] | None) – Object-store options, passed to Polars and deltalake.

  • version (int | None) – Table version to read; None reads the latest. A pinned store is read-only.

metadata()[source]#

Metadata stored in the table schema, or None if there is no table.

Returns:

The table’s dataset metadata.

Raises:

ValueError – if the table has no dataset metadata.

Return type:

DatasetMetadata | None

scan(schema)[source]#

Scan the table, cast to schema.

Returns:

Lazy frame over the table.

Raises:

FileNotFoundError – if there is no table.

Parameters:

schema (pl.Schema)

Return type:

pl.LazyFrame

property table_version: int#

Version of the table that scan() reads.

write(frame, metadata, part)[source]#

Commit frame as one append, creating the table if needed.

New tables are partitioned by quantity and variants, which are selected exactly and never interpolated, and carry metadata on the value column. Initial creation is exclusive; retry an append if another writer creates the table first.

Returns:

The URI and the committed table version.

Raises:

ValueError – if the store is pinned to a version.

Parameters:
Return type:

str

exception qpdk.models.datasets.store.LFSPointerError[source]#

A results file is an unresolved Git LFS pointer instead of Parquet data.

class qpdk.models.datasets.store.ParquetParts(location)[source]#

Local, append-only directory of Parquet parts.

Parameters:

location (str | Path) – Directory of the parts.

property files: list[Path]#

Part files in a deterministic order.

metadata()[source]#

Metadata stored in the parts, or None if there are none yet.

Returns:

The metadata shared by every part.

Raises:

ValueError – if the parts disagree or a part has no metadata.

Return type:

DatasetMetadata | None

scan(schema)[source]#

Scan all parts with schema.

Returns:

Lazy frame over every part.

Raises:

FileNotFoundError – if there are no parts.

Parameters:

schema (pl.Schema)

Return type:

pl.LazyFrame

write(frame, metadata, part)[source]#

Publish frame as a new part without ever replacing an existing one.

Returns:

Path of the written part.

Raises:
Parameters:
Return type:

str

Jittable N-dimensional lookup of gridded FEM datasets.

Data is loaded, validated, and arranged once, outside tracing (see qpdk.models.datasets.Dataset.grid()). The interpolation itself is jax.scipy.interpolate.RegularGridInterpolator, so calling the lookup under jax.jit(), jax.vmap(), or jax.grad() involves no Polars, file I/O, or data-dependent Python control flow.

GridInterpolator only adds what a dataset lookup needs on top: keyword arguments named after the axes, matrix-valued results, axes with a single grid value, and NaN outside the validated domain rather than just outside the grid.

class qpdk.models.datasets.interpolation.GridInterpolator(grid, *, out_of_range='nan')[source]#

Multilinear interpolation of a Grid.

Call with one keyword argument per axis. Arguments broadcast against each other; the result has shape (*broadcast shape, *component shape).

Parameters:
  • grid (Grid) – Complete grid from qpdk.models.datasets.Dataset.grid().

  • out_of_range (OutOfRange) – "nan" (default) returns NaN for any query outside the validated domain of an axis. "clip" evaluates at the nearest point of the domain; use it only where a deliberate clamp is acceptable.

Example

>>> grid = Dataset("plate_capacitor_palace").grid(
...     "maxwell_capacitance", cross_section="cpw"
... )
>>> c = GridInterpolator(grid)(length=100.0, width=10.0, gap=5.0)  # (2, 2) in F
in_domain(**params)[source]#

Check which query points lie inside the validated domain.

Returns:

Boolean mask of the broadcast shape.

Parameters:

params (ArrayLike)

Return type:

Array

Conversions and physical checks for capacitance matrices.

Matrices have terminals on the last two axes, so leading batch axes from interpolated lookups pass straight through.

exception qpdk.models.datasets.capacitance.NonPhysicalMatrixError(problems)[source]#

A Maxwell capacitance matrix violates a physical property.

Parameters:

problems (list[str])

Return type:

None

qpdk.models.datasets.capacitance.check_maxwell(maxwell, *, rtol=1e-09)[source]#

Check that a Maxwell capacitance matrix is physical.

Checks symmetry, positive diagonal, non-positive off-diagonal entries, and diagonal dominance (non-negative capacitance to ground), each up to rtol relative to the largest diagonal entry of each matrix. Non-finite entries are reported before any other check, since they make the tolerance meaningless. Runs eagerly on concrete arrays; it is a check, not a model, and is not jittable.

Raises:

NonPhysicalMatrixError – Listing every violated property.

Parameters:
Return type:

None

qpdk.models.datasets.capacitance.differential_capacitance(maxwell, positive=0, negative=1)[source]#

Return the capacitance seen when moving charge between two floating islands.

Put charge \(+Q\) on positive and \(-Q\) on negative. This returns \(Q/(V_\text{positive}-V_\text{negative})\), including the effect of capacitances to ground. Neither island is held at ground voltage. For two islands, their mutual capacitance is in parallel with the series combination of their two capacitances to ground.

Other named conductors float with no added charge; the reference ground stays at zero voltage. To ground a named conductor, first remove its row and column. Add any junction capacitance separately in parallel. The calculation is \((d^{\text{T}} C^{-1} d)^{-1}\), where \(d = e_\text{positive} - e_\text{negative}\); see the node-charge energy in [VD17], section 2.1.10.

Parameters:
  • maxwell (ArrayLike) – Maxwell capacitance matrix in F, with optional leading batch axes.

  • positive (int) – First terminal index; a static Python integer when using JIT.

  • negative (int) – Second terminal index; a static Python integer when using JIT.

Returns:

Differential capacitance in F, preserving leading batch dimensions.

Raises:

ValueError – If the terminal indices are equal or outside the matrix.

Return type:

Array

qpdk.models.datasets.capacitance.maxwell_to_mutual(maxwell)[source]#

Convert a Maxwell capacitance matrix to the mutual (SPICE-style) matrix.

Off-diagonal entries become the branch capacitances \(C_{ij}^m = -C_{ij}\), and diagonal entries become the capacitance of each terminal to the reference ground, \(C_{ii}^m = \sum_j C_{ij}\).

Returns:

Mutual capacitance matrix, same shape as maxwell.

Parameters:

maxwell (ArrayLike)

Return type:

Array

Generic SAX models from stored N-port scattering or Maxwell matrices.

qpdk.models.datasets.models.capacitance_model(dataset, quantity='maxwell_capacitance', **variants)[source]#

Return a lumped N-port SAX model from a Maxwell capacitance dataset.

Continuous geometry axes are passed by name; discrete variants are fixed here. f is in Hz and z_ref is a common real port impedance in ohms. The admittance is \(Y = j 2 \pi f C\); all terminals reference the dataset ground. This model includes terminal-to-ground capacitances and mutual branches, with no distributed propagation or inductance. Unspecified geometry axes use their middle stored coordinates.

Returns:

SAX model with the dataset terminal names.

Raises:

ValueError – If the quantity is not a physical real Maxwell matrix.

Parameters:
Return type:

SDictModel

qpdk.models.datasets.models.junction_model(dataset, *, junction, inductance, junction_capacitance=0.0, terminal_groups=None, **variants)[source]#

Build a small-signal circuit from electrode capacitances and one junction.

The dataset supplies capacitances between electrodes and to ground. Between the two junction ports, this adds an inductor in parallel with an optional junction capacitor. The inductor approximates the Josephson junction for small oscillations around its operating point; it does not model nonlinear dynamics or qubit anharmonicity. See [VD17], sections 2.1.6 and 4.1, for this linearization and the Josephson element.

The branch admittance is \(Y_\text{J}=1/(j\omega L_\text{J})+j\omega C_\text{J}\). Its inductance and capacitance are explicit inputs, since an electrostatic dataset cannot determine the Josephson inductance. Only add junction capacitance that is not already included in the dataset.

terminal_groups optionally connects electrodes with ideal wires. For example, (("island", "top"), ("ring",)) joins island and top and leaves ring separate. Each group’s first name becomes its SAX port. Every dataset terminal must appear exactly once. This applies \(B^{\text{T}} C B\), with \(B\) mapping group voltages to electrodes, and retains capacitance to ground. The wires add no field or inductance. All SAX ports reference dataset ground, and frequency is in Hz.

Parameters:
  • dataset (Dataset | Path | str) – Dataset or path containing real Maxwell capacitance matrices in F.

  • junction (tuple[str, str]) – Names of the two ports connected by the junction, after grouping.

  • inductance (float) – Positive small-signal Josephson inductance in H.

  • junction_capacitance (float) – Additional parallel junction capacitance in F.

  • terminal_groups (tuple[tuple[str, ...], ...] | None) – Electrodes to connect together; defaults to separate ports.

  • **variants (str) – Discrete dataset selections.

Returns:

Jittable small-signal SAX model, with geometry in dataset axis units.

Raises:

ValueError – If terminals, grouping, matrix type or explicit inputs are invalid.

Return type:

SDictModel

qpdk.models.datasets.models.s_parameters_model(dataset, quantity='s_parameters', *, frequency_axis='frequency', **variants)[source]#

Load stored N-port S-parameters and return a jittable SAX lookup.

f is in Hz and supplies frequency_axis. Other continuous grid axes are passed by name; discrete variants are fixed when creating the model. Terminals become port names. Reference impedances and planes are those of the stored simulation, without renormalization. Out-of-domain values are NaN. Multilinear interpolation does not enforce losslessness between points. Unspecified geometry axes use their middle stored coordinates.

Returns:

SAX model with the dataset terminal names.

Raises:

ValueError – If the quantity or frequency axis has the wrong convention.

Parameters:
Return type:

SDictModel