Repository navigation
Conversation
Request and result types for the upcoming ai/timeseries/{forecast,impute}
endpoints:
- InputDatapoint / InputTimeSeries: request objects (dataclasses, like
DatapointsQuery). Datapoints can be InputDatapoint, (timestamp, value)
tuples or API-shaped dicts; timestamps accept ms, datetime or time-shift
strings.
- QuantileDatapoint: one step with quantile level -> value. Non-numeric keys
are ignored on load.
- ForecastResult(List) / ImputeResult(List): one result per input label, with
quantile_levels and cohorts from the response metadata (like NodeList's
typing/debug), lookup by .get(label=...), and to_pandas() with a
DatetimeIndex.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ea1105b to
12e6a69
Compare
There was a problem hiding this comment.
Code Review
This pull request introduces new data classes and helper functions in cognite/client/data_classes/ai.py to support forecasting and imputation features, including InputDatapoint, InputTimeSeries, QuantileDatapoint, ForecastResult, ForecastResultList, ImputeResult, and ImputeResultList, along with corresponding unit tests. Feedback on the changes includes handling empty inputs in _concat_columns to prevent pandas errors, supporting lists in addition to tuples for input datapoints, and safely handling potential null metadata values in API responses to avoid attribute errors.
pd.concat raised "No objects to concatenate" for a ForecastResultList or ImputeResultList with no results (e.g. an empty slice). Other SDK lists return an empty DataFrame, so do the same. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Re @niekbeckers' question on reusing — Written by Claude |
Direct pd.to_datetime calls are only allowed in _pandas_helpers.py (enforced by test_pandas_timestamp_constructors_only_used_in_pandas_helpers), so build the empty DatetimeIndex with to_pandas_datetime_index instead. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #2873 +/- ##
==========================================
+ Coverage 93.49% 93.73% +0.24%
==========================================
Files 527 541 +14
Lines 55489 57404 +1915
==========================================
+ Hits 51879 53808 +1929
+ Misses 3610 3596 -14
🚀 New features to boost your workflow:
|
|
|
||
|
|
||
| @dataclass | ||
| class InputDatapoint: |
There was a problem hiding this comment.
I think we need a more specific name that's tied to its use, e.g. ForecastInputDatapoint.
There was a problem hiding this comment.
I guess this is also used for /impute 🤔 Consider just
ImputeInputDatapoint = ForecastInputDatapoint
There was a problem hiding this comment.
Renamed to ForecastInputDatapoint / ForecastInputTimeSeries in f71110f. I skipped the Impute alias: impute takes the same request body, and two names for one class would make the docs and isinstance checks confusing. The docstrings say the classes are used for both.
| Args: | ||
| timestamp (int | float | str | datetime): Milliseconds since epoch, a datetime, or a time-shift string like "2d-ago". | ||
| value (float | None): The observed value, or None when there is no reading. | ||
| missing (bool): Hide this point from the model. For impute, the missing points are the ones reconstructed. |
There was a problem hiding this comment.
Value being None and/or missing being True: this needs more text/explanations imo
|
|
||
| Args: | ||
| label (str): Caller-chosen identifier, echoed back in the result. | ||
| datapoints (Sequence[InputDatapoint | tuple[int | float | str | datetime, float | None] | dict[str, Any]]): |
There was a problem hiding this comment.
Let's go with InputDatapoint only. InputDatapoint can then have a load method that is very accepting of different formats.
There was a problem hiding this comment.
Done in d04aa67: datapoints takes ForecastInputDatapoint only.
| def dump(self, camel_case: bool = True) -> dict[str, Any]: | ||
| output: dict[str, Any] = { | ||
| "label": self.label, | ||
| "datapoints": [_to_input_datapoint(dp).dump(camel_case) for dp in self.datapoints], |
There was a problem hiding this comment.
_to_input_datapoint should be a load method on the class
There was a problem hiding this comment.
Added ForecastInputDatapoint.load in d04aa67. It accepts tuples, lists and API-style dicts.
| cohort (str | None): Series sharing a cohort are forecast jointly and must have identical timestamps. | ||
| Omit it to forecast the series independently. |
There was a problem hiding this comment.
This needs to explain how the user uses the setting
| # Only numeric keys are quantile levels; ignore anything else, like unknown fields elsewhere in the SDK. | ||
| quantiles = {float(level): value for level, value in resource["quantiles"].items() if _is_number(level)} |
There was a problem hiding this comment.
Are these fields really unknown? Is there a pattern for example, like key starting with quantile or a number or something? We can not decide this based on "is number" imo
There was a problem hiding this comment.
No: the API keys quantiles by level only ("0.50", or "0.025" when more decimals are needed). Every key is now parsed, 6216571.
| metadata = response.get("metadata", {}) | ||
| return cls( | ||
| [ForecastResult._load(item) for item in response["timeSeries"]], | ||
| quantile_levels=[float(q) for q in metadata.get("quantileLevels", [])], |
There was a problem hiding this comment.
Why do you need to call float? Are you passing number as strings in the payload?
There was a problem hiding this comment.
No need, the levels are numbers. Removed in 2d5e38a.
| def get( | ||
| self, | ||
| id: int | None = None, | ||
| external_id: str | None = None, | ||
| instance_id: InstanceId | tuple[str, str] | None = None, | ||
| *, | ||
| label: str | None = None, | ||
| ) -> ForecastResult | None: |
There was a problem hiding this comment.
ForecastResult has no identifier, only label. Thus I think you should
| def get( | |
| self, | |
| id: int | None = None, | |
| external_id: str | None = None, | |
| instance_id: InstanceId | tuple[str, str] | None = None, | |
| *, | |
| label: str | None = None, | |
| ) -> ForecastResult | None: | |
| def get( | |
| self, | |
| label: str | None = None, | |
| ) -> ForecastResult | None: # with a type ignore-override-something |
| Returns: | ||
| pd.DataFrame: The forecasts. | ||
| """ | ||
| return _concat_columns({r.label: _points_to_pandas(r.forecast, self.quantile_levels) for r in self.data}) |
There was a problem hiding this comment.
This does not handle repeat labels, either verify or handle duplicates like the datapoints retrieve methods do
| cohorts=metadata.get("cohorts", []), | ||
| ) | ||
|
|
||
| def get( |
There was a problem hiding this comment.
Same comment here, lets go label only
The forecast and impute endpoints return their series under "items", like other CDF list endpoints, rather than "timeSeries". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ForecastInputTimeSeries InputDatapoint and InputTimeSeries were too generic for data_classes.ai, which also holds the document AI classes. The forecast and impute endpoints take the same request body, so both use these classes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a load method ForecastInputTimeSeries.datapoints accepted datapoints, tuples and dicts, which widened its type for little gain. It now takes ForecastInputDatapoint only, and ForecastInputDatapoint.load creates one from a tuple, a list or an API-style dict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API keys quantiles by their level only ("0.50", or more decimals when
needed, like "0.025"), so there are no other keys to skip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
metadata, with quantileLevels and cohorts, is always in the response, and the levels are numbers already, so there is nothing to default or convert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Results have no id, external id or instance id, so get takes just the label. Labels are unique, since duplicate labels in a request are rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_handle_unknown_arguments_when_loading adds a made-up key to every dict in a resource. Every key in a quantiles dict must be a quantile level, like "0.50", so loading fails on the made-up key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @haakonvt for the useful feedback! It's addressed in 8772cc0e..cb8eba8b, with a reply on each thread. One question is still open, on the The range also includes a recent API change: the array of series is now called |
Description
First PR in a stack that adds SDK support for the
ai/timeseries/forecastandai/timeseries/imputeendpoints. The API is in beta.The stack builds the feature in two layers, both under
client.ai.time_series:client.ai.time_series.forecast/.impute: a low-level client that mirrors the API. PRs 1–4.client.ai.time_series.forecast_with_retrieval/.impute_with_retrieval: convenience methods that work on CDF time series by instance id. PRs 5–8. They read history through the existingclient.time_series.data.retrieve.This PR only adds data classes, with no API calls yet:
InputDatapointandInputTimeSeries, the request objects. They're dataclasses, likeDatapointsQuery.QuantileDatapoint.ForecastResult(List)andImputeResult(List). The lists carryquantile_levelsandcohortsfrom the response metadata, the wayNodeListcarriestyping/debug. They support.get(label=...)andto_pandas().Results are
CogniteResourcesubclasses, unlike the existing document-AI classes indata_classes/ai.py. That gives them dump/load, notebook rendering and list lookups for free.Where the stack is going
The interface once all 8 PRs are in:
What this PR adds, on its own:
Checklist:
test_data_classes/test_ai.py. The pandas tests are markeddsl.🤖 Generated with Claude Code