From 9e95a4a62f27f21784bd1c8f1434d94de5ff11b1 Mon Sep 17 00:00:00 2001 From: jgjesdal Date: Wed, 30 Sep 2026 14:52:56 +0200 Subject: [PATCH 1/3] docs: bring the prose in line with what the Python and Rust SDKs do on main An audit of every reference page and guide against dataplatform-rust-sdk main (and the api where a claim is server-side). The examples already compiled and every Python call already matched its signature; what had drifted was the prose. - Default filter limit for Python and Rust is 1000, not 100 (events, filters, subscriptions); the subscription filter now sends every criterion and the cursor. - Resource, asset and function update echoes are typed in Python and Rust, not flat Resources; Python and Rust have the assets service; neither has a relation update form or a policy service. - Cursors no longer carry a version tag; example cursors re-encoded. - subType/status sorts page now; a sort order is compared case-insensitively and the first recognised property wins. - events.list is documented (oldest first, no cursor); the 64-character update limit on source is gone; the distinct-value endpoints are eventually consistent. - id is an exact numeric criterion, not a pattern. - 408 is spooled; the buffered path sends one request at a time. - AsyncDataHubClient lacks only the binary ingest calls. - Every refusal is a typed problem document now; the trash keeps external ids and records deletedAt. - fetch_related walks edges in both directions, so correlate-alarms' intersection includes both pumps; re-raising an event with the same external id stores a second event; an MCP time-series delete purges its datapoints, and a data-set delete with members fails. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: jgjesdal --- docs/advanced/sustained-alarm-window.mdx | 7 +++--- docs/guides/correlate-alarms.mdx | 12 +++++---- docs/guides/ingest-timeseries.mdx | 3 ++- docs/mcp-server.mdx | 10 +++++--- docs/reference/client.md | 28 +++++++++++---------- docs/reference/events.md | 32 ++++++++++++++++-------- docs/reference/external-ids.md | 2 +- docs/reference/files.md | 5 ++-- docs/reference/filters.md | 23 ++++++++--------- docs/reference/limits.md | 4 +-- docs/reference/policies.md | 5 ++++ docs/reference/resources.md | 16 ++++++++---- docs/reference/subscriptions.md | 24 ++++++++++-------- docs/reference/timeseries.md | 20 ++++++++------- docs/tutorial.mdx | 5 ++-- 15 files changed, 115 insertions(+), 81 deletions(-) diff --git a/docs/advanced/sustained-alarm-window.mdx b/docs/advanced/sustained-alarm-window.mdx index 054d7a1..a4656b2 100644 --- a/docs/advanced/sustained-alarm-window.mdx +++ b/docs/advanced/sustained-alarm-window.mdx @@ -399,9 +399,10 @@ assert_eq!(found.get_items().len(), 1, "expected exactly one finding"); -Re-running the replay is safe to try, and instructive: the detector raises the same finding -again with the same external id, and the platform collapses it rather than storing a -duplicate. Events are keyed by id, datapoints by series and timestamp. +Re-running the replay is instructive: the detector raises the same finding again with the +same external id, and the platform stores it as a second event rather than collapsing it, so +the check above then finds two. An event's external id is not unique; each create without an +`id` gets a new one. Only the datapoints collapse, keyed by series and timestamp. ## 5. Run the same detector live diff --git a/docs/guides/correlate-alarms.mdx b/docs/guides/correlate-alarms.mdx index 99f9b00..5d2f7e1 100644 --- a/docs/guides/correlate-alarms.mdx +++ b/docs/guides/correlate-alarms.mdx @@ -88,8 +88,10 @@ let nb = api.resources.fetch_related( ## 3. Find the shared subsystem -Intersect the two node sets. Whatever remains (besides the sensors themselves) is a -subsystem both alarms belong to, the likely common cause. +Intersect the two node sets. The walk follows edges in both directions, so once it reaches +the shared skid it also comes back down the other branch: besides the sensors themselves, the +intersection holds both pumps as well as `skid_1` and `cooling_system`, the subsystems both +alarms belong to and the likely common cause. @@ -105,7 +107,7 @@ Set shared = nb.nodes().stream() shared.remove("sensor_a"); shared.remove("sensor_b"); -System.out.println("Both alarms are part of: " + shared); // [cooling_system, skid_1] +System.out.println("Both alarms are part of: " + shared); // [cooling_system, skid_1, pump_a, pump_b] if (!shared.contains("cooling_system")) { throw new AssertionError("expected cooling_system, found " + shared); } @@ -119,7 +121,7 @@ a_nodes = {n.external_id for n in na.nodes} shared = {n.external_id for n in nb.nodes} & a_nodes shared -= {"sensor_a", "sensor_b"} -print("Both alarms are part of:", shared) # {'cooling_system', 'skid_1'} +print("Both alarms are part of:", shared) # {'cooling_system', 'skid_1', 'pump_a', 'pump_b'} assert "cooling_system" in shared, f"expected cooling_system, found {shared}" ``` @@ -137,7 +139,7 @@ let mut shared: HashSet<&str> = nb.nodes().iter() shared.remove("sensor_a"); shared.remove("sensor_b"); -println!("Both alarms are part of: {:?}", shared); // {"cooling_system", "skid_1"} +println!("Both alarms are part of: {:?}", shared); // {"cooling_system", "skid_1", "pump_a", "pump_b"} assert!(shared.contains("cooling_system"), "expected cooling_system, found {:?}", shared); ``` diff --git a/docs/guides/ingest-timeseries.mdx b/docs/guides/ingest-timeseries.mdx index cc7801c..2d41e82 100644 --- a/docs/guides/ingest-timeseries.mdx +++ b/docs/guides/ingest-timeseries.mdx @@ -22,7 +22,8 @@ Datapoints are split into batches (the store is optimised for large batches) and concurrently up to a bounded in-flight limit. The caps below are the API's and bind every client. Java sends **10,000** datapoints per request by default, retries transient failures (HTTP 429/5xx, network) in process and returns an `IngestResult` summarising what landed and what -didn't. Python and Rust send up to **100,000** per request, four at once, and retry through the +didn't. Python and Rust send up to **100,000** per request, four at once (one at a time with +buffering on), and retry through the [durable spool](/reference/client#durable-ingest-buffering): with buffering on, a transient failure (429, 5xx, network, or a `401`/`403`) writes the input to disk, and the next ingest call sends the spool again, oldest first, before its own data. With buffering off, the first request diff --git a/docs/mcp-server.mdx b/docs/mcp-server.mdx index 8826662..4c3b26d 100644 --- a/docs/mcp-server.mdx +++ b/docs/mcp-server.mdx @@ -179,7 +179,7 @@ neighbour ("the time series measuring this pump"). | `timeseries_get` | Look up by externalId or numeric id | | `timeseries_create` | Create one time series, returns the assigned id. Needs a `unit` or a `unitExternalId` (see `unit_list`) | | `timeseries_update` | Update common fields by id | -| `timeseries_delete` | Delete the definition. Existing datapoints are not removed | +| `timeseries_delete` | Delete the definition. Its datapoints are purged afterwards, asynchronously | | `timeseries_get_latest` | Most recent datapoint, for "what is it reading now?" | | `timeseries_fetch_datapoints` | History between two timestamps, ISO-8601 or epoch millis | | `timeseries_send_datapoint` | Append a single datapoint | @@ -314,9 +314,11 @@ and daily ingest quota as your own jobs, and hit the same field, batch and body- agent that lists in a loop can rate-limit the pipeline it shares a tenant with. The refusal comes back as a `429` carrying `Retry-After`, which is worth teaching the agent to honour. -**Deletes do not cascade.** Deleting a data set leaves its resources and time series in place; -deleting a time series leaves its datapoints. This is deliberate, but it means an agent -tidying up will need more than one call, and a half-finished tidy leaves orphans. +**Deletes do not cascade.** Deleting a data set that still has resources or time series in it +fails: remove or reassign the members first. (Deleting a time series is the exception: its +datapoints are purged after it, asynchronously.) This is deliberate, but it means an agent +tidying up will need more than one call, in the right order, and a half-finished tidy leaves +orphans. **Name coercion is server-side, and not uniform.** `edge_create_type` and `label_create` normalise a name to `SCREAMING_SNAKE_CASE`, so `derived from` becomes `DERIVED_FROM`, and the diff --git a/docs/reference/client.md b/docs/reference/client.md index 012a084..0ffafb7 100644 --- a/docs/reference/client.md +++ b/docs/reference/client.md @@ -42,8 +42,9 @@ client = DataHubClient.from_envfile("/path/to/.env") client = DataHubClient(base_url="https://api.intellistream.ai", token="...") ``` -For `async`/`await`, use `AsyncDataHubClient` instead, same methods, awaited (one is spelled -differently, see [Units](./units#client-coverage)): +For `async`/`await`, use `AsyncDataHubClient` instead, same methods, awaited (except the +[binary datapoint](./binary-datapoints) ingest, `insert_datapoints_binary` and +`insert_from_lists_binary`, which only `DataHubClient` has): ```python from intellistream_datahub_sdk import AsyncDataHubClient @@ -443,7 +444,7 @@ try { -Methods return plain `list[T]`. Non-2xx raises `DataHubException`: +Most methods return plain `list[T]`. Non-2xx raises `DataHubException`: ```python from intellistream_datahub_sdk import DataHubException @@ -458,7 +459,8 @@ except DataHubException as e: Most methods return `Result, ResponseError>`, where `get_items()` holds the -results. `resources.by_ids`, `create`, `update` and `delete`, and `edges.by_ids`, return a +results. `resources.by_ids`, `create`, `update` and `delete`, `assets.update`, +`functions.update` and `edges.by_ids` return a `GraphDataWrapper` instead, whose `nodes()` holds them. `ResponseError` exposes `get_status()` and `get_message()` (its `Display` prints both): @@ -600,14 +602,14 @@ rotated credential does not cost the batch. What each ingest path does with eith [which failures are worth retrying](#retryable-failures). :::caution Not every failure is a problem document -Some endpoints still answer in a shape the API has not converged yet: plain text, a Spring -whitelabel body carrying a stack trace, the legacy `{"error": {...}}` wrapper, or a -success-shaped `{"items": [...]}` envelope. Those all arrive labelled `application/json`, so the -`Content-Type` does not separate them and the clients read the body's structure instead. -`problem` is absent for every one of them in Python and Rust; in Java `problem()` is never null, -but carries the HTTP status alone and leaves `slug()` null. A real problem document may also carry no `type` -(some `404`s), which leaves the slug absent too. Write that branch first and keep the status and raw -message as the fallback: it stays load-bearing until the API is done converging. +The API now answers every refusal with a typed problem document, but a proxy or gateway in front +of it, or an older API, can still hand you plain text, HTML, a Spring whitelabel body, the legacy +`{"error": {...}}` wrapper or a success-shaped `{"items": [...]}` envelope. Several of those +arrive labelled `application/json`, so the `Content-Type` does not separate them and the clients +read the body's structure instead. `problem` is absent for every one of them in Python and Rust; +in Java `problem()` is never null, but carries the HTTP status alone and leaves `slug()` null. A +problem document from something other than this API may also carry no `type`, which leaves the +slug absent too. Write that branch first and keep the status and raw message as the fallback. ::: ### Which failures are worth retrying {#retryable-failures} @@ -634,7 +636,7 @@ sleeping when that is over 30 seconds, so a spent daily quota reaches your code, instead of parking a thread until midnight UTC. Rust and Python retry JSON ingest through the [durable spool](#durable-ingest-buffering): with -buffering on, a `429`, `5xx` or network failure and a `401` or `403` are written to disk and sent +buffering on, a `408`, `429`, `5xx` or network failure and a `401` or `403` are written to disk and sent again, oldest first, by the next ingest call; with it off, they reach you. Their binary path, `insert_datapoints_binary`, retries `429`, `5xx` and network failures up to three times by default, 1, 2 and 3 seconds apart, and rebuilds a request refused for a removed or renamed diff --git a/docs/reference/events.md b/docs/reference/events.md index a1298f1..022ed63 100644 --- a/docs/reference/events.md +++ b/docs/reference/events.md @@ -33,7 +33,7 @@ event even when a `snake_case` policy is rejecting it on resources. | `subType` | string, 3–128 | Refinement of `type` (`overpressure`). | | `status` | string, 3–128 | Free-form lifecycle marker (`OPEN`, `acknowledged`). No state machine is enforced. | | `source` | string, 2–128 | The system of record the event came from (`SAP`, a historian). | -| `description` | string | Prose. This is the field [full-text search](#search) reads. | +| `description` | string | Prose. [Full-text search](#search) reads it, along with `externalId` and the metadata values. | | `metadata` | map<string, string> | Flat key/value. An empty key is dropped rather than rejected. | | `dataSetId` | number | Optional. Platform-internal events (say, anomaly detection on a series outside any data set) legitimately have none. | | `relatedResources` | object[] | Resources the event is about. Each entry takes an `id`, an `externalId`, or both. | @@ -237,11 +237,17 @@ let events = api.events.filter(&filter).await?; `limit` defaults to **1 000** and is capped at **10 000**; a zero or negative value falls back to the default rather than returning nothing. The Python and Rust clients always send a -`limit`, **100** unless you set one, so the server's default only applies to a request that -omits the field. Whatever you ask for, the result is intersected with +`limit`, **1 000** unless you set one, so a call that says nothing about paging gets the same +page size as the server's default. Whatever you ask for, the result is intersected with the data sets your token may read, a filter can never widen access, so an empty page can mean "no matches" or "none you may see", and the two are not distinguished. +`GET /events?limit=` is the criteria-free read, `events.list` in all three clients. It runs the +filter with an empty body, so it returns the **oldest** `limit` events (`eventTime` ascending), +not the newest. `limit` defaults to 1 000, and above 10 000 is a `400` rather than a clamp. It +never returns a `nextCursor`, so it is one page and no more: for "what just happened", filter with +`sort` on `eventTime` descending. In Python it returns a plain list rather than a `Page`. + ### Filtering {#filtering} Every field you supply is combined with **AND**, an event must match all of them. @@ -483,10 +489,10 @@ cursor pages in, so paging does not change the order underneath you. Ask for ano "limit": 200 } ``` -Only the **first** `property` is used, and `id` is appended behind it, a sort column alone is +Only the **first** sortable `property` is used, and `id` is appended behind it, a sort column alone is not a position unless it is unique, and a page boundary inside a run of equal values repeats or drops exactly those rows. A property that is not sortable is ignored rather than rejected, and -any `order` that is not exactly `desc` sorts ascending, a malformed sort degrades to the +any `order` that is not `desc` (compared case-insensitively) sorts ascending, a malformed sort degrades to the default instead of silently reversing your results. Null values sort last ascending, first descending. @@ -495,12 +501,12 @@ To walk past the first page, echo back the `nextCursor` the response carried: ```json { "filter": { "type": "alarm" }, "sort": { "property": ["eventTime"], "order": "desc" }, - "cursor": "djE6ZXZlbnRUaW1lfGRlc2N8MTc1NDQ3NjUyMjEwNHwwMTk1ZjNhMg", + "cursor": "ZXZlbnRUaW1lfGRlc2N8MDE5NWYzYTItNGMxYi03ZjllLTljM2EtMWIyZDRlNmY4YTkwfHYxNzU0NDc2NTIyMTA0", "limit": 200 } ``` -The cursor is **opaque**, base64 of a versioned encoding carrying the sort, the boundary value -and the id, so do not build or parse one. A cursor that does not decode is refused with a +The cursor is **opaque**, base64 of an encoding carrying the sort, the boundary value and the +id, with no version tag, so do not build or parse one. A cursor that does not decode is refused with a `400` of `type: ".../errors/malformed-cursor"` rather than guessed at: half a position would silently skip or repeat the rows around the boundary. @@ -754,6 +760,12 @@ query. Both families take `limit` (default 1 000, clamped to 1–10 000). The `search/*` form requires `q` and returns `400` without it. +The values come from small tables the write path maintains, not from a scan of the events, which +is what makes them cheap. They are **eventually consistent** with the events: a new value appears +once the write path has recorded it, and a value no event carries any more lingers until a +periodic reconcile. Good for populating a picker, not proof that an event with that value exists +right now. + ``` GET /events/search/type?q=alarm&limit=20 → { "items": ["alarm", "alarm_cleared", "pre_alarm"] } @@ -824,9 +836,6 @@ instead: that is what an append-only log is for, and it keeps the correction its there for. ::: -`source` is worth one warning: create accepts up to 128 characters, but the update path -rejects anything over **64**. A value in between can be written and then not modified. - ## High-throughput ingestion @@ -920,6 +929,7 @@ The three clients cover every event endpoint, the facet endpoints included. | --- | --- | --- | --- | | Create | `events().create` / `ingest` | `events.create` | `events.create` | | Get by id | `events().getById` | `events.get` | `events.get` | +| List, oldest first (`GET /events`) | `events().list(limit)` | `events.list(limit=None)` | `events.list(limit)` | | Look up by id / external id | `events().byIds` | `events.by_ids` | `events.by_ids` | | Filter | `events().filter` | `events.filter` | `events.filter` | | Filter with `sort` | `EventRetreiver.sort` | `sort_by` / `sort_order` | `set_sort` | diff --git a/docs/reference/external-ids.md b/docs/reference/external-ids.md index 9e15173..36ca715 100644 --- a/docs/reference/external-ids.md +++ b/docs/reference/external-ids.md @@ -364,7 +364,7 @@ POST /events/filter { "filter": { "type": "policy_finding" }, "sort": { "property": ["eventTime"], "order": "asc" }, - "cursor": "djE6ZXZlbnRUaW1lfGFzY3wxNzU0NDc2NTIyMTA0fDAxOTVmM2Ey", + "cursor": "ZXZlbnRUaW1lfGFzY3wwMTk4N2VmMy0yYTc4LTdjM2UtOWYxYS0yYjRjNmQ4ZTBhMTN8djE3NTQ0NzY1MjIxMDQ", "limit": 200 } ``` diff --git a/docs/reference/files.md b/docs/reference/files.md index 0532fcd..6db8fd6 100644 --- a/docs/reference/files.md +++ b/docs/reference/files.md @@ -244,9 +244,8 @@ Identify the node by `externalId` or `id`; every other field is optional and nul unchanged". `metadata` and `relatedResources` **replace** rather than merge. A restore is not a force-overwrite. If something else already occupies the path, you get a -`409`, so move or rename the occupant first. Names and paths in the trash listing are the -pre-deletion values, and the deletion time is encoded in the external id as -`DELETED_..._`. +`409`, so move or rename the occupant first. Names, paths and external ids in the trash listing +are the pre-deletion values, and the deletion time is in the node's `deletedAt` field. ## Delete diff --git a/docs/reference/filters.md b/docs/reference/filters.md index f7ea81d..e531c58 100644 --- a/docs/reference/filters.md +++ b/docs/reference/filters.md @@ -107,7 +107,7 @@ let plants = plants.build(); There is no Python `NodeFilter` class: the shared fields are inlined into each filter, so -`DatasetFilter`, `ResourceFilter` and `TimeSeriesFilter` each declare them directly. +`DatasetFilter`, `ResourceFilter`, `TimeSeriesFilter` and `FunctionFilter` each declare them directly. `dataSetId` is the one list where **null and empty mean opposite things**: @@ -302,12 +302,12 @@ a filter sort — where nulls sit follows the direction. A `nulls` field did exi so sending one now is an unknown field and a `400`. Nodes default to `createdTime` descending and events to `eventTime` ascending. An unsortable -property falls back to the default rather than failing, and any `order` that is not exactly -`desc` sorts ascending. +property falls back to the default rather than failing, and any `order` that is not `desc` +(compared case-insensitively) sorts ascending. -Subscriptions are the exception: their own sort type is separate and still carries `nulls`. It is -not reachable from `events.filter`, `datasets.filter`, `timeseries.filter` or `resources.filter`, -which take a sort property and direction and build the filter sort internally. +Subscriptions take the same sort, without `nulls`, over `id`, `externalId`, `name`, +`createdTime` and `lastUpdatedTime`, newest created first by default. In Python every `filter()`, +`subscriptions.filter` included, takes `sort_by` and `sort_order` and builds the sort internally. ### Default sizes {#defaults} @@ -319,9 +319,8 @@ which take a sort property and direction and build the filter sort internally. Asking for more than the maximum is a `400`. The cap is on one page, not on how much you can read — walk past it with a cursor. -The SDKs do not rely on the server default: the Python and Rust clients send `limit` on every -filter call and default it to **100**, so a call that says nothing about paging returns 100 rows -rather than 1000. +When you do not set a `limit`, the Python and Rust clients either omit it or send 1000, so a +call that says nothing about paging returns up to 1000 rows either way. ## Paging with a cursor {#paging} @@ -339,9 +338,9 @@ same `sort`. Three things are refused with a `400` None of them restarts the walk silently, which would loop a paging client or skip rows. -Sorting by `subType` or `status` cannot be paged at all: neither is selective enough to order a -keyset on, so a cursor carrying one is refused. Sort by something else if you need to walk the -whole result. +Sorting by `subType` or `status` pages like any other sort, although both are nullable: the +null block is a position in the order, so the walk continues through it rather than stopping +after the first page. A full page does not mean there is more — a walk ends with one request that comes back empty. [Events](./events#paging) has the walk. diff --git a/docs/reference/limits.md b/docs/reference/limits.md index 0e21562..6e329de 100644 --- a/docs/reference/limits.md +++ b/docs/reference/limits.md @@ -296,8 +296,8 @@ times; a `500` the API marks `needs-operator` is surfaced instead. Its backoff i rather than sleeping, so a spent daily quota reaches your code, or the spool, instead of holding a thread until 00:00 UTC. -Rust and Python retry JSON ingest through the spool instead: with buffering on, a `429`, `5xx` or -network failure is written to disk and the next ingest call sends it again, oldest first, before +Rust and Python retry JSON ingest through the spool instead: with buffering on, a `408`, `429`, `5xx` +or network failure is written to disk and the next ingest call sends it again, oldest first, before its own data; with it off, the error reaches your code. Their binary path, `insert_datapoints_binary`, retries `429`, `5xx` and network failures up to three times by default, 1, 2 and 3 seconds apart. Neither waits the `Retry-After`, so a rate limit that diff --git a/docs/reference/policies.md b/docs/reference/policies.md index ce3becc..6c43f2a 100644 --- a/docs/reference/policies.md +++ b/docs/reference/policies.md @@ -17,6 +17,11 @@ The Java client reaches both through `client.policies()` and `client.governance( [`datasets().policies()`](./datasets#client-coverage) lists the same catalogue from the data set side, in all three clients. +Python and Rust have no policy service. A policy is a node, so they reach one through +[`resources`](./resources#typed-reads): a read narrowed to node type `policy` comes back as `Policy`, +and `resources.create` takes a `Policy` node, under the same `403` rule as any +[`POLICY`-labelled create](./resources#body). + ## Policy types {#types} `GET /policies/types` lists the types a policy can instantiate. Each entry carries the type's diff --git a/docs/reference/resources.md b/docs/reference/resources.md index db2dc73..d2328e9 100644 --- a/docs/reference/resources.md +++ b/docs/reference/resources.md @@ -151,9 +151,9 @@ update echo: `relatedResources` is left empty on purpose. The request touched on node's edges, and answering with those alone would be indistinguishable from answering with all of them. A delete has no echo at all, being a `204` with no body. -The Python and Rust clients read the update echo as a flat `Resource` whatever the node's type, -so a time series updated there comes back without its `unit`, and an asset without its -`geoLocation`. Read the node again with `get_by_id` or `by_ids` when you need its typed shape. +The Python and Rust clients read the update echo typed as well: Python's `GraphResult.nodes` +holds each node as its own class, and Rust's `update` returns `GraphDataWrapper`, so a +time series updated there comes back with its `unit` and an asset with its `geoLocation`. ## Look up @@ -607,7 +607,8 @@ This is worth designing for rather than retrying blindly: two writers doing All three clients wrap this: `resources().update(nodes, relations)` in Java, `resources.update([...])` in Python, and `resources.update(&updates)` in Rust, each taking the -per-entry update forms above. +per-entry update forms above. The Python and Rust forms (`ResourceUpdate`) cover nodes only: +neither client has a relation update form, so retarget or retype an edge from Java or over HTTP. ## Delete {#delete} @@ -684,7 +685,7 @@ Nodes from `fetch-related` and `fetch-nearest` come back [typed by label](#typed carry the fields the graph mirror holds: `id`, `externalId`, `name`, `description`, `source`, `dataSetId`, `labels`, `metadata`, `createdTime` and `lastUpdatedTime`, plus `relatedResources` built from the edges of the network you fetched. By type: `isRoot` on resources and assets, -`geoLocation` on assets, `unit`, `unitExternalId` and `valueType` on time series, and +`geoLocation` on assets (a point only; other geometries are not carried), `unit`, `unitExternalId` and `valueType` on time series, and `deactivated` on policies. Fetch by id when you need a field outside that list. @@ -1039,6 +1040,11 @@ DataWrapper some = client.assets().byIds(List.of( counterparts. The update echo is a `GraphDataWrapper`, not an asset one, because an update may touch a relation whose other end is not an asset. +Python and Rust have the same service as `assets` (`client.assets`, `api.assets`), with +`create`, `get_by_id`, `by_ids`, `list`, `filter`, `search`, `update` and `delete`. Reads come +back as `Asset`, and `update` echoes typed nodes as on `/resources`: a `GraphResult` in Python, +a `GraphDataWrapper` in Rust. + ## The `/functions` endpoints {#functions} A **function** is a plain node distinguished by its `FUNCTION` label. Like diff --git a/docs/reference/subscriptions.md b/docs/reference/subscriptions.md index 1167fb3..26db41b 100644 --- a/docs/reference/subscriptions.md +++ b/docs/reference/subscriptions.md @@ -80,7 +80,8 @@ criteria combined by **AND**: | Criterion | Matching | | --- | --- | -| `id`, `externalId`, `name` | Patterns, case-insensitive. `*` and `%` are wildcards, `_` is literal, and an entry with no wildcard matches exactly. | +| `id` | Exact numeric ids. | +| `externalId`, `name` | Patterns, case-insensitive. `*` and `%` are wildcards, `_` is literal, and an entry with no wildcard matches exactly. | | `timeseries` | Subscriptions bound to **any** of these time-series, each named by `id`, `externalId`, or both. | | `createdTime`, `lastUpdatedTime` | `{ "min": …, "max": … }` bounds. | @@ -107,17 +108,19 @@ response carries `nextCursor` while more remain. } ``` -The clients do not all send the whole body yet: +All three clients send the whole body: | | Java | Python and Rust | | --- | --- | --- | -| Criteria | all of them, on `SubscriptionRetriever` | `timeseries` only | -| `cursor` | sent | not sent yet | -| `limit` you did not set | the server's default, 1000 | 100 | +| Criteria | all of them, on `SubscriptionRetriever` | all of them, on `SubscriptionFilter` | +| `cursor` | sent | sent | +| `limit` you did not set | the server's default, 1000 | the server's default, 1000 | -`limit` is capped at 10 000 everywhere. To narrow by series in Python, pass keywords, +`limit` is capped at 10 000 everywhere. In Python, pass the criteria as keywords, `client.subscriptions.filter(timeseries=["engine_temperature"], limit=100)`, or a prepared -`SubscriptionFilterForm`, which is the only form Rust takes. +`SubscriptionFilter` as `filter=`, with `sort_by`, `sort_order` and `cursor` as keywords of the +call; the result is a `Page` carrying `next_cursor`. Rust takes a `SubscriptionFilterForm`, whose +`paging` carries `sort` and `cursor`. This replaced `POST /subscriptions/list`, which took a `filter` argument in a shape nothing else in the API used: its own default page size, a sort that was not validated, and no cursor, so a @@ -187,8 +190,9 @@ an `AsyncDataHubClient`, `listen` returns a `SubscriptionListenerAsync`, driven `next().await` yields `Some(Ok(msg))` or `Some(Err(..))` and never `None`: a dropped or closed socket is reconnected for you, with a fresh token and the same subscriptions. An `Err` is a -refused subscription while the socket stays open, a frame that could not be decoded, or a -reconnect that ran out of retries, and calling `next` again resumes reconnecting: +refused subscription while the socket stays open, a connection refused at the WebSocket cap +(`ListenError::ConnectionLimit`), a frame that could not be decoded, or a reconnect that ran out +of retries, and calling `next` again resumes reconnecting: ```rust let mut listener = api.subscriptions.listen(&["engine_temps"]).await?; @@ -314,7 +318,7 @@ error instead of retried. | --- | --- | --- | --- | | Create | `subscriptions().create` | `subscriptions.create` | `subscriptions.create` | | List `subscriptions().list` | HTTP | `subscriptions.list(limit=None)` | `subscriptions.list(limit)` | -| Filter | `subscriptions().filter` | `subscriptions.filter(form=None, *, timeseries=, limit=, sort=)` | `subscriptions.filter` | +| Filter | `subscriptions().filter` | `subscriptions.filter(*, filter=, id=, external_id=, name=, timeseries=, created_time=, last_updated_time=, limit=, sort_by=, sort_order=, cursor=)`, returning a `Page` | `subscriptions.filter` | | Delete | `subscriptions().delete` | `subscriptions.delete` | `subscriptions.delete` | | Live delivery | `subscriptions().listen` | `subscriptions.listen` (`SubscriptionListenerAsync` with `async for` on the async client) | `subscriptions.listen` | | [Live tail](#live-tail), no subscription | — | `timeseries.listen_datapoints` | `time_series.listen_datapoints` | diff --git a/docs/reference/timeseries.md b/docs/reference/timeseries.md index e5b0e98..694f3a1 100644 --- a/docs/reference/timeseries.md +++ b/docs/reference/timeseries.md @@ -194,12 +194,13 @@ combined with AND, a series must match every criterion to be included. | `unit` | Pattern, case-insensitive. `*` and `%` are wildcards, `_` is literal (`"cel%"`). | | `unitExternalId` | Pattern on the unit-catalogue external id (e.g. `temperature_deg_c`), on the same rules. | | `valueType` | Exact, case-insensitive, against the closed catalogue: `BIGINT`, `FLOAT`, `FLOAT32`, `NUMERIC`, `DECIMAL32`, `TEXT`, `MIXED`. Not a pattern. | -| `id`, `externalId`, `name`, `source` | The shared node criteria. Patterns, on the same rules as `unit`. | +| `id` | The shared node criterion. Exact numeric ids, not patterns. | +| `externalId`, `name`, `source` | The shared node criteria. Patterns, on the same rules as `unit`. | | `labels` | Series carrying **all** of these labels. | | `metadata` | Every key/value pair given must be present. **A null value matches the key alone**, whatever it holds. | | `createdTime`, `lastUpdatedTime` | `{ "min": …, "max": … }` bounds. | -Each field above except `labels` and `metadata` takes **either a bare value or an array**, and the +Each field above except `labels`, `metadata` and the two time windows takes **either a bare value or an array**, and the entries of an array are combined with **OR**. That is why they are named in the singular: `"unit": "celsius"` is the common case, and `"unit": ["celsius", "kelvin"]` asks for either. `labels` and `metadata` require **all** entries to match and keep their plural names for that @@ -306,10 +307,10 @@ newest created first. "limit": 100 } ``` -Only the **first** `property` is used, and `id` is appended behind it: a sort column alone is not +Only the **first** recognised `property` is used, and `id` is appended behind it: a sort column alone is not a position unless it is unique, and a page boundary inside a run of equal values repeats or drops exactly those rows. An unrecognised property falls back to the default rather than being -rejected, and any `order` that is not exactly `desc` sorts ascending. Nulls sort last ascending, +rejected, and any `order` that is not `desc`, compared without case, sorts ascending. Nulls sort last ascending, first descending, most of these columns are nullable, since every node type shares one table. A page that has a successor carries a `nextCursor`. Echo it back as `cursor` to continue: @@ -317,11 +318,11 @@ A page that has a successor carries a `nextCursor`. Echo it back as `cursor` to ```json { "filter": { "unit": "celsius" }, "sort": { "property": ["name"], "order": "asc" }, - "cursor": "djE6bmFtZXxhc2N8N3x2YQ", + "cursor": "bmFtZXxhc2N8N3x2YQ", "limit": 100 } ``` -The cursor is **opaque**, base64 of a versioned encoding carrying the sort, the boundary value +The cursor is **opaque**, base64 of an encoding carrying the sort, the boundary value and the id, so do not build or parse one. Send it with the **same** sort that produced it; a cursor is a position in one particular order, and continuing it under another is refused. One that does not decode is refused with a `400` of `type: ".../errors/malformed-cursor"`. @@ -394,7 +395,8 @@ with `by_ids`, which leaves out what it cannot find rather than failing. `GET /timeseries?limit=` is the cheap "what have I got" read: the first `limit` series, newest created first, no criteria. Add `dataSetId` (an id or an external id) to restrict it to one data -set and everything beneath it in the `BELONGS_TO` hierarchy. Anything narrower belongs in +set and everything beneath it in the `BELONGS_TO` hierarchy. Python's and Rust's `list` take `limit` +alone. Anything narrower belongs in [filter](#filter-series). `POST /timeseries/update` changes fields on series that already exist. It is a partial update: @@ -591,7 +593,7 @@ of at most 100 000 datapoints, and up to four are in flight at once. Change that `DATAPOINT_INSERT_PARALLELISM` in the environment, which `create_api_service()` and Python's `from_env()` read, or with `set_datapoint_insert_parallelism` on a Rust `DataHubConfig`. Retries go through the [durable spool](./client#durable-ingest-buffering): with buffering on, the requests -go one at a time, a transient failure (429, 5xx, network, or a `401`/`403`) writes the input to +go one at a time, a transient failure (408, 429, 5xx, network, or a `401`/`403`) writes the input to disk, and the next insert sends the spool again, oldest first, before its own data. With buffering off, the first request to fail ends the call with its error and the requests not yet sent are dropped, so part of the input may have landed. Sending all of it again is safe either way, since @@ -755,7 +757,7 @@ window: | `limit` | Datapoints per page, default 100, at most 100 000. | | `aggregates` | Any of `avg`, `sum`, `min`, `max`, lower-case. A name outside that set is dropped, not rejected. `avg` comes back as `average`. | | `granularity` | A number and a unit: `s`, `m`, `h`, `d`, `w`, `mo`, `y`, or the words `sec`, `min`, `hour`, `day`, `week`, `month`, `year` and their plurals (`15m`, `1h`, `30 min`). Bare `m` is a minute; a month is `mo`. Required when `aggregates` is set. | -| `includeOutsidePoints`, `mergeDuplicates` | Booleans, default `false`. | +| `includeOutsidePoints`, `mergeDuplicates` | Booleans, default `false`. The Python and Rust `RetrieveFilter` do not carry them, so those clients always get the default. | | `cursor` | The previous page's `nextCursor`, carried on each returned collection. | A datapoint comes back with an ISO-8601 UTC `timestamp` and a `value`. Python reads the diff --git a/docs/tutorial.mdx b/docs/tutorial.mdx index 89d7044..54dce68 100644 --- a/docs/tutorial.mdx +++ b/docs/tutorial.mdx @@ -647,8 +647,9 @@ exist and it goes straight to ingesting: ... ``` -Now simulate the outage: stop the API for a minute, watch the ticks report buffering -(and `datahub-spool/` fill up), then start it again and watch the backlog flush. To see +Now simulate the outage: stop the API for a minute, watch the ticks report buffering in Java +(and `datahub-spool/` fill up in every language; the Python and Rust programs keep printing +`ingested`, since a spooled call succeeds), then start it again and watch the backlog flush. To see the ingested data, read it back with a [retrieve filter](/reference/timeseries#retrieve-datapoints) or aggregate it as in [Query & aggregate](/guides/query-and-aggregate). From 7ed6cc1776b56457c315399dcd12493e6e7c5fdd Mon Sep 17 00:00:00 2001 From: jgjesdal Date: Wed, 30 Sep 2026 14:58:35 +0200 Subject: [PATCH 2/3] docs(files): an upload's external id defaults to the file name, verbatim SDK main (811b093) dropped the client-side snake-casing of the default external id and gained INode.deleted_at, following the api's verbatim file ids and deletedAt trash. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: jgjesdal --- docs/reference/files.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/reference/files.md b/docs/reference/files.md index 6db8fd6..d9d9987 100644 --- a/docs/reference/files.md +++ b/docs/reference/files.md @@ -47,6 +47,10 @@ sends the `path` you give it as-is, so include the file name and a leading `/`. and Rust clients take a `destination_path` folder plus a `name` (defaulting to the local file's name) and join the two into that same full path. The Java client uploads raw `content` bytes; the Python and Rust clients read a local file. +They also default the external id to the file name as-is, the same default the server +applies. External ids are stored verbatim and allow only letters, digits and `. _ : + = -`, so a +file whose name has a space or another character outside that set needs an explicit external +id, or the upload is a `400` naming `externalId`. @@ -245,7 +249,8 @@ unchanged". `metadata` and `relatedResources` **replace** rather than merge. A restore is not a force-overwrite. If something else already occupies the path, you get a `409`, so move or rename the occupant first. Names, paths and external ids in the trash listing -are the pre-deletion values, and the deletion time is in the node's `deletedAt` field. +are the pre-deletion values, and the deletion time is in the node's `deletedAt` field +(`deleted_at` in Python and Rust). ## Delete From 64b6a8cc4cd055fa074d1d12187e31691ebec973 Mon Sep 17 00:00:00 2001 From: jgjesdal Date: Thu, 1 Oct 2026 09:22:28 +0200 Subject: [PATCH 3/3] docs: fix the examples that fail on their own code Found by the live Python tier; each was a mistake in the page, not the platform. - tutorial: step 1 never imported intellistream_datahub_sdk, which step 2 uses. - events: the lookup example used uuid without importing it. - datasets: the lookup read a data set id that does not exist; it now reads the id of the one it just looked up. - Python TimeSeries defaults value_type to bigint, so twelve series that are written decimals now say value_type="float"; the three that said "numeric", which Python cannot create, say "float" too. - construction, medical-devices, aerospace: the demo-data block created nodes that step 1 then creates again, a 409 for anyone following the page. - data-cleaning-lineage: fetch_related walks edges both ways, so the forward impact walk now follows the returned edges' direction itself. Plans: events and resources now supply what a reader carries in from an earlier example, and events drops an [expect] its own delete example contradicts. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: jgjesdal --- docs/advanced/data-cleaning-lineage.mdx | 21 ++++++++++++++++--- .../agriculture-food/precision-farming.mdx | 6 +++--- .../agriculture-food/salmon-farming.mdx | 4 ++-- .../built-environment/construction.mdx | 9 ++------ .../energy-utilities/air-quality.mdx | 8 +++---- docs/industries/energy-utilities/grid.mdx | 2 +- docs/industries/energy-utilities/wind.mdx | 6 +++--- .../financial-services/portfolio-risk.mdx | 4 ++-- .../industries/healthcare/medical-devices.mdx | 6 +++--- .../manufacturing-process/discrete.mdx | 2 +- .../manufacturing-process/pharma.mdx | 2 +- .../manufacturing-process/semiconductor.mdx | 2 +- .../transport-logistics/aerospace.mdx | 7 ++++--- docs/reference/datasets.md | 6 +++--- docs/reference/events.md | 2 ++ docs/tutorial.mdx | 1 + doctests/plans/reference__datasets.toml | 8 +++++++ doctests/plans/reference__events.toml | 21 +++++++++++++++++-- doctests/plans/reference__resources.toml | 3 +++ doctests/plans/tutorial.toml | 6 ++---- 20 files changed, 83 insertions(+), 43 deletions(-) diff --git a/docs/advanced/data-cleaning-lineage.mdx b/docs/advanced/data-cleaning-lineage.mdx index a32648c..b26f6a4 100644 --- a/docs/advanced/data-cleaning-lineage.mdx +++ b/docs/advanced/data-cleaning-lineage.mdx @@ -157,10 +157,25 @@ it poison?** Walk *forward* from the raw sensor and every downstream series it t lights up, while the vibration-only features stay clean. That's the precise list to recompute or quarantine. +`fetch_related` walks edges in both directions, so from the raw sensor it reaches the +whole connected pipeline, the vibration branch included. Each returned edge keeps its +direction, though, `start` → `end`, so follow those from the sensor to keep only what lies +downstream of it. + ```python -fwd = client.resources.fetch_related(external_id="engine_temperature_raw", depth=12) -affected = [n.external_id for n in fwd.nodes - if n.external_id.startswith("engine_") and n.external_id != "engine_temperature_raw"] +net = client.resources.fetch_related(external_id="engine_temperature_raw", depth=12) +ext_id = {n.id: n.external_id for n in net.nodes} +start = next(i for i, x in ext_id.items() if x == "engine_temperature_raw") + +downstream, frontier = set(), [start] +while frontier: + node = frontier.pop() + for e in net.edges: + if e.start == node and e.end not in downstream: + downstream.add(e.end) + frontier.append(e.end) + +affected = sorted(ext_id[i] for i in downstream if ext_id[i].startswith("engine_")) print("affected by the bad temperature sensor:", affected) # engine_temperature_clean, engine_temp_roll_mean, engine_temp_roc, # engine_feature_vector, engine_health_score — but NOT the vibration features diff --git a/docs/industries/agriculture-food/precision-farming.mdx b/docs/industries/agriculture-food/precision-farming.mdx index 3de85e0..713b248 100644 --- a/docs/industries/agriculture-food/precision-farming.mdx +++ b/docs/industries/agriculture-food/precision-farming.mdx @@ -28,7 +28,7 @@ import intellistream_datahub_sdk, numpy as np, pandas as pd client = intellistream_datahub_sdk.DataHubClient.from_env() for s, u in [("field_north_40_soil_moisture", "pct"), ("field_north_40_soil_temp_c", "deg_c")]: - client.timeseries.create([intellistream_datahub_sdk.TimeSeries(external_id=s, name=s, unit=u)]) + client.timeseries.create([intellistream_datahub_sdk.TimeSeries(value_type="float", external_id=s, name=s, unit=u)]) idx = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=48, freq="1h") moisture = 35 - np.linspace(0, 15, 48) + np.random.normal(0, 1, 48) # drying out client.timeseries.insert_from_lists(timestamps=idx, values=moisture, ts="field_north_40_soil_moisture") @@ -63,9 +63,9 @@ client.timeseries().ingest(Map.of( import intellistream_datahub_sdk client.timeseries.create([ - intellistream_datahub_sdk.TimeSeries(external_id="field_north_40_soil_moisture", + intellistream_datahub_sdk.TimeSeries(value_type="float", external_id="field_north_40_soil_moisture", name="North 40 soil moisture", unit="pct"), - intellistream_datahub_sdk.TimeSeries(external_id="field_north_40_soil_temp_c", + intellistream_datahub_sdk.TimeSeries(value_type="float", external_id="field_north_40_soil_temp_c", name="North 40 soil temperature", unit="deg_c")]) client.timeseries.insert_from_lists( timestamps=timestamps, values=moisture, ts="field_north_40_soil_moisture") diff --git a/docs/industries/agriculture-food/salmon-farming.mdx b/docs/industries/agriculture-food/salmon-farming.mdx index 220b532..d6ecda7 100644 --- a/docs/industries/agriculture-food/salmon-farming.mdx +++ b/docs/industries/agriculture-food/salmon-farming.mdx @@ -139,8 +139,8 @@ client.timeseries().ingest(Map.of( import intellistream_datahub_sdk client.timeseries.create([ - intellistream_datahub_sdk.TimeSeries(external_id="pen_h_07_feed_kg", name="Pen H-07 feed", unit="kg"), - intellistream_datahub_sdk.TimeSeries(external_id="pen_h_07_biomass_kg", name="Pen H-07 biomass", unit="kg")]) + intellistream_datahub_sdk.TimeSeries(value_type="float", external_id="pen_h_07_feed_kg", name="Pen H-07 feed", unit="kg"), + intellistream_datahub_sdk.TimeSeries(value_type="float", external_id="pen_h_07_biomass_kg", name="Pen H-07 biomass", unit="kg")]) client.timeseries.insert_from_lists( timestamps=[pd.Timestamp.now(tz="UTC")], values=[420.0], ts="pen_h_07_feed_kg") ``` diff --git a/docs/industries/built-environment/construction.mdx b/docs/industries/built-environment/construction.mdx index ca2edf0..85b1745 100644 --- a/docs/industries/built-environment/construction.mdx +++ b/docs/industries/built-environment/construction.mdx @@ -18,19 +18,14 @@ This scenario pairs **document management** with safety events on a modelled sit ## Set up demo data -New workspace? Run this once (Python) to create the site graph and a crane-utilisation -series. (The drawing upload in step 1 needs a local file — any PDF works.) Safe to re-run. +New workspace? Run this once (Python) to create a crane-utilisation series; step 1 +builds the site graph itself. (The drawing upload in step 1 needs a local file — any PDF works.) ```python import intellistream_datahub_sdk, numpy as np, pandas as pd client = intellistream_datahub_sdk.DataHubClient.from_env() -client.resources.create( - [intellistream_datahub_sdk.Resource(external_id=x, name=x, labels=[lbl]) for x, lbl in - [("site_harbour_tower", "Site"), ("zone_level_12", "Zone")]], - [intellistream_datahub_sdk.RelForm.by_external_ids("site_harbour_tower", "zone_level_12", "contains")]) - client.timeseries.create([intellistream_datahub_sdk.TimeSeries(external_id="crane_02_hours", name="Crane 02 hours", unit="h", value_type="float")]) idx = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=14, freq="1d") client.timeseries.insert_from_lists(timestamps=idx, values=np.random.uniform(4, 9, 14), ts="crane_02_hours") diff --git a/docs/industries/energy-utilities/air-quality.mdx b/docs/industries/energy-utilities/air-quality.mdx index 546c04b..5815dae 100644 --- a/docs/industries/energy-utilities/air-quality.mdx +++ b/docs/industries/energy-utilities/air-quality.mdx @@ -29,7 +29,7 @@ client = intellistream_datahub_sdk.DataHubClient.from_env() for s, u in [("station_kirkeveien_pm25", "ug_m3"), ("station_kirkeveien_no2", "ppb"), ("station_kirkeveien_co", "ppm")]: - client.timeseries.create([intellistream_datahub_sdk.TimeSeries(external_id=s, name=s, unit=u)]) + client.timeseries.create([intellistream_datahub_sdk.TimeSeries(value_type="float", external_id=s, name=s, unit=u)]) idx = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=24, freq="1h") client.timeseries.insert_from_lists(timestamps=idx, values=np.random.uniform(5, 45, 24), ts="station_kirkeveien_pm25") ``` @@ -102,9 +102,9 @@ client.timeseries().create(pm25, no2, co); import intellistream_datahub_sdk client.timeseries.create([ - intellistream_datahub_sdk.TimeSeries(external_id="station_kirkeveien_pm25", name="Kirkeveien PM2.5", unit="ug_m3"), - intellistream_datahub_sdk.TimeSeries(external_id="station_kirkeveien_no2", name="Kirkeveien NO2", unit="ppb"), - intellistream_datahub_sdk.TimeSeries(external_id="station_kirkeveien_co", name="Kirkeveien CO", unit="ppm")]) + intellistream_datahub_sdk.TimeSeries(value_type="float", external_id="station_kirkeveien_pm25", name="Kirkeveien PM2.5", unit="ug_m3"), + intellistream_datahub_sdk.TimeSeries(value_type="float", external_id="station_kirkeveien_no2", name="Kirkeveien NO2", unit="ppb"), + intellistream_datahub_sdk.TimeSeries(value_type="float", external_id="station_kirkeveien_co", name="Kirkeveien CO", unit="ppm")]) ``` diff --git a/docs/industries/energy-utilities/grid.mdx b/docs/industries/energy-utilities/grid.mdx index fa07a9f..7decf19 100644 --- a/docs/industries/energy-utilities/grid.mdx +++ b/docs/industries/energy-utilities/grid.mdx @@ -27,7 +27,7 @@ import intellistream_datahub_sdk, numpy as np, pandas as pd client = intellistream_datahub_sdk.DataHubClient.from_env() -client.timeseries.create([intellistream_datahub_sdk.TimeSeries(external_id="feeder_f12_load_mw", name="Feeder F12 load", unit="mw")]) +client.timeseries.create([intellistream_datahub_sdk.TimeSeries(value_type="float", external_id="feeder_f12_load_mw", name="Feeder F12 load", unit="mw")]) idx = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=24, freq="1h") load = 8 + 4 * np.sin(np.arange(24) / 24 * 2 * np.pi) + np.random.normal(0, 0.5, 24) load[18] = 14.8 # an evening overload past the 12 MW rating diff --git a/docs/industries/energy-utilities/wind.mdx b/docs/industries/energy-utilities/wind.mdx index d00623a..29319ad 100644 --- a/docs/industries/energy-utilities/wind.mdx +++ b/docs/industries/energy-utilities/wind.mdx @@ -27,7 +27,7 @@ import intellistream_datahub_sdk, numpy as np, pandas as pd client = intellistream_datahub_sdk.DataHubClient.from_env() for s, u in [("turbine_t14_power_kw", "kw"), ("turbine_t14_wind_ms", "m_s")]: - client.timeseries.create([intellistream_datahub_sdk.TimeSeries(external_id=s, name=s, unit=u)]) + client.timeseries.create([intellistream_datahub_sdk.TimeSeries(value_type="float", external_id=s, name=s, unit=u)]) idx = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=168, freq="1h") wind = np.clip(np.random.weibull(2.0, 168) * 6.0, 0, 25) power = np.clip(wind**3 * 2.0, 0, 3500) # rated ~3500 kW @@ -65,9 +65,9 @@ client.timeseries().ingest(Map.of( import intellistream_datahub_sdk client.timeseries.create([ - intellistream_datahub_sdk.TimeSeries(external_id="turbine_t14_power_kw", + intellistream_datahub_sdk.TimeSeries(value_type="float", external_id="turbine_t14_power_kw", name="Turbine T14 power", unit="kw"), - intellistream_datahub_sdk.TimeSeries(external_id="turbine_t14_wind_ms", + intellistream_datahub_sdk.TimeSeries(value_type="float", external_id="turbine_t14_wind_ms", name="Turbine T14 wind speed", unit="m_s")]) client.timeseries.insert_from_lists( timestamps=timestamps, values=power_kw, ts="turbine_t14_power_kw") diff --git a/docs/industries/financial-services/portfolio-risk.mdx b/docs/industries/financial-services/portfolio-risk.mdx index 21b6706..42f5003 100644 --- a/docs/industries/financial-services/portfolio-risk.mdx +++ b/docs/industries/financial-services/portfolio-risk.mdx @@ -27,7 +27,7 @@ client = intellistream_datahub_sdk.DataHubClient.from_env() desk_id = client.datasets.create([intellistream_datahub_sdk.Dataset(external_id="desk_equities", name="Equities desk")])[0].id client.timeseries.create([intellistream_datahub_sdk.TimeSeries(external_id="portfolio_growth_mtm_usd", - name="Growth portfolio — MtM (USD)", unit="usd", value_type="numeric", data_set_id=desk_id)]) + name="Growth portfolio — MtM (USD)", unit="usd", value_type="float", data_set_id=desk_id)]) idx = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=30, freq="1d") client.timeseries.insert_from_lists(timestamps=idx, values=1e7 + np.cumsum(np.random.normal(0, 5e4, 30)), ts="portfolio_growth_mtm_usd") @@ -105,7 +105,7 @@ client.timeseries().ingest(Map.of( client.timeseries.create([intellistream_datahub_sdk.TimeSeries( external_id="portfolio_growth_mtm_usd", name="Growth portfolio — mark-to-market (USD)", - unit="usd", value_type="numeric", data_set_id=desk_id)]) + unit="usd", value_type="float", data_set_id=desk_id)]) client.timeseries.insert_from_lists( timestamps=[pd.Timestamp.now(tz="UTC")], values=[10428155.42], diff --git a/docs/industries/healthcare/medical-devices.mdx b/docs/industries/healthcare/medical-devices.mdx index 20b55bd..d1d1468 100644 --- a/docs/industries/healthcare/medical-devices.mdx +++ b/docs/industries/healthcare/medical-devices.mdx @@ -17,8 +17,8 @@ What we solve here is device availability and fast, complete recall tracing. ## Set up demo data -New workspace? Run this once (Python) to create three pumps of the same model in a ward — -so the recall walk in step 2 returns the full pull list. Safe to re-run. +New workspace? Run this once (Python) to create the model, the ward and two more pumps of +the same model in it. Step 1 registers a third, so the recall walk in step 2 returns all three. ```python import intellistream_datahub_sdk @@ -28,7 +28,7 @@ client = intellistream_datahub_sdk.DataHubClient.from_env() nodes = [intellistream_datahub_sdk.Resource(external_id="model_acme_x200", name="Acme X200 infusion pump", labels=["PumpModel"]), intellistream_datahub_sdk.Resource(external_id="ward_icu", name="Intensive care", labels=["Ward"])] edges = [] -for u in ["3391", "3392", "3401"]: +for u in ["3392", "3401"]: nodes.append(intellistream_datahub_sdk.Resource(external_id=f"pump_icu_{u}", name=f"Infusion pump {u}", labels=["Pump"])) edges += [intellistream_datahub_sdk.RelForm.by_external_ids(f"pump_icu_{u}", "ward_icu", "located_in"), intellistream_datahub_sdk.RelForm.by_external_ids(f"pump_icu_{u}", "model_acme_x200", "is_model")] diff --git a/docs/industries/manufacturing-process/discrete.mdx b/docs/industries/manufacturing-process/discrete.mdx index 0f25708..4dd01bd 100644 --- a/docs/industries/manufacturing-process/discrete.mdx +++ b/docs/industries/manufacturing-process/discrete.mdx @@ -65,7 +65,7 @@ client.timeseries().ingest(Map.of( import intellistream_datahub_sdk client.timeseries.create([ - intellistream_datahub_sdk.TimeSeries(external_id="station_07_cycle_time_s", + intellistream_datahub_sdk.TimeSeries(value_type="float", external_id="station_07_cycle_time_s", name="Station 07 cycle time", unit="s"), intellistream_datahub_sdk.TimeSeries(external_id="station_07_scrap", name="Station 07 scrap count", unit="count")]) diff --git a/docs/industries/manufacturing-process/pharma.mdx b/docs/industries/manufacturing-process/pharma.mdx index 89d05d8..46ed2e5 100644 --- a/docs/industries/manufacturing-process/pharma.mdx +++ b/docs/industries/manufacturing-process/pharma.mdx @@ -64,7 +64,7 @@ client.timeseries().ingest(Map.of( import intellistream_datahub_sdk, pandas as pd client.timeseries.create([intellistream_datahub_sdk.TimeSeries( - external_id="batch_22f_ph", name="Batch 22F — pH", unit="ph", value_type="numeric")]) + external_id="batch_22f_ph", name="Batch 22F — pH", unit="ph", value_type="float")]) client.timeseries.insert_from_lists( timestamps=[pd.Timestamp.now(tz="UTC")], values=[7.0421], ts="batch_22f_ph") diff --git a/docs/industries/manufacturing-process/semiconductor.mdx b/docs/industries/manufacturing-process/semiconductor.mdx index e607afe..0694f4a 100644 --- a/docs/industries/manufacturing-process/semiconductor.mdx +++ b/docs/industries/manufacturing-process/semiconductor.mdx @@ -67,7 +67,7 @@ import intellistream_datahub_sdk, pandas as pd client.timeseries.create([intellistream_datahub_sdk.TimeSeries( external_id="tool_etch_07_chamber_pressure_mtorr", - name="Etch-07 chamber pressure", unit="mtorr", value_type="numeric")]) + name="Etch-07 chamber pressure", unit="mtorr", value_type="float")]) client.timeseries.insert_from_lists( timestamps=[pd.Timestamp.now(tz="UTC")], values=[12.0436], ts="tool_etch_07_chamber_pressure_mtorr") diff --git a/docs/industries/transport-logistics/aerospace.mdx b/docs/industries/transport-logistics/aerospace.mdx index 75276ec..b70bacf 100644 --- a/docs/industries/transport-logistics/aerospace.mdx +++ b/docs/industries/transport-logistics/aerospace.mdx @@ -19,8 +19,9 @@ and when one component looks suspect, walk the graph to its whole blast radius. ## Set up demo data -New workspace? Run this once (Python) to create three aircraft whose pumps are all the same -part type — so the blast-radius walk in step 2 returns the whole affected fleet. Safe to re-run. +New workspace? Run this once (Python) to create the part type and two aircraft whose pumps +are of that type. Step 1 adds a third, so the blast-radius walk in step 2 returns the whole +affected fleet. ```python import intellistream_datahub_sdk @@ -29,7 +30,7 @@ client = intellistream_datahub_sdk.DataHubClient.from_env() nodes = [intellistream_datahub_sdk.Resource(external_id="part_type_hp_47", name="Hydraulic pump type HP-47", labels=["part_type_hp_47"])] edges = [] -for ac in ["ln_312", "ln_318", "ln_401"]: +for ac in ["ln_318", "ln_401"]: nodes += [intellistream_datahub_sdk.Resource(external_id=f"aircraft_{ac}", name=f"Aircraft {ac}", labels=[f"aircraft_{ac}"]), intellistream_datahub_sdk.Resource(external_id=f"hyd_pump_{ac}_1", name=f"Pump {ac}", labels=[f"hyd_pump_{ac}_1"])] edges += [intellistream_datahub_sdk.RelForm.by_external_ids(f"aircraft_{ac}", f"hyd_pump_{ac}_1", "contains"), diff --git a/docs/reference/datasets.md b/docs/reference/datasets.md index f39ccde..494b709 100644 --- a/docs/reference/datasets.md +++ b/docs/reference/datasets.md @@ -80,7 +80,7 @@ failing. `GET /datasets/{id}` reads one data set by numeric id and answers `404` ```java DataWrapper some = client.datasets() .byIds(List.of(IdCollection.createFromExternalId("plant_a"))); -DataWrapper one = client.datasets().getById(5677892L); +DataWrapper one = client.datasets().getById(some.getItems().get(0).getId()); client.datasets().delete(List.of(IdCollection.createFromExternalId("plant_a"))); ``` @@ -90,7 +90,7 @@ client.datasets().delete(List.of(IdCollection.createFromExternalId("plant_a"))); ```python some = client.datasets.by_ids(["plant_a"]) -one = client.datasets.get_by_id(5677892) +one = client.datasets.get_by_id(some[0].id) client.datasets.delete(["plant_a"]) ``` @@ -101,7 +101,7 @@ client.datasets.delete(["plant_a"]) use intellistream_datahub_sdk::generic::IdAndExtId; let some = api.datasets.by_ids(&vec![IdAndExtId::from_external_id("plant_a")]).await?; -let one = api.datasets.get_by_id(5677892).await?; +let one = api.datasets.get_by_id(some.get_items()[0].id.unwrap()).await?; api.datasets.delete(&vec![IdAndExtId::from_external_id("plant_a")]).await?; ``` diff --git a/docs/reference/events.md b/docs/reference/events.md index 022ed63..535d3d3 100644 --- a/docs/reference/events.md +++ b/docs/reference/events.md @@ -172,6 +172,8 @@ DataWrapper events = client.events().byIds(List.of( ```python +import uuid + events = client.events.by_ids(["PO-4500171"]) # a str selects by external id events = client.events.by_ids([uuid.UUID("0195f3a2-4c1b-7f9e-9c3a-1b2d4e6f8a90")]) ``` diff --git a/docs/tutorial.mdx b/docs/tutorial.mdx index 54dce68..e7aa9e5 100644 --- a/docs/tutorial.mdx +++ b/docs/tutorial.mdx @@ -158,6 +158,7 @@ DatahubClient client = DatahubClient.create(DatahubConfig.builder() ```python +import intellistream_datahub_sdk from intellistream_datahub_sdk import DataHubClient client = DataHubClient( diff --git a/doctests/plans/reference__datasets.toml b/doctests/plans/reference__datasets.toml index d090029..49766c0 100644 --- a/doctests/plans/reference__datasets.toml +++ b/doctests/plans/reference__datasets.toml @@ -30,6 +30,14 @@ import intellistream_datahub_sdk client = intellistream_datahub_sdk.DataHubClient.from_env() """ +# The lookup example reads the plant_a a reader made in the Create example; independent +# blocks each start from a swept backend, so it is made again here. +[[python.inject]] +before = 2 +code = """ +client.datasets.create([intellistream_datahub_sdk.Dataset(external_id="plant_a", name="Plant A")]) +""" + [owns] datasets = ["plant_a"] diff --git a/doctests/plans/reference__events.toml b/doctests/plans/reference__events.toml index 7e86b7e..2b262dd 100644 --- a/doctests/plans/reference__events.toml +++ b/doctests/plans/reference__events.toml @@ -36,9 +36,26 @@ import intellistream_datahub_sdk client = intellistream_datahub_sdk.DataHubClient.from_env() """ +# "Fold the stream" continues from the filter example above it, which defines `findings`; +# independent blocks each start fresh, so the reader's previous step is supplied here. +[[python.inject]] +before = 6 +code = """ +findings = client.events.filter(type="policy_finding", limit=200) +""" + +# "High-throughput ingestion" sends `events`, the reader's own batch. +[[python.inject]] +before = 7 +code = """ +from datetime import datetime, timezone +events = [intellistream_datahub_sdk.Event(external_id="door_open", type="door_open", + event_time=datetime.now(timezone.utc))] +""" + [owns] events = ["door_open"] # The create example must leave its event behind. -[expect] -events = ["door_open"] +# No [expect]: the last example deletes the event the first one creates, so nothing is +# meant to outlive the run. The proof is that every example executed cleanly. diff --git a/doctests/plans/reference__resources.toml b/doctests/plans/reference__resources.toml index 93827b0..e8274e8 100644 --- a/doctests/plans/reference__resources.toml +++ b/doctests/plans/reference__resources.toml @@ -30,6 +30,9 @@ timeout = 600 # needs — so each block runs on its own. independent = true +# The traversal example walks out from `sensor_a`, which the sandbox page seeds. +requires_once = ["guides__seed-a-sandbox-data"] + # Each example needs a client. Composing the quickstart in to get one would also # create its `engine_temperature`, which several of these pages create themselves; # `from_env()` is the constructor this section documents, so this is the reader's diff --git a/doctests/plans/tutorial.toml b/doctests/plans/tutorial.toml index d9b61d4..90d8dbe 100644 --- a/doctests/plans/tutorial.toml +++ b/doctests/plans/tutorial.toml @@ -29,10 +29,8 @@ requires_python = ["psutil"] # and `host` in narrative and names psutil in its install step, so a reader has # them by Step 2; the harness supplies exactly that and nothing more. # -# Deliberately NOT supplied: the SDK import. Step 2 calls `datahub_sdk.TimeSeries` -# but no step-path block ever imports it — only the complete listing at the bottom -# does. Handing it over here would hide that gap, which is precisely the kind of -# thing this suite exists to surface. +# Deliberately NOT supplied: the SDK import. Step 1 imports it, and handing it over +# here would hide the gap if that line went missing again. imports = """ import os import socket