gridappsd-python 2026.2.1__tar.gz → 2026.2.1a2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/PKG-INFO +1 -1
  2. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/app_registration.py +35 -10
  3. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/goss.py +47 -53
  4. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/gridappsd.py +20 -13
  5. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/simulation.py +41 -23
  6. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/timeseries.py +16 -22
  7. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/pyproject.toml +1 -1
  8. gridappsd_python-2026.2.1a2/tests/test_app_registration.py +63 -0
  9. gridappsd_python-2026.2.1a2/tests/test_goss.py +128 -0
  10. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/tests/test_gridappsd.py +71 -2
  11. gridappsd_python-2026.2.1a2/tests/test_simulation.py +76 -0
  12. gridappsd_python-2026.2.1a2/tests/test_simulation_commands.py +93 -0
  13. gridappsd_python-2026.2.1a2/tests/test_timeseries.py +66 -0
  14. gridappsd_python-2026.2.1/tests/test_goss.py +0 -204
  15. gridappsd_python-2026.2.1/tests/test_simulation.py +0 -65
  16. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/.gitignore +0 -0
  17. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/CHANGELOG.md +0 -0
  18. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/README.md +0 -0
  19. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/conf/entrypoint.sh +0 -0
  20. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/conf/run-gridappsd.sh +0 -0
  21. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/example.env +0 -0
  22. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/__init__.py +0 -0
  23. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/__main__.py +0 -0
  24. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/difference_builder.py +0 -0
  25. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/docker_handler.py +0 -0
  26. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/field_interface/__init__.py +0 -0
  27. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/houses.py +0 -0
  28. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/json_extension.py +0 -0
  29. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/loghandler.py +0 -0
  30. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/register_app.py +0 -0
  31. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/topics.py +0 -0
  32. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd/utils.py +0 -0
  33. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/gridappsd_python_testing.ipynb +0 -0
  34. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/info/CHANGELOG.md +0 -0
  35. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/info/VERSION +0 -0
  36. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/poetry.toml +0 -0
  37. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/tests/conftest.py +0 -0
  38. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/tests/run_gridappsd.py +0 -0
  39. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/tests/simulation_fixtures/13_node_2_min_base.json +0 -0
  40. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/tests/test_containers.py +0 -0
  41. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/tests/test_difference_builder.py +0 -0
  42. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/tests/test_docker_handler.py +0 -0
  43. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/tests/test_houses.py +0 -0
  44. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/tests/test_json.py +0 -0
  45. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/tests/test_logging.py +0 -0
  46. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/tests/test_logging_integration.py +0 -0
  47. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/tests/test_topics.py +0 -0
  48. {gridappsd_python-2026.2.1 → gridappsd_python-2026.2.1a2}/tests/unittest_test_template.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridappsd-python
3
- Version: 2026.2.1
3
+ Version: 2026.2.1a2
4
4
  Summary: A GridAPPS-D Python Adapter
5
5
  Project-URL: Repository, https://github.com/GRIDAPPSD/gridappsd-python
6
6
  Project-URL: Homepage, https://gridappsd.readthedocs.io
@@ -1,6 +1,7 @@
1
1
  # import json
2
2
  import logging
3
3
  import os
4
+ from enum import Enum
4
5
  from queue import Queue
5
6
  import time
6
7
  import subprocess
@@ -14,6 +15,30 @@ from . import utils, json_extension as json
14
15
 
15
16
  _log = logging.getLogger(__name__)
16
17
 
18
+ GRIDAPPSD_APPLICATION_STATUS = "GRIDAPPSD_APPLICATION_STATUS"
19
+
20
+
21
+ class ApplicationStatusEnum(Enum):
22
+ """Values this module writes to the GRIDAPPSD_APPLICATION_STATUS environment variable.
23
+
24
+ This is a distinct enum from gridappsd.utils.ProcessStatusEnum: this module's
25
+ STOPPED value has no equivalent in ProcessStatusEnum (which has CLOSED
26
+ instead), so reusing that enum here would either drop STOPPED or introduce
27
+ a mismatch between the value written and the value a reader expects.
28
+ """
29
+
30
+ STARTING = "STARTING"
31
+ STOPPING = "STOPPING"
32
+ RUNNING = "RUNNING"
33
+ STOPPED = "STOPPED"
34
+ ERROR = "ERROR"
35
+
36
+
37
+ def _set_application_status(status: ApplicationStatusEnum) -> None:
38
+ """Write status to the GRIDAPPSD_APPLICATION_STATUS environment variable."""
39
+ os.environ[GRIDAPPSD_APPLICATION_STATUS] = status.value
40
+
41
+
17
42
  # determine OS type
