Skip to content

Commit dc76513

Browse files
authored
feat(simulators): Add support for running simulations using revisions (#2339)
1 parent c49d2b8 commit dc76513

6 files changed

Lines changed: 199 additions & 22 deletions

File tree

cognite/client/_api/simulators/routines.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,22 +231,58 @@ def list(
231231
filter=routines_filter.dump(),
232232
)
233233

234+
@overload
234235
def run(
235236
self,
237+
*,
236238
routine_external_id: str,
237239
inputs: Sequence[SimulationInputOverride] | None = None,
238240
run_time: int | None = None,
239241
queue: bool | None = None,
240242
log_severity: Literal["Debug", "Information", "Warning", "Error"] | None = None,
241243
wait: bool = True,
242244
timeout: float = 60,
245+
) -> SimulationRun: ...
246+
247+
@overload
248+
def run(
249+
self,
250+
*,
251+
routine_revision_external_id: str,
252+
model_revision_external_id: str,
253+
inputs: Sequence[SimulationInputOverride] | None = None,
254+
run_time: int | None = None,
255+
queue: bool | None = None,
256+
log_severity: Literal["Debug", "Information", "Warning", "Error"] | None = None,
257+
wait: bool = True,
258+
timeout: float = 60,
259+
) -> SimulationRun: ...
260+
261+
def run(
262+
self,
263+
routine_external_id: str | None = None,
264+
routine_revision_external_id: str | None = None,
265+
model_revision_external_id: str | None = None,
266+
inputs: Sequence[SimulationInputOverride] | None = None,
267+
run_time: int | None = None,
268+
queue: bool | None = None,
269+
log_severity: Literal["Debug", "Information", "Warning", "Error"] | None = None,
270+
wait: bool = True,
271+
timeout: float = 60,
243272
) -> SimulationRun:
244273
"""`Run a simulation <https://developer.cognite.com/api#tag/Simulation-Runs/operation/run_simulation_simulators_run_post>`_
245274
246-
Run a simulation for a given simulator routine.
275+
Run a simulation for a given simulator routine. Supports two modes:
276+
1. By routine external ID only
277+
2. By routine revision external ID + model revision external ID
247278
248279
Args:
249-
routine_external_id (str): External id of the simulator routine to run
280+
routine_external_id (str | None): External id of the simulator routine to run.
281+
Cannot be specified together with routine_revision_external_id and model_revision_external_id.
282+
routine_revision_external_id (str | None): External id of the simulator routine revision to run.
283+
Must be specified together with model_revision_external_id.
284+
model_revision_external_id (str | None): External id of the simulator model revision.
285+
Must be specified together with routine_revision_external_id.
250286
inputs (Sequence[SimulationInputOverride] | None): List of input overrides
251287
run_time (int | None): Run time in milliseconds. Reference timestamp used for data pre-processing and data sampling.
252288
queue (bool | None): Queue the simulation run when connector is down.
@@ -258,17 +294,25 @@ def run(
258294
SimulationRun: Created simulation run
259295
260296
Examples:
261-
Create new simulation run:
297+
Create new simulation run using routine external ID:
262298
>>> from cognite.client import CogniteClient
263299
>>> client = CogniteClient()
264300
>>> run = client.simulators.routines.run(
265301
... routine_external_id="routine1",
266302
... log_severity="Debug"
267303
... )
304+
305+
Create new simulation run using routine and model revision external IDs:
306+
>>> run = client.simulators.routines.run(
307+
... routine_revision_external_id="routine_revision1",
308+
... model_revision_external_id="model_revision1",
309+
... )
268310
"""
269311
self._warning.warn()
270312
run_object = SimulationRunWrite(
271313
routine_external_id=routine_external_id,
314+
routine_revision_external_id=routine_revision_external_id,
315+
model_revision_external_id=model_revision_external_id,
272316
inputs=list(inputs) if inputs is not None else None,
273317
run_time=run_time,
274318
queue=queue,

cognite/client/data_classes/simulators/runs.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,20 +87,33 @@ class SimulationRunCore(WriteableCogniteResource["SimulationRunWrite"]):
8787
def __init__(
8888
self,
8989
run_type: str | None,
90-
routine_external_id: str,
90+
routine_external_id: str | None,
9191
run_time: int | None = None,
92+
routine_revision_external_id: str | None = None,
93+
model_revision_external_id: str | None = None,
9294
) -> None:
9395
self.run_type = run_type
9496
self.run_time = run_time
9597
self.routine_external_id = routine_external_id
98+
self.routine_revision_external_id = routine_revision_external_id
99+
self.model_revision_external_id = model_revision_external_id
96100

97101

98102
class SimulationRunWrite(SimulationRunCore):
99103
"""
100104
Request to run a simulator routine asynchronously.
101105
106+
This class supports two modes of running simulations:
107+
1. By routine external ID only
108+
2. By routine revision external ID + model revision external ID
109+
102110
Args:
103-
routine_external_id (str): External id of the associated simulator routine
111+
routine_external_id (str | None): External id of the associated simulator routine.
112+
Cannot be specified together with routine_revision_external_id and model_revision_external_id.
113+
routine_revision_external_id (str | None): External id of the associated simulator routine revision.
114+
Must be specified together with model_revision_external_id.
115+
model_revision_external_id (str | None): External id of the associated simulator model revision.
116+
Must be specified together with routine_revision_external_id.
104117
run_type (str | None): The type of the simulation run
105118
run_time (int | None): Run time in milliseconds. Reference timestamp used for data pre-processing and data sampling.
106119
queue (bool | None): Queue the simulation run when connector is down.
@@ -110,15 +123,34 @@ class SimulationRunWrite(SimulationRunCore):
110123

111124
def __init__(
112125
self,
113-
routine_external_id: str,
126+
routine_external_id: str | None = None,
127+
routine_revision_external_id: str | None = None,
128+
model_revision_external_id: str | None = None,
114129
run_type: str | None = None,
115130
run_time: int | None = None,
116131
queue: bool | None = None,
117132
log_severity: str | None = None,
118133
inputs: list[SimulationInputOverride] | None = None,
119134
) -> None:
135+
is_routine_mode = routine_external_id and not routine_revision_external_id and not model_revision_external_id
136+
is_revision_mode = (
137+
not routine_external_id
138+
and routine_revision_external_id is not None
139+
and model_revision_external_id is not None
140+
)
141+
142+
# Validate that either routine_external_id OR (routine_revision_external_id + model_revision_external_id) is provided
143+
if not (is_routine_mode or is_revision_mode):
144+
param_error = ValueError(
145+
"Must specify either 'routine_external_id' alone, or both "
146+
"'routine_revision_external_id' and 'model_revision_external_id' together."
147+
)
148+
raise param_error
149+
120150
super().__init__(
121151
routine_external_id=routine_external_id,
152+
routine_revision_external_id=routine_revision_external_id,
153+
model_revision_external_id=model_revision_external_id,
122154
run_type=run_type,
123155
run_time=run_time,
124156
)
@@ -128,20 +160,36 @@ def __init__(
128160

129161
@classmethod
130162
def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> SimulationRunWrite:
131-
inputs = resource.get("inputs", None)
163+
inputs = resource.get("inputs")
164+
routine_revision_external_id = resource.get("routineRevisionExternalId")
165+
model_revision_external_id = resource.get("modelRevisionExternalId")
166+
routine_external_id = resource.get("routineExternalId")
167+
132168
return cls(
133169
run_type=resource.get("runType"),
134-
routine_external_id=resource["routineExternalId"],
135170
run_time=resource.get("runTime"),
136171
queue=resource.get("queue"),
137172
log_severity=resource.get("logSeverity"),
138173
inputs=([SimulationInputOverride._load(_input) for _input in inputs] if inputs else None),
174+
routine_external_id=routine_external_id,
175+
routine_revision_external_id=routine_revision_external_id,
176+
model_revision_external_id=model_revision_external_id,
139177
)
140178

141179
def dump(self, camel_case: bool = True) -> dict[str, Any]:
142180
output = super().dump(camel_case=camel_case)
143181
if self.inputs is not None:
144182
output["inputs"] = [input_.dump(camel_case=camel_case) for input_ in self.inputs]
183+
184+
# Remove fields based on the mode we're in
185+
if self.routine_external_id is not None:
186+
# Routine-only mode: remove revision fields that might be None
187+
output.pop("routineRevisionExternalId", None)
188+
output.pop("modelRevisionExternalId", None)
189+
else:
190+
# Revision mode: remove routine_external_id
191+
output.pop("routineExternalId", None)
192+
145193
return output
146194

147195
def as_write(self) -> SimulationRunWrite:

tests/tests_integration/test_api/test_simulators/test_routines.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
from cognite.client._cognite_client import CogniteClient
66
from cognite.client.data_classes.simulators.filters import PropertySort
7+
from cognite.client.data_classes.simulators.routine_revisions import SimulatorRoutineRevision
78
from cognite.client.data_classes.simulators.routines import SimulatorRoutine, SimulatorRoutineWrite
89
from cognite.client.utils._text import random_string
9-
from tests.tests_integration.test_api.test_simulators.seed.data import ResourceNames
10+
from tests.tests_integration.test_api.test_simulators.seed.data import (
11+
ResourceNames,
12+
)
1013

1114

1215
@pytest.mark.usefixtures(
@@ -55,6 +58,34 @@ def test_sort(
5558
for i in range(1, len(routines_asc)):
5659
assert routines_asc[i].created_time >= routines_asc[i - 1].created_time
5760

61+
62+
@pytest.mark.usefixtures(
63+
"seed_resource_names",
64+
"seed_simulator_routine_revisions",
65+
)
66+
class TestSimulatorRoutinesRunWithRevisions:
67+
def test_run_with_revisions(
68+
self,
69+
cognite_client: CogniteClient,
70+
seed_resource_names: ResourceNames,
71+
seed_simulator_routine_revisions: tuple[SimulatorRoutineRevision, SimulatorRoutineRevision],
72+
) -> None:
73+
"""Test running a simulation using routine and model revision external IDs."""
74+
routine_revision_external_id = seed_simulator_routine_revisions[0].external_id
75+
model_revision_external_id = seed_resource_names.simulator_model_revision_external_id
76+
simulator_integration_unique_external_id = seed_resource_names.simulator_integration_external_id
77+
78+
# Run simulation using revision external IDs
79+
run = cognite_client.simulators.routines.run(
80+
routine_revision_external_id=routine_revision_external_id,
81+
model_revision_external_id=model_revision_external_id,
82+
wait=False, # Don't wait to avoid timeout in tests
83+
)
84+
85+
assert run is not None
86+
assert run.id is not None
87+
assert run.routine_revision_external_id == routine_revision_external_id
88+
assert run.model_revision_external_id == model_revision_external_id
5889
# Test iterator with sort
5990
routines_iter = list(
6091
cognite_client.simulators.routines(

tests/tests_integration/test_api/test_simulators/test_runs.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@
77
from cognite.client._cognite_client import CogniteClient
88
from cognite.client.data_classes import TimestampRange
99
from cognite.client.data_classes.simulators.filters import SimulationRunsSort
10-
from cognite.client.data_classes.simulators.routine_revisions import SimulatorRoutineRevision
1110
from cognite.client.data_classes.simulators.runs import (
1211
SimulationInput,
1312
SimulationOutput,
1413
SimulationRun,
1514
SimulationRunWrite,
1615
SimulationValueUnitName,
1716
)
18-
from tests.tests_integration.test_api.test_simulators.seed.data import ResourceNames
17+
from tests.tests_integration.test_api.test_simulators.seed.data import RESOURCES, ResourceNames
1918

2019

2120
@pytest.mark.usefixtures("seed_resource_names", "seed_simulator_routine_revisions")
@@ -113,23 +112,42 @@ async def test_run_with_wait_and_retrieve(
113112
assert data_res is not None
114113
assert data_res.dump() == data_res2.dump()
115114

115+
@pytest.mark.parametrize(
116+
"simulation_run_item",
117+
[
118+
(
119+
SimulationRunWrite(
120+
routine_revision_external_id=f"{RESOURCES.simulator_routine_external_id}_v1",
121+
model_revision_external_id=RESOURCES.simulator_model_revision_external_id,
122+
)
123+
),
124+
(
125+
SimulationRunWrite(
126+
routine_external_id=RESOURCES.simulator_routine_external_id,
127+
)
128+
),
129+
],
130+
ids=[
131+
"with_routine_revision_and_model_revision",
132+
"with_routine_only",
133+
],
134+
)
116135
def test_create_run(
117136
self,
118137
cognite_client: CogniteClient,
119-
seed_simulator_routine_revisions: tuple[SimulatorRoutineRevision, SimulatorRoutineRevision],
120-
seed_resource_names: ResourceNames,
138+
simulation_run_item: SimulationRunWrite,
121139
) -> None:
122-
routine_external_id = seed_resource_names.simulator_routine_external_id
123-
created_runs = cognite_client.simulators.runs.create(
124-
[
125-
SimulationRunWrite(
126-
run_type="external",
127-
routine_external_id=routine_external_id,
128-
)
129-
]
140+
created_runs = cognite_client.simulators.runs.create([simulation_run_item])
141+
is_run_by_revision = (
142+
simulation_run_item.routine_revision_external_id is not None
143+
and simulation_run_item.model_revision_external_id is not None
130144
)
145+
if is_run_by_revision:
146+
assert created_runs[0].routine_revision_external_id == simulation_run_item.routine_revision_external_id
147+
assert created_runs[0].model_revision_external_id == simulation_run_item.model_revision_external_id
148+
else:
149+
assert created_runs[0].routine_external_id == simulation_run_item.routine_external_id
131150
assert len(created_runs) == 1
132-
assert created_runs[0].routine_external_id == routine_external_id
133151
assert created_runs[0].id is not None
134152

135153
def test_list_filtering_timestamp_ranges(

tests/tests_unit/test_data_classes/test_simulators.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import re
4+
35
import pytest
46

57
from cognite.client.data_classes.simulators.models import (
@@ -22,6 +24,7 @@
2224
SimulationOutput,
2325
SimulationRunDataItem,
2426
SimulationRunDataList,
27+
SimulationRunWrite,
2528
SimulationValueUnitName,
2629
)
2730
from cognite.client.data_classes.simulators.simulators import (
@@ -464,3 +467,25 @@ def test_get_units_empty_units_list(self) -> None:
464467
units = simulator.get_units("empty")
465468
assert units == []
466469
assert len(units) == 0
470+
471+
472+
class TestSimulationRunWrite:
473+
def test_error_handling(self) -> None:
474+
error_message = "Must specify either 'routine_external_id' alone, or both 'routine_revision_external_id' and 'model_revision_external_id' together."
475+
with pytest.raises(
476+
ValueError,
477+
match=re.escape(error_message),
478+
):
479+
SimulationRunWrite(
480+
routine_external_id="routine_external_id_1",
481+
routine_revision_external_id="routine_revision_external_id_1",
482+
model_revision_external_id="model_revision_external_id_1",
483+
)
484+
485+
with pytest.raises(
486+
ValueError,
487+
match=re.escape(error_message),
488+
):
489+
SimulationRunWrite(
490+
routine_revision_external_id="routine_revision_external_id_1",
491+
)

tests/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
from cognite.client.data_classes.filters import Filter
5454
from cognite.client.data_classes.hosted_extractors.jobs import BodyLoad, NextUrlLoad, RestConfig
5555
from cognite.client.data_classes.simulators.routine_revisions import SimulatorRoutineStepArguments
56+
from cognite.client.data_classes.simulators.runs import SimulationRunWrite
5657
from cognite.client.data_classes.transformations.notifications import TransformationNotificationWrite
5758
from cognite.client.data_classes.transformations.schedules import TransformationScheduleWrite
5859
from cognite.client.data_classes.transformations.schema import TransformationSchemaUnknownType
@@ -485,6 +486,16 @@ def create_instance(self, resource_cls: type[T_Object], skip_defaulted_args: boo
485486
keyword_arguments.pop("max_list_size", None)
486487
elif resource_cls is SimulatorRoutineStepArguments:
487488
keyword_arguments = {"data": {"reference_id": self._random_string(50), "arg2": self._random_string(50)}}
489+
elif resource_cls is SimulationRunWrite:
490+
# SimulationRunWrite requires either routine_external_id alone OR both routine_revision_external_id and model_revision_external_id
491+
if self._random.choice([True, False]):
492+
# Use routine_external_id only
493+
keyword_arguments.pop("routine_revision_external_id", None)
494+
keyword_arguments.pop("model_revision_external_id", None)
495+
keyword_arguments["routine_external_id"] = self._random_string(50)
496+
else:
497+
# Use revision-based parameters
498+
keyword_arguments.pop("routine_external_id", None)
488499

489500
return resource_cls(*positional_arguments, **keyword_arguments)
490501

0 commit comments

Comments
 (0)