Skip to content

Cloud client (ph.cloud)

The client for invitation-gated remote GPU execution. Configure credentials, preflight and price a job, run it, and read the same RunResult back. See the cloud guide.

Configuration

configure

configure(
    api_key: Optional[str] = None,
    url: Optional[str] = None,
    *,
    cache_dir=None,
    poll_interval_s: float = 2.0,
    poll_backoff_max_s: float = 15.0,
    request_timeout_s: float = 30.0,
    allow_insecure_http: bool = False,
    max_bundle_download_bytes: int = DEFAULT_MAX_COMPRESSED_BYTES,
    max_bundle_extract_bytes: int = DEFAULT_MAX_EXPANDED_BYTES,
    max_bundle_members: int = DEFAULT_MAX_MEMBERS,
) -> CloudConfig

Set the active cloud configuration. Returns it for inspection.

get_config

get_config() -> CloudConfig

The active config, building one from the environment on first use.

reset

reset() -> None

Clear the active config (mainly for tests).

CloudConfig dataclass

CloudConfig(
    url: str,
    api_key: str,
    cache_dir: Path,
    poll_interval_s: float = 2.0,
    poll_backoff_max_s: float = 15.0,
    request_timeout_s: float = 30.0,
    allow_insecure_http: bool = False,
    max_bundle_download_bytes: int = DEFAULT_MAX_COMPRESSED_BYTES,
    max_bundle_extract_bytes: int = DEFAULT_MAX_EXPANDED_BYTES,
    max_bundle_members: int = DEFAULT_MAX_MEMBERS,
)

url instance-attribute

url: str

api_key class-attribute instance-attribute

api_key: str = field(repr=False)

cache_dir instance-attribute

cache_dir: Path

poll_interval_s class-attribute instance-attribute

poll_interval_s: float = 2.0

poll_backoff_max_s class-attribute instance-attribute

poll_backoff_max_s: float = 15.0

request_timeout_s class-attribute instance-attribute

request_timeout_s: float = 30.0

allow_insecure_http class-attribute instance-attribute

allow_insecure_http: bool = False

max_bundle_download_bytes class-attribute instance-attribute

max_bundle_download_bytes: int = (
    DEFAULT_MAX_COMPRESSED_BYTES
)

max_bundle_extract_bytes class-attribute instance-attribute

max_bundle_extract_bytes: int = DEFAULT_MAX_EXPANDED_BYTES

max_bundle_members class-attribute instance-attribute

max_bundle_members: int = DEFAULT_MAX_MEMBERS

Preflight and pricing

preflight

preflight(
    sim,
    *,
    device: str = "gpu",
    solver=None,
    max_usd: float = DEFAULT_MAX_USD,
) -> CloudPreflight

Get a device/solver-bound quote and enforce the spend/balance limit.

This call does not submit a job. max_usd defaults to the beta's $5 credit ceiling. The account must report available_usd (or exact available_micros); falling back to total balance would be unsafe because funds reserved by active jobs are not spendable. When the quote reports cells_per_axis and dt_s, they must match the grid sim realizes locally, before any price check; a quote that omits them warns. This checks the quoted grid only: the solver image that runs the job is versioned separately from the service that quotes it.

device and solver default as in run; the returned device and solver are the resolved values the quote is bound to, to pass with its quote_id.

CloudPreflight dataclass

CloudPreflight(
    device: str,
    solver: str | None,
    max_usd: float,
    quote_usd: float,
    available_usd: float,
    remaining_usd: float,
    quote_id: str,
    quote: dict[str, Any],
)

A quote that passed the caller's ceiling and available-balance checks.

quote_id is intentionally omitted from repr so displaying this object in a notebook does not publish the opaque accepted-quote token. quote retains the service response for audit fields such as cell count, step count, rate, and expiry.

device instance-attribute

device: str

solver instance-attribute

solver: str | None

max_usd instance-attribute

max_usd: float

quote_usd instance-attribute

quote_usd: float

available_usd instance-attribute

available_usd: float

remaining_usd instance-attribute

remaining_usd: float