18
43
  posix = False
19
44
  if os.name == "posix":
@@ -35,20 +60,20 @@ class Job(threading.Thread):
35
60
  def run(self):
36
61
  try:
37
62
  self.running = True
38
- os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "RUNNING"
63
+ _set_application_status(ApplicationStatusEnum.RUNNING)
39
64
 
40
65
  p = subprocess.Popen(args=self._args, shell=False, stdout=self._out, stderr=self._err)
41
66
 
42
67
  # Loop while process is executing
43
68
  while p.poll() is None and self.running:
44
- os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "RUNNING"
69
+ _set_application_status(ApplicationStatusEnum.RUNNING)
45
70
  time.sleep(1)
46
71
 
47
72
  except Exception as e:
48
- os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "ERROR"
73
+ _set_application_status(ApplicationStatusEnum.ERROR)
49
74
  _log.error(repr(e))
50
75
  else:
51
- os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STOPPED"
76
+ _set_application_status(ApplicationStatusEnum.STOPPED)
52
77
 
53
78
 
54
79
  class ApplicationController(object):
@@ -58,7 +83,7 @@ class ApplicationController(object):
58
83
  if not isinstance(gridappsd, GridAPPSD):
59
84
  raise ValueError("Invalid gridappsd instance passed.")
60
85
 
61
- os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STOPPED"
86
+ _set_application_status(ApplicationStatusEnum.STOPPED)
62
87
  self._configDict = config.copy()
63
88
  self._validate_config()
64
89
  self._gapd = gridappsd
@@ -80,7 +105,7 @@ class ApplicationController(object):
80
105
  self._end_callback = None
81
106
  self._print_queue = Queue()
82
107
  self._heartbeat_thread = None
83
- os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STOPPED"
108
+ _set_application_status(ApplicationStatusEnum.STOPPED)
84
109
 
85
110
  if "type" not in self._configDict or self._configDict["type"] != "REMOTE":
