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
From PyPI (recommended)
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]; rowiis the forecast atquantile_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 aForecastResponse. Readresponse.results[model_id]and branch on its type;incompatibleanderrorresults are no longer dropped.NamedTensorhas nonamesfield, and itsshapeneeds at least two dimensions ([1, T]for one series).valuesmay containNone.ForecastTensoris nowForecastTargetTensor, andForecastCostis nowCostBreakdown, with all six cost fields.model_usedis gone: the key ofresultsis the model id, andcost.modelrepeats it.ValidateAPIKeyResponsecarries the gateway's fields (valid,key_id,name,user_external_id,meta,permissions,roles);clientandexpires_atare gone.- HTTP 415 raises
UnsupportedMediaTypeErrorinstead ofUnexpectedResponseError. - New:
forecast_batch(),models(),models_online(),models_served(),models_blocked(),model_families()andattributions().
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)
| File | Size | Uploaded | |
|---|---|---|---|
| simulacrum_sdk-2.0.0.tar.gz | 41.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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