quote_id class-attribute instance-attribute

quote_id: str = field(repr=False)

quote class-attribute instance-attribute

quote: dict[str, Any] = field(repr=False)

summary

summary() -> str

Format the accepted quote, spend limit, and available-balance checks.

Include expiry when the service supplied it. The quote identifier is omitted. This method makes no request and reserves no funds.

estimate

estimate(sim, *, device='gpu', solver=None) -> dict

Server-side quote bound to sim, device, and solver ref.

As in preflight, a quote whose cells_per_axis or dt_s differ from the grid sim realizes locally raises CloudError instead of returning its price, and a quote that omits them warns. The check belongs here because the quote_id can be passed to run or submit, which receive only the id and so cannot check the grid themselves. It covers the quoted grid only: the solver image that runs the job is versioned separately from the service that quotes it.

device and solver default as in run: the service's only GPU, and on a GPU the commit of the locally installed solver. A quote_id from here binds to a submission made with the same arguments. Defaults resolve to the same values only while the service's GPU menu and the locally installed solver are unchanged; if either changed in between, the service refuses the submission because it no longer matches the quote, at no charge. To be sure, pass the resolved values, which preflight returns beside its quote_id.

gpus

gpus() -> list

The curated menu of GPUs you can run on, each a dict like {"id": ..., "vendor": ..., "arch": ..., "gpu_mem_gb": ...}. Pass an id to ph.cloud.run(sim, device="gpu:<id>"); bare device="gpu" (the default) uses the only entry when the menu lists exactly one, and is refused client-side when it lists several. The platform manages which providers back each entry, that stays an internal detail.

Run

run

Cloud run entry points, the prime directive: ph.cloud.submit returns the same Job as the local path, so job = ph.cloud.submit(sim); data = job.result() reads identically whether local or cloud, and a server-side failure surfaces as the same SolverRunError.

Where the two differ, it is to keep the spend bounded: every submission is quoted first and refused above max_usd (default $5) unless the caller opts out with max_usd=None; job.cancel() asks the service to cancel the job; and wait_timeout_s bounds only the local wait, never the job.

ProgressCb module-attribute

ProgressCb = Optional[Callable[[dict], None]]

DEFAULT_MAX_USD module-attribute

DEFAULT_MAX_USD = _DefaultCeiling(5.0)

CloudJobTimeout

CloudJobTimeout(job_id: str, timeout: float)

Bases: TimeoutError

The client stopped waiting (wait_timeout_s) while the service job is still running, and billing.

job_id instance-attribute

job_id = job_id

wait_timeout_s instance-attribute

wait_timeout_s = timeout

timeout instance-attribute

timeout = timeout

run

run(
    sim,
    *,
    max_usd: Optional[float] = DEFAULT_MAX_USD,
    name=None,
    device: Optional[str] = "gpu",
    solver=None,
    progress: ProgressCb = None,
    wait_timeout_s: Optional[float] = None,
    quote_id: Optional[str] = None,
) -> RunResult

Submit sim to the cloud under a dollar ceiling and block until its result is ready. Returns a RunResult; raises SolverRunError if the run fails, CloudError for transport, auth or result-transfer problems and for a refused quote.

Spend-safe by default. The job is quoted first and submitted bound to that quote only when it is within max_usd (default $5) and the account's available balance, and when the quote's grid matches the one sim realizes here; otherwise nothing is submitted (see preflight). Pass the quote_id of a quote you already accepted (from preflight or estimate) to bind it as given, without quoting again. max_usd=None is the explicit opt-out: an unquoted submission with no ceiling.

wait_timeout_s bounds only how long this call waits. When it runs out it raises CloudJobTimeout and the service job keeps running, and billing: ask the service to cancel it with ph.cloud.cancel(job_id) or collect it with resume. timeout is its old name and warns. device defaults to "gpu" as in every cloud call, which becomes the service's GPU when gpus lists exactly one; "gpu:<id>" picks one. On a GPU, solver defaults to the commit of the locally installed solver (photonhub which-solver --info), which must be a release solver reporting its full 40-character commit: a source build's short id is refused before any request. Pass a commit to pin another build. The service runs only builds it has approved for the GPU. The quote and the submission carry the same device and solver. The check covers the quoted grid only: the solver image that runs the job is versioned separately from the service that quotes it.

