Skip to main content

Simulacrum Logo

Simulacrum SDK

A Python client for the Simulacrum time-series forecasting gateway. The SDK covers every public /v1 route of the gateway with type-safe Pydantic models and a distinct typed exception per HTTP error code.


Installation

Requires Python 3.8 or newer.

pip install simulacrum-sdk

From source

git clone https://github.com/Smlcrm/simulacrum-sdk.git
cd simulacrum-sdk
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

Quick start

Creating a client

from simulacrum import Simulacrum

client = Simulacrum(api_key="sp_your_api_key")

Override the base URL for staging or on-premise deployments:

client = Simulacrum(
    api_key="sp_your_api_key", base_url="https://staging.api.smlcrm.com"
)

Requesting a forecast

The SDK mirrors the gateway's Tempus/NamedTensor wire format. Build a ForecastPayload with your history, then call client.forecast() with one or more model ids.

from simulacrum.models import (
    ForecastPayload,
    ModelErrorResult,
    ModelForecastResult,
    ModelIncompatibleResult,
    NamedTensor,
)

# NamedTensor encodes a block of series as a flat C-order list. The last
# dimension is time: shape=[1, T] is one series of T steps. None marks a
# missing value.
revenue = NamedTensor(shape=[1, 4], values=[102.4, 106.0, None, 111.9])

payload = ForecastPayload(
    forecast_horizon=3,
    past_timestamps=["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04"],
    future_timestamps=["2024-01-05", "2024-01-06", "2024-01-07"],
    past_targets={"revenue": revenue},
)

response = client.forecast(models=["chronos2_small", "lafn"], payload=payload)

for model_id, result in response.results.items():
    if isinstance(result, ModelForecastResult):
        tensor = result.forecast_targets["revenue"]
        print(model_id, "median:", tensor.median)
        print(model_id, "p10 / p90:", tensor.p10, tensor.p90)
        print(model_id, "cost (micro-cents):", result.cost.total_micro_cents)
    elif isinstance(result, ModelIncompatibleResult):
        print(model_id, "cannot serve this payload:", result.reason)
    elif isinstance(result, ModelErrorResult):
        print(model_id, "failed:", result.code, result.reason)

response.results holds one entry per requested model, and each entry is one of three types, chosen by its status:

Type status Fields
ModelForecastResult "success" future_timestamps, forecast_targets, cost, transaction_id
ModelIncompatibleResult "incompatible" reason: the model cannot serve this payload; nothing is charged
ModelErrorResult "error" code (e.g. WALLET_INSUFFICIENT, MODEL_NOT_ENTITLED), reason, transaction_id

A model that fails does not fail the call; its result says why.

Each ForecastTargetTensor in forecast_targets holds the dense quantile grid plus five convenience knots, every list of length forecast_horizon:

  • quantile_levels: the served quantile levels, strictly increasing (e.g. [0.01, 0.05, 0.10, ..., 0.95, 0.99]).
  • quantiles: [len(quantile_levels)][forecast_horizon]; row i is the forecast at quantile_levels[i], the source for adjustable confidence bands.
  • mean: the model's point (mean) forecast.
  • median, p10, p25, p75, p90: knots taken from the grid by the gateway (grid rows, or interpolated where the level is not served).

cost is a CostBreakdown: model, horizon, num_targets, num_samples, price_per_value_micro_cents and total_micro_cents (1 USD = 100 000 000 micro-cents).

Covariates go in past_covariates (over the history) and future_covariates (over the horizon), both name-to-NamedTensor dicts. The SDK checks the payload with the gateway's rules before sending it, so a bad payload raises pydantic.ValidationError and nothing is sent.

Backtesting with a batch forecast

forecast_batch() sends the whole series once with a list of rolling-origin windows, and each model forecasts every window. A window's origin_index is the last point of its context, and it needs forecast_horizon realised points after it.

from simulacrum.models import (
    BatchForecastPayload,
    BatchWindow,
    ModelBatchForecastResult,
)

history = [100.0, 101.5, 103.2, 104.0, 106.1, 107.3, 108.0]
batch = BatchForecastPayload(
    forecast_horizon=2,
    past_timestamps=[f"2024-01-0{day}" for day in range(1, 8)],
    past_targets={"revenue": NamedTensor(shape=[1, 7], values=history)},
    windows=[
        BatchWindow(origin_index=3, context_steps=4),
        BatchWindow(origin_index=4, context_steps=4),
    ],
)

backtest = client.forecast_batch(models=["chronos2_small"], payload=batch)
result = backtest.results["chronos2_small"]
if isinstance(result, ModelBatchForecastResult):
    for window in result.windows:
        median = window.forecast_targets["revenue"].median
        print("origin", window.origin_index, "median:", median)