86
111
  _log.warning(
@@ -119,7 +144,7 @@ class ApplicationController(object):
119
144
  self._stop_control_topic = response.get("stopControlTopic")
120
145
 
121
146
  os.environ["GRIDAPPSD_APPLICATION_ID"] = self._application_id
122
- os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STOPPED"
147
+ _set_application_status(ApplicationStatusEnum.STOPPED)
123
148
 
124
149
  self._gapd.subscribe(self._stop_control_topic, self.__handle_stop)
125
150
  self._gapd.subscribe(self._start_control_topic, self.__handle_start)
@@ -160,7 +185,7 @@ class ApplicationController(object):
160
185
  obj = json.loads(message)
161
186
  else:
162
187
  obj = message
163
- os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STARTING"
188
+ _set_application_status(ApplicationStatusEnum.STARTING)
164
189
  self._gapd.get_logger().debug("Handling Start: {}\ndict:\n{}".format(headers, obj))
165
190
 
166
191
  if "command" not in obj:
@@ -176,12 +201,12 @@ class ApplicationController(object):
176
201
 
177
202
  def __handle_stop(self, headers, message):
178
203
  print("Handling Stop: {} {}".format(headers, message))
179
- os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STOPPING"
204
+ _set_application_status(ApplicationStatusEnum.STOPPING)
180
205
  if self._thread:
181
206
  self._thread.join()
182
207
  if self._end_callback is not None:
183
208
  self._end_callback()
184
- os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STOPPED"
209
+ _set_application_status(ApplicationStatusEnum.STOPPED)
185
210
 
186
211
  def shutdown(self):
187
212
  self._shutting_down = True
@@ -57,7 +57,6 @@ from enum import Enum
57
57
  from logging import Logger
58
58
  from queue import Queue
59
59
 
60
- import stomp as _stomp_module
61
60
  from stomp import Connection12 as Connection
62
61
  from stomp.exception import NotConnectedException
63
62
  from time import sleep
@@ -66,19 +65,41 @@ from gridappsd import json_extension as json
66
65
 
67
66
  _log: Logger = logging.getLogger(inspect.getmodulename(__file__))
68
67
 
69
- # stomp.py 8.x changed listener callbacks from (headers, body) to (frame)
70
- _stomp_major = (
71
- int(getattr(_stomp_module, "__version__", (0,))[0])
72
- if isinstance(getattr(_stomp_module, "__version__", None), tuple)
73
- else 0
74
- )
75
- try:
76
- from importlib.metadata import version as _pkg_version
77
-
78
- _stomp_major = int(_pkg_version("stomp-py").split(".")[0])
79
- except Exception:
80
- pass
81
- _STOMP_V8 = _stomp_major >= 8
68
+
69
+ def _unpack_stomp_args(*args):
70
+ """Return (headers, body) from a stomp listener callback's arguments.
71
+
72
+ stomp-py pre 8.x, and this module's own CallbackRouter dispatch, call
73
+ on_message/on_error with two positional arguments: headers and body.
74
+ stomp-py 8.x's raw listener protocol calls with a single Frame object
75
+ exposing .headers and .body instead. A listener reached through
76
+ CallbackRouter (any listener registered via subscribe()) always
77
+ receives the two argument shape, regardless of the installed stomp-py
78
+ version, because CallbackRouter.run_callbacks is the caller, not
79
+ stomp itself. Only a listener registered directly on a raw stomp
80
+ Connection (CallbackRouter itself, TokenResponseListener) is actually
81
+ invoked by stomp's own version dependent dispatch. Detecting the
82
+ argument shape at each call, instead of trusting a single global
83
+ version sniffed flag, is correct for both callers.
84
+ """
85
+ if len(args) >= 2:
86
+ return args[0], args[1]
87
+ frame = args[0]
88
+ if hasattr(frame, "headers") and hasattr(frame, "body"):
89
+ return frame.headers, frame.body
90
+ headers, body = frame
91
+ return headers, body
92
+
93
+
94
+ def _serialize_message(message):
95
+ """Return message ready to send on the wire.
96
+
97
+ A list or dict body is serialized to a JSON string; any other body
98
+ (already a string, bytes, etc.) is passed through unchanged.
99
+ """
100
+ if isinstance(message, (list, dict)):
101
+ return json.dumps(message)
102
+ return message
82
103
 
83
104
 
84
105
  class GRIDAPPSD_ENV_ENUM(Enum):
@@ -168,8 +189,7 @@ class GOSS(object):
168
189
 
169
190
  def send(self, topic, message):
170
191
  self._make_connection()
171
- if isinstance(message, list) or isinstance(message, dict):
172
- message = json.dumps(message)
192
+ message = _serialize_message(message)
173
193
  _log.debug("Sending topic: {} body: {}".format(topic, message))
174
194
  self._conn.send(
175
195
  body=message, destination=topic, headers={"GOSS_HAS_SUBJECT": True, "GOSS_SUBJECT": self.__token}
@@ -185,11 +205,8 @@ class GOSS(object):
185
205
  if "resultFormat" in message:
186
206
  self.result_format = message["resultFormat"]
187
207
 
188
- # Change message to string if we have a dictionary.
189
- if isinstance(message, dict):
190
- message = json.dumps(message)
191
- elif isinstance(message, list):
192
- message = json.dumps(message)
208
+ # Change message to string if we have a dictionary or list.
209
+ message = _serialize_message(message)
193
210
 
194
211
  class ResponseListener(object):
195
212
  def __init__(self, topic, result_format):
@@ -198,11 +215,7 @@ class GOSS(object):
198
215
  self.result_format = result_format
199
216
 
200
217
  def on_message(self, *args):
201
- if _STOMP_V8:
202
- frame = args[0]
203
- header, message = frame.headers, frame.body
204
- else:
205
- header, message = args[0], args[1]
218
+ header, message = _unpack_stomp_args(*args)
206
219
  _log.debug("Internal on message is: {} {}".format(header, message))
207
220
  try:
208
221
  if self.result_format == "JSON":
@@ -216,11 +229,7 @@ class GOSS(object):
216
229
  self.response = dict(error="Invalid json returned", header=header, message=message)
217
230
 
218
231
  def on_error(self, *args):
219
- if _STOMP_V8:
220
- frame = args[0]
221
- headers, message = frame.headers, frame.body
222
- else:
223
- headers, message = args[0], args[1]
232
+ headers, message = _unpack_stomp_args(*args)
224
233
  _log.error("ERR: {}".format(headers))
225
234
  _log.error("OUR ERROR: {}".format(message))
226
235
 
@@ -337,21 +346,13 @@ class GOSS(object):
337
346
  return self.__token
338
347
 
339
348
  def on_message(self, *args):
340
- if _STOMP_V8:
341
- frame = args[0]
342
- header, message = frame.headers, frame.body
343
- else:
344
- header, message = args[0], args[1]
349
+ header, message = _unpack_stomp_args(*args)
345
350
  _log.debug("Internal on message is: {} {}".format(header, message))
346
351
 
347
352
  self.__token = str(message)
348
353
 
349
354
  def on_error(self, *args):
350
- if _STOMP_V8:
351
- frame = args[0]
352
- headers, message = frame.headers, frame.body
353
- else:
354
- headers, message = args[0], args[1]
355
+ headers, message = _unpack_stomp_args(*args)
355
356
  _log.error("ERR: {}".format(headers))
356
357
  _log.error("OUR ERROR: {}".format(message))
357
358
 
@@ -404,9 +405,10 @@ class CallbackRouter(object):
404
405
  cb, hdrs, msg = self._queue_callerback.get()
405
406
  try:
406
407
  msg = json.loads(msg)
407
- except:
408
+ except (TypeError, ValueError):
409
+ # msg was not JSON text (already a dict, or plain string body);
410
+ # pass it through unchanged rather than as a decode failure.
408
411
  pass
409
- # msg = message
410
412
 
411
413
  for c in cb:
412
414
  c(hdrs, msg)
@@ -429,11 +431,7 @@ class CallbackRouter(object):
429
431
  pass
430
432
 
431
433
  def on_message(self, *args):
432
- if _STOMP_V8:
433
- frame = args[0]
434
- headers, message = frame.headers, frame.body
435
- else:
436
- headers, message = args[0], args[1]
434
+ headers, message = _unpack_stomp_args(*args)
437
435
  destination = headers["destination"]
438
436
  # _log.debug("Topic map keys are: {keys}".format(keys=self._topics_callback_map.keys()))
439
437
  if destination in self._topics_callback_map:
@@ -442,11 +440,7 @@ class CallbackRouter(object):
442
440
  _log.error("INVALID DESTINATION {destination}".format(destination=destination))
443
441
 
444
442
  def on_error(self, *args):
445
- if _STOMP_V8:
446
- frame = args[0]
447
- header, message = frame.headers, frame.body
448
- else:
449
- header, message = args[0], args[1]
443
+ header, message = _unpack_stomp_args(*args)
450
444
  _log.error("Error in callback router")
451
445
  _log.error(header)
452
446
  _log.error(message)
@@ -116,29 +116,32 @@ class GridAPPSD(GOSS):
116
116
  self._simulation_log_topic = t.simulation_log_topic(self._simulation_id)
117
117
  return self._simulation_id
118
118
 
119
- def set_application_status(self, status):
120
- """
121
- Set the application status.
122
- :param status:
119
+ def _set_status(self, status, kind):
120
+ """Set self._process_status, warning instead of raising on an invalid value.
121
+
122
+ :param status: candidate value for ProcessStatusEnum
123
+ :param kind: human readable label for the warning message, e.g. "application"
123
124
  """
124
125
  try:
125
126
  self._process_status = ProcessStatusEnum(status)
126
127
  except ValueError:
127
128
  self.get_logger().warning(
128
- "Unsuccessful change of application status." + f"Valid statuses are {ProcessStatusEnum.__members__}."
129
+ f"Unsuccessful change of {kind} status." + f"Valid statuses are {ProcessStatusEnum.__members__}."
129
130
  )
130
131
 
132
+ def set_application_status(self, status):
133
+ """
134
+ Set the application status.
135
+ :param status:
136
+ """
137
+ self._set_status(status, "application")
138
+
131
139
  def set_service_status(self, status):
132
140
  """
133
141
  Set the service status.
134
142
  :param status:
135
143
  """
136
- try:
137
- self._process_status = ProcessStatusEnum(status)
138
- except ValueError:
139
- self.get_logger().warning(
140
- "Unsuccessful change of service status." + f"Valid statuses are {ProcessStatusEnum.__members__}."
141
- )
144
+ self._set_status(status, "service")
142
145
 
143
146
  def set_simulation_id(self, simulation_id):
144
147
  if simulation_id is None:
@@ -148,12 +151,16 @@ class GridAPPSD(GOSS):
148
151
  self._simulation_id = simulation_id
149
152
  self._simulation_log_topic = t.simulation_log_topic(self._simulation_id)
150
153
 
154
+ def _get_status(self):
155
+ """Return self._process_status as its plain string value."""
156
+ return self._process_status.value
157
+
151
158
  def get_application_status(self):
152
159
  """
153
160
  Return the application status
154
161
  :return:
155
162
  """
156
- return self._process_status.value
163
+ return self._get_status()
157
164
 
158
165
  def get_application_id(self):
159
166
  return utils.get_gridappsd_application_id()
@@ -163,7 +170,7 @@ class GridAPPSD(GOSS):
163
170
  Return the service status
164
171
  :return:
165
172
  """
166
- return self._process_status.value
173
+ return self._get_status()
167
174
 
168
175
  def query_object_types(self, model_id=None):
169
176
  """Allows the caller to query the different object types.
@@ -42,11 +42,11 @@ class ConfigBase:
42
42
 
43
43
  @dataclass
44
44
  class ModelCreationConfig(ConfigBase):
45
- load_scaling_factor: str = field(default="1")
45
+ load_scaling_factor: float = field(default=1)
46
46
  schedule_name: str = field(default="ieeezipload")
47
- z_fraction: str = field(default="0")
48
- i_fraction: str = field(default="1")
49
- p_fraction: str = field(default="0")
47
+ z_fraction: float = field(default=0)
48
+ i_fraction: float = field(default=1)
49
+ p_fraction: float = field(default=0)
50
50
  randomize_zipload_fractions: bool = field(default=False)
51
51
  use_houses: bool = field(default=False)
52
52
 
@@ -56,14 +56,30 @@ class ModelCreationConfig(ConfigBase):
56
56
 
57
57
  @dataclass
58
58
  class SimulationArgs(ConfigBase):
59
- start_time: str = field(default="1655321830")
60
- duration: str = field(default="300")
61
- timestep_frequency: str = field(default="1000")
62
- timestep_increment: str = field(default="1000")
59
+ start_time: int = field(default=1655321830)
60
+ duration: int = field(default=300)
61
+ publish_period: int | None = field(default=None)
62
+ interval: int | None = field(default=None)
63
63
  run_realtime: bool = field(default=True)
64
64
  pause_after_measurements: bool = field(default=False)
65
65
  simulation_name: str = field(default="ieee13nodeckt")
66
66
 
67
+ def __post_init_(self):
68
+ if self.run_realtime:
69
+ self.interval = 1
70
+ if not self.publish_period:
71
+ self.publish_period = 3
72
+ else:
73
+ if not self.interval:
74
+ self.interval = 60
75
+ if not self.publish_period:
76
+ self.publish_period = 60
77
+ if self.publish_period < self.interval:
78
+ raise RuntimeError(
79
+ "A simulation's publishing_period cannot be less than the simulation's timestep "
80
+ "interval. please make the publishing_period >= interval!"
81
+ )
82
+
67
83
 
68
84
  @dataclass
69
85
  class SimulatorArgs(ConfigBase):
@@ -201,26 +217,30 @@ class Simulation:
201
217
  for p in self.__on_start:
202
218
  p(self)
203
219
 
204
- def pause(self):
205
- """Pause simulation"""
206
- _log.debug("Pausing simulation")
207
- command = dict(command="pause")
220
+ def _send_simulation_command(self, command_name, **command_input):
221
+ """Send a command to this simulation's input topic.
222
+
223
+ :param command_name: value of the command field sent to the simulation
224
+ :param command_input: when given, nested under the command's "input" key
225
+ """
226
+ _log.debug("Sending simulation command: {}".format(command_name))
227
+ command = dict(command=command_name)
228
+ if command_input:
229
+ command["input"] = command_input
208
230
  self._gapps.send(t.simulation_input_topic(self.simulation_id), json.dumps(command))
209
231
  self._running_or_paused = True
210
232
 
233
+ def pause(self):
234
+ """Pause simulation"""
235
+ self._send_simulation_command("pause")
236
+
211
237
  def stop(self):
212
238
  """Stop the simulation"""
213
- _log.debug("Stopping simulation")
214
- command = dict(command="stop")
215
- self._gapps.send(t.simulation_input_topic(self.simulation_id), json.dumps(command))
216
- self._running_or_paused = True
239
+ self._send_simulation_command("stop")
217
240
 
218
241
  def resume(self):
219
242
  """Resume the simulation"""
220
- _log.debug("Resuming simulation")
221
- command = dict(command="resume")
222
- self._gapps.send(t.simulation_input_topic(self.simulation_id), json.dumps(command))
223
- self._running_or_paused = True
243
+ self._send_simulation_command("resume")
224
244
 
225
245
  def run_loop(self):
226
246
  """Loop around the running of the simulation itself.
@@ -251,9 +271,7 @@ class Simulation:
251
271
  :param pause_in: number of seconds to run before pausing the simulation
252
272
  """
253
273
  _log.debug("Resuming simulation. Will pause after {} seconds".format(pause_in))
254
- command = dict(command="resumePauseAt", input=dict(pauseIn=pause_in))
255
- self._gapps.send(t.simulation_input_topic(self.simulation_id), json.dumps(command))
256
- self._running_or_paused = True
274
+ self._send_simulation_command("resumePauseAt", pauseIn=pause_in)
257
275
 
258
276
  def add_onmeasurement_callback(self, callback, device_filter=()):
259
277
  """registers an onmeasurment callback to be called when measurements have come through.
@@ -46,38 +46,37 @@ class Query:
46
46
  self.last = n
47
47
  return self
48
48
 
49
- def ge(self, value):
50
- """Method to add 'value greater than or equal to' filter to a key.
49
+ def _add_filter(self, operator, value):
50
+ """Append a single {key, operator: value} filter for the current key.
51
51
 
52
- :param value:
52
+ :param operator: one of "ge", "le", "eq"
53
+ :param value: value to filter on
53
54
  """
54
55
  if self.key is None:
55
56
  raise ValueError("Key is not specified. Call where_key first.")
56
- obj = {"key": self.key, "ge": value}
57
- self.queryFilter.append(obj)
57
+ self.queryFilter.append({"key": self.key, operator: value})
58
58
  return self
59
59
 
60
+ def ge(self, value):
61
+ """Method to add 'value greater than or equal to' filter to a key.
62
+
63
+ :param value:
64
+ """
65
+ return self._add_filter("ge", value)
66
+
60
67
  def le(self, value):
61
68
  """Method to add 'value less than or equal to' filter to a key.
62
69
 
63
70
  :param value:
64
71
  """
65
- if self.key is None:
66
- raise ValueError("Key is not specified. Call where_key first.")
67
- obj = {"key": self.key, "le": value}
68
- self.queryFilter.append(obj)
69
- return self
72
+ return self._add_filter("le", value)
70
73
 
71
74
  def eq(self, value):
72
75
  """Method to add 'value equal to' filter to a key.
73
76
 
74
77
  :param value:
75
78
  """
76
- if self.key is None:
77
- raise ValueError("Key is not specified. Call where_key first.")
78
- obj = {"key": self.key, "eq": value}
79
- self.queryFilter.append(obj)
80
- return self
79
+ return self._add_filter("eq", value)
81
80
 
82
81
  def between(self, value1, value2):
83
82
  """Method to add 'value between' value1 and value2 filter to a key.
@@ -85,13 +84,8 @@ class Query:
85
84
  :param value1: defines 'greater than equal to' filter for key's value
86
85
  :param value2: defines 'less than equal to' filter for key's value
87
86
  """
88
- if self.key is None:
89
- raise ValueError("Key is not specified. Call where_key first.")
90
- obj = {"key": self.key, "ge": value1}
91
- self.queryFilter.append(obj)
92
- obj = {"key": self.key, "le": value2}
93
- self.queryFilter.append(obj)
94
- return self
87
+ self._add_filter("ge", value1)
88
+ return self._add_filter("le", value2)
95
89
 
96
90
  def where_key(self, key):
97
91
  self.key = key
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gridappsd-python"
7
- version = "2026.02.1"
7
+ version = "2026.02.1a2"
8
8
  description = "A GridAPPS-D Python Adapter"
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -0,0 +1,63 @@
1
+ """Unit tests for gridappsd.app_registration status handling.
2
+
3
+ _set_application_status writes ApplicationStatusEnum values to the
4
+ GRIDAPPSD_APPLICATION_STATUS environment variable. These tests assert
5
+ the exact string value written for each status, and that Job.run()
6
+ drives the environment variable through the expected RUNNING then
7
+ STOPPED transition for a command that exits cleanly.
8
+ """
9
+
10
+ import os
11
+
12
+ import pytest
13
+
14
+ from gridappsd.app_registration import (
15
+ GRIDAPPSD_APPLICATION_STATUS,
16
+ ApplicationStatusEnum,
17
+ Job,
18
+ _set_application_status,
19
+ )
20
+
21
+
22
+ @pytest.fixture(autouse=True)
23
+ def _clear_status_env(monkeypatch):
24
+ monkeypatch.delenv(GRIDAPPSD_APPLICATION_STATUS, raising=False)
25
+
26
+
27
+ class TestSetApplicationStatus:
28
+ @pytest.mark.parametrize(
29
+ "status",
30
+ [
31
+ ApplicationStatusEnum.STARTING,
32
+ ApplicationStatusEnum.STOPPING,
33
+ ApplicationStatusEnum.RUNNING,
34
+ ApplicationStatusEnum.STOPPED,
35
+ ApplicationStatusEnum.ERROR,
36
+ ],
37
+ )
38
+ def test_writes_exact_status_value_to_environment(self, status):
39
+ _set_application_status(status)
40
+
41
+ assert os.environ[GRIDAPPSD_APPLICATION_STATUS] == status.value
42
+
43
+ def test_overwrites_previous_value(self):
44
+ _set_application_status(ApplicationStatusEnum.STARTING)
45
+ _set_application_status(ApplicationStatusEnum.RUNNING)
46
+
47
+ assert os.environ[GRIDAPPSD_APPLICATION_STATUS] == "RUNNING"
48
+
49
+
50
+ class TestJobRunSetsApplicationStatus:
51
+ def test_successful_job_ends_in_stopped_status(self):
52
+ job = Job(args=["true"])
53
+
54
+ job.run()
55
+
56
+ assert os.environ[GRIDAPPSD_APPLICATION_STATUS] == ApplicationStatusEnum.STOPPED.value
57
+
58
+ def test_failing_job_ends_in_error_status(self):
59
+ job = Job(args=["/nonexistent-command-for-gadp-044-test"])
60
+
61
+ job.run()
62
+
63
+ assert os.environ[GRIDAPPSD_APPLICATION_STATUS] == ApplicationStatusEnum.ERROR.value