run_quoted

run_quoted(
    sim,
    *,
    max_usd: float = DEFAULT_MAX_USD,
    name=None,
    device: str = "gpu",
    solver=None,
    progress: ProgressCb = None,
    wait_timeout_s: Optional[float] = None,
) -> RunResult

Preflight and submit one quote-bound job under a hard dollar ceiling.

run does the same by default; this form has no unquoted opt-out (max_usd must be a number) and takes no caller quote: it verifies a finite server quote, that the quote's cells_per_axis and dt_s match the grid sim realizes locally when the service reports them (a quote that omits them warns and is still submitted), max_usd (default $5), and the account's available balance before binding that quote id to the submission. A quote for a different grid, from a service whose SDK resolves the document differently, is refused with CloudError; the solver image that runs the job is versioned separately, so this checks the quote, not the run. The service remains the final authority for quote expiry and concurrent balance changes. device and solver default as in run.

submit

submit(
    sim,
    *,
    max_usd: Optional[float] = DEFAULT_MAX_USD,
    name=None,
    device: Optional[str] = "gpu",
    solver=None,
    progress: ProgressCb = None,
    wait_timeout_s: Optional[float] = None,
    quote_id: Optional[str] = None,
    _preflighted_wire: Optional[dict] = None,
    _defaults_resolved: bool = False,
) -> Job

Submit sim and return the same Job handle type as local ph.submit once the service accepts the job. Polling and download continue in the background; collect with job.result().

Spend-safe by default, exactly as run: quoted first and refused above max_usd (default $5) or the available balance; a quote_id you already accepted is bound as given; max_usd=None is the explicit unquoted opt-out. device and solver default as in run. job.cancel() asks the service to cancel the job and returns its reply; a service that refuses raises CloudError, and the job keeps running. wait_timeout_s bounds only how long job.result() polls: the service job keeps running, and billing, after it runs out. timeout is its old name and warns.

submit_quoted

submit_quoted(
    sim,
    *,
    max_usd: float = DEFAULT_MAX_USD,
    name=None,
    device: str = "gpu",
    solver=None,
    progress: ProgressCb = None,
    wait_timeout_s: Optional[float] = None,
) -> Job

Async form of run_quoted, returning the accepted service id.

resume

resume(
    job_id: str,
    *,
    simulation=None,
    progress: ProgressCb = None,
    wait_timeout_s: Optional[float] = None,
) -> Job

Return a handle that resumes an already-submitted service job.

If the job's result bundle was already downloaded and validated, the handle loads it straight from the local cache, no service round-trip, so a finished run stays loadable offline and after the service ages the job out. Otherwise it resumes polling exactly as before. job.cancel() asks the service to cancel the job; wait_timeout_s bounds only the wait (timeout is its old name and warns).

The result reads in the same absolute units and the same frame as the call that submitted the job: this client stores the wire spec of every job it submits, so the reader knows the simulation and undoes the engine's per-unit-amplitude normalization here too, and it stores beside it the client state the spec does not carry (the user-frame origin of a fitted domain, the half a symmetry plane dropped, declared ports), so coordinates, whole planes and transmission() agree. Both are stored once the service returns the job id, so a job submitted from another machine, through a cache that has since been cleared, or by a submission whose response was lost has neither: pass simulation= then, and whenever you still hold the simulation. It takes precedence over the stored spec, and it must be the simulation that ran: one that differs from the sim.json in the job's bundle, from the stored spec when the bundle has none and that spec is the input the job recorded, or else from the grid its manifest recorded, raises SimulationMismatchError, and the cached result stays. Passing nothing is never refused: where the job's saved document differs from the stored spec, the job reads with what it saved. With neither, the frequency-domain arrays carry the engine's unit-amplitude values and say so in their normalization attr, and the coordinates are the wire's corner frame.

