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.