Batch results use ModelBatchForecastResult, ModelBatchIncompatibleResult and ModelBatchErrorResult, which work like their single-forecast counterparts.

Browsing models, families and licences

catalog = client.models()
for entry in catalog.models:
    licence = entry.license
    commercial = licence.commercial_use if licence else None
    print(entry.id, entry.status, "commercial use:", commercial)

families = client.model_families()
for family in families.families:
    for member in family.models:
        print(family.id, member.id, "available:", member.available)

Route on each entry's status: "serving" accepts forecasts. Check license.commercial_use before using a model's forecasts commercially. The catalog has four views: models() (every published model), models_served() and models_blocked() (models meant, or not meant, to be deployed) and models_online() (models with a running instance, the only view that fills instance_count).

Attribution notices

Some models' licences require a notice wherever their forecasts are shown. Display each one verbatim:

for attribution in client.attributions().attributions:
    print(attribution.notice, "for", ", ".join(attribution.model_ids))

Accessing the raw response

forecast_raw() returns the response as parsed JSON, without the typed models:

raw = client.forecast_raw(models=["chronos2_small", "lafn"], payload=payload)
for model_id, entry in raw["results"].items():
    print(model_id, entry["status"])

Validating an API key

validation = client.validate()
print("Valid:", validation.valid, "| key id:", validation.key_id)

An invalid key raises UnauthorizedError (401) or ForbiddenError (403).


Route note

The gateway exposes a single canonical forecast route: POST /v1/forecast. The portal and this SDK both use that path. An older per-model route pattern /{model}/v1/forecast is not the canonical route and should not be relied on.


Handling errors

Each gateway HTTP status maps to a distinct typed exception. Catch the most specific class you need; fall back to SimulacrumError for anything else.

from simulacrum.exceptions import (
    UnauthorizedError,
    PaymentRequiredError,
    ForbiddenError,
    NotFoundError,
    UnsupportedMediaTypeError,
    ValidationError,
    RateLimitError,
    BadGatewayError,
    ServiceUnavailableError,
    GatewayTimeoutError,
    ServerError,
    UnexpectedResponseError,
    SimulacrumError,
)

try:
    response = client.forecast(models=["chronos2_small"], payload=payload)
except UnauthorizedError as exc:
    # HTTP 401 -- invalid or missing API key
    print("Auth failed:", exc.message, "| type:", exc.error_type)
except PaymentRequiredError as exc:
    # HTTP 402 -- insufficient wallet balance
    print("Wallet empty:", exc.message)
except ForbiddenError as exc:
    # HTTP 403 -- key recognised but access denied
    print("Forbidden:", exc.message)
except NotFoundError as exc:
    # HTTP 404 -- unknown model id
    print("Not found:", exc.message)
except UnsupportedMediaTypeError as exc:
    # HTTP 415 -- the request body's content type is not accepted
    print("Unsupported media type:", exc.message)
except ValidationError as exc:
    # HTTP 400 / 422 -- bad request payload
    print("Validation error:", exc.message, "| details:", exc.details)
except RateLimitError as exc:
    # HTTP 429 -- retry after exc.retry_after_seconds
    print("Rate limited; retry after", exc.retry_after_seconds, "s")
except BadGatewayError as exc:
    # HTTP 502 -- upstream model failure (retryable)
    print("Bad gateway:", exc.message)
except ServiceUnavailableError as exc:
    # HTTP 503 -- temporarily offline (retryable)
    print("Service unavailable:", exc.message)
except GatewayTimeoutError as exc:
    # HTTP 504 -- upstream timed out (retryable)
    print("Gateway timeout:", exc.message)
except ServerError as exc:
    # HTTP 5xx (other than 502/503/504)
    print("Server error:", exc.message, "| trace id:", exc.trace_id())
except UnexpectedResponseError as exc:
    # Unmapped status code -- inspect exc.status_code
    print("Unexpected response:", exc.status_code, exc.message)
except SimulacrumError as exc:
    print("Simulacrum error:", exc)

Billing and entitlement failures of a single model (for example an empty wallet) arrive as that model's ModelErrorResult with a code, not as an exception, so the other models' forecasts still come back.

Every exception exposes:

Attribute Description
status_code HTTP status returned by the gateway
error_type type field from the gateway ErrorEnvelope
message Human-readable reason
details Optional structured detail dict
trace_id() Best available trace identifier (body or header)
retry_after_seconds Seconds to wait before retry (RateLimitError only)

HTTP status to exception mapping

HTTP status Exception class
401 UnauthorizedError
402 PaymentRequiredError
403 ForbiddenError
404 NotFoundError
409 ConflictError
415 UnsupportedMediaTypeError
400 / 422 ValidationError
429 RateLimitError
502 BadGatewayError
503 ServiceUnavailableError
504 GatewayTimeoutError
other 5xx ServerError
anything else UnexpectedResponseError