submit

submit(
    sim,
    *,
    max_usd: Optional[float] = DEFAULT_MAX_USD,
    name=None,
    device: Optional[str] = "gpu",
    solver=None,
    progress: ProgressCb = None,
    wait_timeout_s: Optional[float] = None,
    quote_id: Optional[str] = None,
    _preflighted_wire: Optional[dict] = None,
    _defaults_resolved: bool = False,
) -> Job

Submit sim and return the same Job handle type as local ph.submit once the service accepts the job. Polling and download continue in the background; collect with job.result().

Spend-safe by default, exactly as run: quoted first and refused above max_usd (default $5) or the available balance; a quote_id you already accepted is bound as given; max_usd=None is the explicit unquoted opt-out. device and solver default as in run. job.cancel() asks the service to cancel the job and returns its reply; a service that refuses raises CloudError, and the job keeps running. wait_timeout_s bounds only how long job.result() polls: the service job keeps running, and billing, after it runs out. timeout is its old name and warns.

run_quoted

run_quoted(
    sim,
    *,
    max_usd: float = DEFAULT_MAX_USD,
    name=None,
    device: str = "gpu",
    solver=None,
    progress: ProgressCb = None,
    wait_timeout_s: Optional[float] = None,
) -> RunResult

Preflight and submit one quote-bound job under a hard dollar ceiling.

run does the same by default; this form has no unquoted opt-out (max_usd must be a number) and takes no caller quote: it verifies a finite server quote, that the quote's cells_per_axis and dt_s match the grid sim realizes locally when the service reports them (a quote that omits them warns and is still submitted), max_usd (default $5), and the account's available balance before binding that quote id to the submission. A quote for a different grid, from a service whose SDK resolves the document differently, is refused with CloudError; the solver image that runs the job is versioned separately, so this checks the quote, not the run. The service remains the final authority for quote expiry and concurrent balance changes. device and solver default as in run.

submit_quoted

submit_quoted(
    sim,
    *,
    max_usd: float = DEFAULT_MAX_USD,
    name=None,
    device: str = "gpu",
    solver=None,
    progress: ProgressCb = None,
    wait_timeout_s: Optional[float] = None,
) -> Job

Async form of run_quoted, returning the accepted service id.

resume

resume(
    job_id: str,
    *,
    simulation=None,
    progress: ProgressCb = None,
    wait_timeout_s: Optional[float] = None,
) -> Job

Return a handle that resumes an already-submitted service job.

If the job's result bundle was already downloaded and validated, the handle loads it straight from the local cache, no service round-trip, so a finished run stays loadable offline and after the service ages the job out. Otherwise it resumes polling exactly as before. job.cancel() asks the service to cancel the job; wait_timeout_s bounds only the wait (timeout is its old name and warns).

The result reads in the same absolute units and the same frame as the call that submitted the job: this client stores the wire spec of every job it submits, so the reader knows the simulation and undoes the engine's per-unit-amplitude normalization here too, and it stores beside it the client state the spec does not carry (the user-frame origin of a fitted domain, the half a symmetry plane dropped, declared ports), so coordinates, whole planes and transmission() agree. Both are stored once the service returns the job id, so a job submitted from another machine, through a cache that has since been cleared, or by a submission whose response was lost has neither: pass simulation= then, and whenever you still hold the simulation. It takes precedence over the stored spec, and it must be the simulation that ran: one that differs from the sim.json in the job's bundle, from the stored spec when the bundle has none and that spec is the input the job recorded, or else from the grid its manifest recorded, raises SimulationMismatchError, and the cached result stays. Passing nothing is never refused: where the job's saved document differs from the stored spec, the job reads with what it saved. With neither, the frequency-domain arrays carry the engine's unit-amplitude values and say so in their normalization attr, and the coordinates are the wire's corner frame.

Batch

Batch(simulations: Mapping[str, Simulation])

Named simulations submitted as independent cloud jobs.

Construction validates names and simulation types and makes no requests. quote returns local cost estimates. run validates all service quotes and spend limits before submitting jobs in parallel. Successful results and per-name failures share the local BatchResults interface.

