Skip to content

Design studies

A design study varies a Simulation over a set of parameters. Use it for sweeps, process corners, Monte Carlo or Latin hypercube samples, and a yield count. The same local and cloud batch runners execute its jobs.

The study makes no physics claim of its own. It is as trustworthy as each simulation in it. Check mesh convergence at a representative point before trusting a sweep, and treat a yield figure like any other result.

Describe the parameters

ph.study.Span defines a numeric interval with low and high. nominal sets the design point used by corners and defaults to the midpoint. For a floating span, sigma makes the random samplers draw a normal distribution around nominal, truncated to the interval. Without sigma, they draw uniformly. Set integer=True for an integer span; its random draws are uniform over the integers. ph.study.Choices lists allowed values of one type family and uses the first value as its default nominal value.

The name suffix supplies a display unit when unit is omitted. For example, width_um gets "um"; you can also pass unit= explicitly. Names must be Python identifiers of at most 64 characters, without a leading underscore, and cannot be keywords. Table column names such as status, sample_name, and quote_usd are reserved.

import photonhub as ph

width = ph.study.Span("width_um", 0.4, 0.6, nominal=0.5, sigma=0.03)
mesh = ph.study.Choices("mesh_um", [0.1, 0.08], nominal=0.1)
parameters = [width, mesh]

Choose the samples

ph.study.grid takes a full factorial grid. num_points sets the number of levels per span, or maps each span name to its own count; each Choices uses all its values. ph.study.points takes explicit rows that name every parameter. ph.study.corners starts with the nominal row. Its "all" style then combines every extreme, while "one_at_a_time" changes one parameter at a time. ph.study.monte_carlo draws independent points and ph.study.latin_hypercube places one point in each probability stratum per parameter. Both require a seed so you can reproduce the rows. Each sampler returns a ph.study.Samples value.

import photonhub as ph

width = ph.study.Span("width_um", 0.4, 0.6, nominal=0.5)
parameters = [width]
grid_samples = ph.study.grid(parameters, num_points=3)
chosen = ph.study.points(parameters, [{"width_um": 0.47}])
all_corners = ph.study.corners(parameters, style="all")
single_corners = ph.study.corners(parameters, style="one_at_a_time")
random_samples = ph.study.monte_carlo(parameters, num_samples=8, seed=7)
stratified = ph.study.latin_hypercube(parameters, num_samples=8, seed=7)
samples = grid_samples + chosen
print(len(samples))
for params in samples:
    print(params)

You can add sample sets only when they have the same parameter definitions. len(samples) counts rows, and iteration yields parameter dictionaries. Repeated rows remain in the table. The samplers refuse more than 100,000 rows (ph.study.sampling.MAX_SAMPLES), so check a large grid's size before building it.

Build and measure

Write build(params) -> Simulation to make one scene for each row. An optional measure(result) -> {name: float} reads scalar values from its RunResult. This example adapts the Quickstart's small dipole scene. It varies the width of one box and reads the first bin of a power monitor. The short run and coarse mesh check the workflow only.

import photonhub as ph

f0 = 1.934e14

def build(params):
    return ph.Simulation(
        size_um=(3.0, 3.0, 3.0),
        grid=ph.UniformMesh(dl_um=0.1),
        run=ph.RunSpec(n_steps=200),
        pml_num_layers=6,
        structures=[ph.Structure(
            geometry=ph.Box(
                center_um=(1.5, 1.5, 1.6),
                size_um=(params["width_um"], 0.2, 0.8),
            ),
            medium=ph.Medium(permittivity=2.25),
        )],
        sources=[ph.PointDipole(
            center_um=(1.5, 1.5, 1.5),
            polarization="Ez",
            source_time=ph.GaussianPulse(freq0_hz=f0, fwidth_hz=0.5 * f0),
        )],
        monitors=[ph.PowerMonitor(
            name="flux", axis="z", position_um=2.1, freqs_hz=(f0,),
        )],
    )

def measure(result):
    return {"flux_w": float(result["flux"].isel(f=0))}

Plan and run locally

ph.study.plan(samples, build, path=...) builds and hashes the rows, checks the directory, and returns a StudyPlan reporting the jobs and reused work. It runs no solver. ph.study.run executes one local ph.Batch for the unique jobs and returns an xarray.Dataset. Continue in the same Python session:

import photonhub as ph

width = ph.study.Span("width_um", 0.4, 0.6, nominal=0.5)
samples = ph.study.grid([width], num_points=3)
study_plan = ph.study.plan(samples, build, path="studies/width")
print(study_plan)
table = ph.study.run(samples, build, path="studies/width", measure=measure)

The directory contains study.json for the pinned parameter definitions, samples.json for the latest rows, jobs.json for job and measurement records and append-only submission history, and table.json for the latest table. Local solver output lives in runs/<job>/. A job name combines a sample name and the first eight digits of its spec hash when that job first runs. ph.spec_sha256 identifies the canonical wire document. Identical simulations run once and share output, even when multiple samples produce them. If two simulations differ only in client state, such as a frame origin, each sample reads that shared output in its own frame when measured.