API reference

Client method Route Returns
forecast(models=, payload=) POST /v1/forecast ForecastResponse
forecast_raw(models=, payload=) POST /v1/forecast dict (parsed JSON)
forecast_batch(models=, payload=) POST /v1/forecast/batch BatchForecastResponse
models() GET /v1/models ModelCatalogResponse
models_online() GET /v1/models_online ModelCatalogResponse
models_served() GET /v1/models_served ModelCatalogResponse
models_blocked() GET /v1/models_blocked ModelCatalogResponse
model_families() GET /v1/model_families ModelFamiliesResponse
attributions() GET /v1/attributions AttributionsResponse
validate() GET /v1/validate ValidateAPIKeyResponse

Every model lives in simulacrum.models and is named after the gateway's OpenAPI schema it mirrors:

Model Description
NamedTensor A block of series in flat C-order form; time is the last dimension
ForecastPayload, ForecastRequest Single-forecast request
BatchWindow, BatchForecastPayload, BatchForecastRequest Batch (backtest) request
ForecastResponse results: model id to ModelForecastResult, ModelIncompatibleResult or ModelErrorResult
ForecastTargetTensor Dense quantile grid (quantile_levels, quantiles, mean) and knots (median, p10, p25, p75, p90)
CostBreakdown, BatchCostBreakdown What a forecast cost, in micro-cents
BatchForecastResponse, WindowForecast Batch results, one forecast per window
ModelCatalogResponse, ModelCatalogEntry, ModelCapabilities The model catalog
ModelLicense, ModelFamilyInfo A model's licence and family lineage
ModelFamiliesResponse, ModelFamily, FamilyModelEntry The family catalog
AttributionsResponse, AttributionEntry Notices to display
ValidateAPIKeyResponse API key metadata

simulacrum.exceptions holds the typed exception hierarchy (one class per HTTP status).


Upgrading to 2.0

2.0 matches the gateway's public /v1 API. Releases on PyPI before 2.0 (0.x) used a different client: rewrite that code against the examples above. Code written against 1.1 from source, whose requests the gateway rejected with 422, needs these changes:

  • forecast() returns a ForecastResponse. Read response.results[model_id] and branch on its type; incompatible and error results are no longer dropped.
  • NamedTensor has no names field, and its shape needs at least two dimensions ([1, T] for one series). values may contain None.
  • ForecastTensor is now ForecastTargetTensor, and ForecastCost is now CostBreakdown, with all six cost fields.
  • model_used is gone: the key of results is the model id, and cost.model repeats it.
  • ValidateAPIKeyResponse carries the gateway's fields (valid, key_id, name, user_external_id, meta, permissions, roles); client and expires_at are gone.
  • HTTP 415 raises UnsupportedMediaTypeError instead of UnexpectedResponseError.
  • New: forecast_batch(), models(), models_online(), models_served(), models_blocked(), model_families() and attributions().

Development

pip install -e ".[dev]"
pytest          # run all tests (no live gateway required)
ruff check .    # lint
ruff format .   # format
python -m build # build sdist + wheel (publish is human-only via twine)

tests/fixtures/gateway_openapi.json is the gateway's committed OpenAPI schema, and tests/test_contract.py holds the SDK to it. To adopt a newer gateway, copy its gateway/openapi.json over the fixture and update the digest in that test.


License

MIT (c) Simulacrum, Inc.

Metadata

Release files for simulacrum-sdk 2.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for simulacrum-sdk 2.0.0
File Size Uploaded
simulacrum_sdk-2.0.0.tar.gz 41.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for simulacrum-sdk 2.0.0
File Interpreter ABI Platform
simulacrum_sdk-2.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 64.0 kB

Release files / simulacrum_sdk-2.0.0.tar.gz

Download URL simulacrum_sdk-2.0.0.tar.gz
Size 41.6 kB
Tags Source
SHA-256 checksum
How to use checksums
18e857a1a72a6c430126c4698ed847f2a04bb31d26cc761c3a5d888acd612317
BLAKE2b-256 checksum
How to use checksums
cc628722937cb3e47cea0bc296aea3e3a4f081e12b195dd873e6b8269a9d6a3f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.

Transparency log

Release files / simulacrum_sdk-2.0.0-py3-none-any.whl

Download URL simulacrum_sdk-2.0.0-py3-none-any.whl
Size 22.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c0f6b30f35bd61e0037db9d4f9b0b1596181bb3412293ba5eec84aea8d8e4f56
BLAKE2b-256 checksum
How to use checksums
53af13e58dbd021a65b3cfdb8b3817fe2f960d602a96aa7b3a944ae6efc0882b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

2.0.0 This release

2 release files

0.2.5

2 release files

0.2.4

2 release files

0.2.3

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page