simulations instance-attribute

simulations = validated

quote

quote(**kwargs)

Per-name CostEstimate + the batch total (local, deterministic).

run

run(
    *,
    device="gpu",
    solver=None,
    max_workers: int = 4,
    wait_timeout_s: Optional[float] = None,
    max_usd=None,
    max_usd_total=None,
    on_submit=None,
    on_complete=None,
) -> BatchResults

Quote every entry, then submit within its accepted spend limit.

max_usd is required and is either one per-job limit applied to all entries or a mapping whose keys exactly match the batch. All estimates are validated before any job is submitted, including, when the service reports a quote's cells_per_axis and dt_s, that they match the grid its simulation realizes locally (a quote that omits them warns and is still submitted), and each accepted quote id is bound to its corresponding submission. device and solver default as in run (the service's only GPU, and on a GPU the commit of the locally installed solver), resolved once for the whole batch and sent unchanged on every estimate, refresh and submission. wait_timeout_s bounds only how long each entry is waited for: an entry past it is reported as a CloudJobTimeout and its service job keeps running, and billing (timeout is its old name and warns). max_usd_total optionally caps the sum of accepted quotes. A quote above either ceiling in the initial pass refuses the whole batch. Batches larger than service concurrency run in waves: set max_workers to the service's active-job limit (1 for the beta). Immediately before submission, quotes near expiry are refreshed and both ceilings checked again against accepted spend. The refresh margin is one quarter of the remaining lifetime when the quote is received, capped at 60 seconds and fixed for that quote. A missing expires_at means no expiry. A refresh refusal fails only that entry; earlier jobs may already have been charged. on_submit(name, job_id, quote_usd) runs on a worker thread after submission. on_complete(name, result, error) runs on the calling thread as each entry finishes. Submit hooks have their own lock. on_submit exceptions are reported as warnings, never raised, because the job is already paid. on_complete exceptions propagate. If collection is interrupted, queued entries are cancelled before the exception is raised; entries already running are waited for first. A submit-time CloudError carries its accepted quote_usd because the service may have accepted a job before the response was lost.

Job control

list_jobs

list_jobs() -> list[dict]

Recent service jobs, including normalized quote/actual/refund dollars.

The retention window and ordering are service policy. This is the recovery surface for finding a paid job id after a notebook or process exits.

job_status

job_status(job_id: str) -> dict

One service job's current state, progress, and cost metadata.

cancel

cancel(job_id: str) -> dict

Request cancellation of a cloud job and return the service response.

Validate job_id before the request. This does not wait for a terminal state or determine the final charge or refund. Use job_status to inspect the recorded state and costs.

Account

account

account() -> dict

Balance/usage for the configured account (micro-USD + dollar fields).

whoami

whoami() -> dict

Return the service identity associated with the configured API key.

This makes an authenticated request. Response-body or socket-read timeouts can propagate as TimeoutError. Other transport and service errors raise CloudError.

create_api_key

create_api_key(name: str = 'default') -> dict

Mint a new API key (the plaintext token is returned exactly once).

Transport and errors

HttpClient

HttpClient(cfg: CloudConfig)

HTTP transport for the configured PhotonHub cloud service.

Requests use the API key and request timeout in cfg. GET requests retry server and network errors with bounded backoff unless a caller asks for one attempt. POST and DELETE requests are attempted once. HTTP errors raise CloudError with parsed details in body and the service's reason, one bounded line with secrets redacted, in the message. Result redirects do not forward the bearer token to the redirected URL.

cfg instance-attribute

cfg = cfg

get_json

get_json(
    path: str,
    *,
    deadline: Optional[float] = None,
    retry: bool = True,
) -> dict

GET a service-relative path and decode its JSON response.

Enforce the JSON body-size limit. Response-body or socket-read timeouts can propagate as TimeoutError, even without a deadline. An optional monotonic deadline bounds request opening and retry waits; expiry also raises TimeoutError. retry=False makes one attempt only. Other transport, body, or decoding failures raise CloudError.