By default, a completed job prints one progress line to stderr. Pass quiet=True to suppress those lines, or progress=callback to receive study events. Set max_workers to bound concurrent local jobs; it defaults to 1.

Resume and retry

Run the same call with the same path to reuse succeeded jobs and stored measurements. Failed jobs and failed measurements stay recorded until you pass retry_failed=True. A failed measurement is measured again from its stored output without rerunning the solver. A temporary cloud read error while measuring leaves that row pending for the call and stores no measurement. The directory pins the parameter definitions. Use a new path for a different parameter set. You can add measure after a run without it; succeeded jobs are measured from stored output without running the solver again. Only one process can run a study in the same directory at a time.

Run on the cloud

Set cloud=True to use ph.cloud.Batch. Configure access as described in Cloud GPU runs. Every new job is quoted before the first submission. max_usd limits each job and defaults to $5, as for other cloud calls. max_usd_total limits the study and also defaults to $5. It includes every prior submitted quote from the same directory, including ambiguous submissions and failed jobs retried on either backend. If either limit is exceeded during initial quoting, the call submits nothing new. Before each submission, quotes near expiry are refreshed and checked against the remaining budget. The refresh margin is one quarter of the remaining lifetime when the quote is received, capped at 60 seconds and fixed for that quote. For a quote received with 60 seconds left, the margin is 15 seconds. A refresh refusal leaves that row pending; earlier jobs may already have been charged. A submitted job keeps its id after a polling or download error; rerun with cloud=True to collect it without another submission. Existing submitted or timed-out jobs are collected before new jobs are quoted and submitted. A local call leaves their statuses unchanged.

# CLOUD ONLY, BILLABLE: submit only with credentials and spend authorization.
import photonhub as ph

# Select the available cloud GPU and the build from your closed-test guide.
GPU = "gpu:" + ph.cloud.gpus()[0]["id"]
SOLVER = "SOLVER_BUILD_FROM_YOUR_GUIDE"

table = ph.study.run(
    samples, build, path="studies/width-cloud", measure=measure,
    cloud=True, device=GPU, solver=SOLVER,
    max_usd=5.0, max_usd_total=5.0, wait_timeout_s=300.0,
)

wait_timeout_s bounds the wait. A row marked timed_out still has a running cloud job and can be collected by rerunning with cloud=True. Cloud max_workers defaults to 1 because the managed service may run one job per account at a time, so larger studies run in waves. The selected solver is recorded with each job and submission; resume with the same solver value or use a new study directory for a different build. A definite submit refusal leaves its rows pending with the service error. If every new submission is refused, the call saves the table and raises CloudError. If the submit response is lost, the row is failed with an unknown outcome and its quote stays in the history. Check ph.cloud.list_jobs() before using retry_failed=True, which resubmits it. If the service no longer knows a submitted job, the row becomes failed.

Read the table

The result is an xarray.Dataset with dimension sample and coordinate sample_name. It has one column per parameter and measured scalar, plus status, error, job, job_id, spec_sha256, solver_version, backend, and quote_usd. job_id and quote_usd record cloud information when available. Status meanings are:

Status Meaning
succeeded The job ran and the measurement, if given, returned scalars.
failed The solver or service reported a failure, or a cloud submission has an unknown outcome.
measure_failed The job ran, but the measurement failed or returned a value that was not a finite real number.
build_failed build raised for that row, so no job exists.
submitted A cloud job was submitted and has not been collected yet.
timed_out The cloud wait ended; the job keeps running for later collection.
pending The row has not run after a definite submit refusal, or a temporary cloud read prevented measurement on this call.

Filter the completed rows with xarray. ph.study.save writes the table as JSON, and ph.study.load reads it back. Passing a study directory to load reads its table.json:

import photonhub as ph

completed = table.where(table["status"] == "succeeded", drop=True)
ph.study.save(table, "studies/width-copy.json")
restored = ph.study.load("studies/width")
print(completed.sizes["sample"], restored.sizes["sample"])

Estimate yield

ph.study.yield_fraction(table, {"name": (low, high)}) counts rows whose measured scalar is within the inclusive bounds. Use None for an open bound. With incomplete="exclude" (the default), rows that did not succeed or lack a required measurement are omitted from the denominator. With incomplete="fail", they count as failures.

import photonhub as ph

estimate = ph.study.yield_fraction(
    table, {"flux_w": (0.0, None)}, incomplete="exclude",
)
print(estimate.fraction, estimate.interval_95)
print(estimate.num_pass, estimate.num_evaluated,
      estimate.num_samples, estimate.num_incomplete)

YieldEstimate reports those counts and a Wilson 95% interval. The estimate covers only the sampled variation and the accuracy of the simulations behind it. Check convergence and whether the sampling distribution represents the variation you intend to assess.

Limits

The samplers are fixed designs. Adaptive and Bayesian search are not yet included; a later search can feed rows to ph.study.run. Stored measurements are reused, so changing measure needs a new directory for now.