post_json

post_json(path: str, body: dict) -> dict

POST a JSON body once to a service-relative path and decode the response.

Enforce the JSON body-size limit. Transport, body, and decoding failures raise CloudError. The request is not retried.

delete

delete(path: str) -> int

Send one DELETE request to a service-relative path and return its status.

HTTP errors raise CloudError. The request is not retried.

get_bytes

get_bytes(
    path: str, *, max_bytes: Optional[int] = None
) -> bytes

Read a response with a hard byte ceiling.

This compatibility helper remains useful for small endpoints and test transports. Large result bundles use get_to_file so they are never duplicated in memory.

get_to_file

get_to_file(
    path: str, dest, *, max_bytes: Optional[int] = None
) -> int

Stream one GET response into dest with a hard byte ceiling.

Overwrite an existing destination. A rejected, truncated, or failed transfer removes dest, even if it existed before the call or the request failed before opening it. Use a new temporary destination to retain a previous result. The parent directory must exist. Returns the number of bytes written.

whoami

whoami() -> dict

Fetch the identity associated with the configured API key.

Response-body or socket-read timeouts can propagate as TimeoutError. Other transport and service errors raise CloudError.

account

account() -> dict

Fetch the service account balance and usage response without normalization.

estimate

estimate(spec: dict, *, device=None, solver=None) -> dict

Request a quote for a solver document and optional device and solver.

Return the raw service response. This transport method does not compare grids, enforce a spend limit, or submit a job.

create_api_key

create_api_key(name: str = 'default') -> dict

Request a new API key with name and return the service response.

Treat the returned plaintext token as a secret.

list_gpus

list_gpus(
    *, deadline: Optional[float] = None, retry: bool = True
) -> list

Return the service's GPU list, or an empty list when its key is absent.

deadline and retry are as in get_json.

list_jobs

list_jobs() -> list[dict]

Fetch recent job records from the service.

Raise CloudError if the response does not contain a list of dicts. Ordering and retention depend on the service.

submit_job

submit_job(
    spec: dict,
    *,
    name=None,
    device=None,
    solver=None,
    quote_id=None,
) -> dict

Submit a solver document with the supplied optional job metadata.

Return the service response. This low-level method does not perform preflight or enforce a spend limit. Use ph.cloud.submit for the checked submission path.

get_job

get_job(
    job_id: str, *, deadline: Optional[float] = None
) -> dict

Validate the job identifier and fetch its raw service status.

An optional monotonic deadline bounds request time and retry waits.

cancel_job

cancel_job(job_id: str) -> dict

Validate the job identifier and request cancellation.

Return the service response. This method does not poll for completion.

download_result

download_result(job_id: str) -> bytes

Download a job's result bundle into memory under the configured byte limit.

Validate the job identifier. Oversized or truncated downloads raise CloudError.

download_result_to

download_result_to(
    job_id: str, dest, *, max_bytes: Optional[int] = None
) -> int

Stream a job's result bundle to dest and return the bytes written.

Validate the job identifier and enforce max_bytes or the configured download ceiling. Overwrite an existing destination. Failed transfers remove dest, even if it existed before the call or the request failed before opening it. Use a new temporary destination to retain a previous result. The parent directory must exist.

CloudError

CloudError(
    message: str,
    *,
    status_code: Optional[int] = None,
    body=None,
    job_id: Optional[str] = None,
)

Bases: RuntimeError

A cloud client/transport/auth/result-transfer error, NOT a simulation failure (that is SolverRunError). Accepted jobs expose job_id for safe resume without a duplicate paid submission.

status_code instance-attribute

status_code = status_code

body instance-attribute

body = body

job_id instance-attribute

job_id = job_id

CloudJobTimeout

CloudJobTimeout(job_id: str, timeout: float)

Bases: TimeoutError

The client stopped waiting (wait_timeout_s) while the service job is still running, and billing.

job_id instance-attribute

job_id = job_id

wait_timeout_s instance-attribute

wait_timeout_s = timeout

timeout instance-attribute

timeout = timeout