Skip to content

Commit 9178212

Browse files
committed
Merge branch 'v2.1' of github.com:simvue-io/python-api into v2.1
2 parents a333e9e + af560ec commit 9178212

File tree

7 files changed

+104
-135
lines changed

7 files changed

+104
-135
lines changed

simvue/eco/api_client.py

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,38 +19,33 @@
1919
import geocoder.location
2020
import typing
2121

22-
CO2_SIGNAL_API_ENDPOINT: str = "https://api.co2signal.com/v1/latest"
22+
CO2_SIGNAL_API_ENDPOINT: str = (
23+
"https://api.electricitymap.org/v3/carbon-intensity/latest"
24+
)
2325

2426

2527
class CO2SignalData(pydantic.BaseModel):
2628
datetime: datetime.datetime
2729
carbon_intensity: float
28-
fossil_fuel_percentage: float
2930

3031

3132
class CO2SignalResponse(pydantic.BaseModel):
32-
disclaimer: str
3333
country_code: str
34-
status: str
3534
data: CO2SignalData
3635
carbon_intensity_units: str
3736

3837
@classmethod
3938
def from_json_response(cls, json_response: dict) -> "CO2SignalResponse":
40-
_data: dict[str, typing.Any] = json_response["data"]
4139
_co2_signal_data = CO2SignalData(
4240
datetime=datetime.datetime.fromisoformat(
43-
_data["datetime"].replace("Z", "+00:00")
41+
json_response["datetime"].replace("Z", "+00:00")
4442
),
45-
carbon_intensity=_data["carbonIntensity"],
46-
fossil_fuel_percentage=_data["fossilFuelPercentage"],
43+
carbon_intensity=json_response["carbonIntensity"],
4744
)
4845
return cls(
49-
disclaimer=json_response["_disclaimer"],
50-
country_code=json_response["countryCode"],
51-
status=json_response["status"],
46+
country_code=json_response["zone"],
5247
data=_co2_signal_data,
53-
carbon_intensity_units=json_response["units"]["carbonIntensity"],
48+
carbon_intensity_units="gCO2e/kWh",
5449
)
5550

5651

@@ -109,16 +104,14 @@ def _get_user_location_info(self) -> None:
109104
def get(self) -> CO2SignalResponse:
110105
"""Get the current data"""
111106
_params: dict[str, float | str] = {
112-
"lat": self._latitude,
113-
"lon": self._longitude,
114-
"countryCode": self._two_letter_country_code,
107+
"zone": self._two_letter_country_code,
115108
}
116109

117110
if self.co2_api_token:
118111
_params["auth-token"] = self.co2_api_token.get_secret_value()
119112

120113
self._logger.debug(f"🍃 Retrieving carbon intensity data for: {_params}")
121-
_response = requests.get(f"{self.co2_api_endpoint}", params=_params)
114+
_response = requests.get(f"{self.co2_api_endpoint}", headers=_params)
122115

123116
if _response.status_code != http.HTTPStatus.OK:
124117
try:

simvue/eco/emissions_monitor.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,12 @@ class CO2Monitor(pydantic.BaseModel):
4747
local_data_directory: pydantic.DirectoryPath
4848
intensity_refresh_interval: int | None | str
4949
co2_intensity: float | None
50-
co2_signal_api_token: str | None
50+
co2_signal_api_token: pydantic.SecretStr | None
5151
offline: bool = False
5252

5353
def now(self) -> str:
5454
"""Return data file timestamp for the current time"""
55-
_now: datetime.datetime = datetime.datetime.now(datetime.UTC)
55+
_now: datetime.datetime = datetime.datetime.now(datetime.timezone.utc)
5656
return _now.strftime(TIME_FORMAT)
5757

5858
@property
@@ -187,7 +187,7 @@ def check_refresh(self) -> bool:
187187
with self._data_file_path.open("r") as in_f:
188188
self._local_data = json.load(in_f)
189189

190-
if not self._client or not self._local_data:
190+
if not self._client:
191191
return False
192192

193193
if (
@@ -252,7 +252,6 @@ def estimate_co2_emissions(
252252
)
253253
_current_co2_intensity = self._current_co2_data.data.carbon_intensity
254254
_co2_units = self._current_co2_data.carbon_intensity_units
255-
256255
_process.gpu_percentage = gpu_percent
257256
_process.cpu_percentage = cpu_percent
258257
_previous_energy: float = _process.total_energy
@@ -271,8 +270,6 @@ def estimate_co2_emissions(
271270
# Measured value is in g/kWh, convert to kg/kWs
272271
_carbon_intensity_kgpws: float = _current_co2_intensity / (60 * 60 * 1e3)
273272

274-
_previous_emission: float = _process.co2_emission
275-
276273
_process.co2_delta = (
277274
_process.power_usage * _carbon_intensity_kgpws * measure_interval
278275
)

simvue/metrics.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ def __init__(
139139
self,
140140
processes: list[psutil.Process],
141141
interval: float | None,
142-
cpu_only: bool = False,
143142
) -> None:
144143
"""Perform a measurement of system resource consumption.
145144
@@ -149,14 +148,10 @@ def __init__(
149148
processes to measure across.
150149
interval: float | None
151150
interval to measure, if None previous measure time used for interval.
152-
cpu_only: bool, optional
153-
only record CPU information, default False
154151
"""
155152
self.cpu_percent: float | None = get_process_cpu(processes, interval=interval)
156153
self.cpu_memory: float | None = get_process_memory(processes)
157-
self.gpus: list[dict[str, float]] = (
158-
None if cpu_only else get_gpu_metrics(processes)
159-
)
154+
self.gpus: list[dict[str, float]] = get_gpu_metrics(processes)
160155

161156
def to_dict(self) -> dict[str, float]:
162157
"""Create metrics dictionary for sending to a Simvue server."""

simvue/run.py

Lines changed: 51 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -334,10 +334,7 @@ def _terminate_run(
334334

335335
def _get_internal_metrics(
336336
self,
337-
system_metrics_step: int | None,
338-
emission_metrics_step: int | None,
339-
res_measure_interval: int | None = None,
340-
ems_measure_interval: int | None = None,
337+
system_metrics_step: int,
341338
) -> None:
342339
"""Refresh resource and emissions metrics.
343340
@@ -346,55 +343,49 @@ def _get_internal_metrics(
346343
347344
Parameters
348345
----------
349-
system_metrics_step: int | None
350-
the current step for this resource metric record,
351-
None if skipping resource metrics.
352-
emission_metrics_step: int | None
353-
the current step for this emission metrics record,
354-
None if skipping emission metrics.
355-
res_measure_interval: int | None, optional
356-
the interval for resource metric gathering, default is None
357-
ems_measure_interval: int | None, optional
358-
the interval for emission metric gathering, default is None
346+
system_metrics_step: int
347+
The current step for this system metric record
359348
360349
Return
361350
------
362351
tuple[float, float]
363352
new resource metric measure time
364353
new emissions metric measure time
365354
"""
355+
356+
# In order to get a resource metric reading at t=0
357+
# because there is no previous CPU reading yet we cannot
358+
# use the default of None for the interval here, so we measure
359+
# at an interval of 1s.
366360
_current_system_measure = SystemResourceMeasurement(
367361
self.processes,
368-
interval=res_measure_interval,
369-
cpu_only=not system_metrics_step,
362+
interval=1 if system_metrics_step == 0 else None,
370363
)
371364

372-
if system_metrics_step is not None:
373-
# Set join on fail to false as if an error is thrown
374-
# join would be called on this thread and a thread cannot
375-
# join itself!
376-
self._add_metrics_to_dispatch(
377-
_current_system_measure.to_dict(),
378-
join_on_fail=False,
379-
step=system_metrics_step,
380-
)
365+
# Set join on fail to false as if an error is thrown
366+
# join would be called on this thread and a thread cannot
367+
# join itself!
368+
self._add_metrics_to_dispatch(
369+
_current_system_measure.to_dict(),
370+
join_on_fail=False,
371+
step=system_metrics_step,
372+
)
381373

382-
if (
383-
self._emissions_monitor
384-
and emission_metrics_step is not None
385-
and ems_measure_interval is not None
386-
and _current_system_measure.cpu_percent is not None
387-
):
374+
# For the first emissions metrics reading, the time interval to use
375+
# Is the time since the run started, otherwise just use the time between readings
376+
if self._emissions_monitor:
388377
self._emissions_monitor.estimate_co2_emissions(
389378
process_id=f"{self._name}",
390379
cpu_percent=_current_system_measure.cpu_percent,
391-
measure_interval=ems_measure_interval,
380+
measure_interval=(time.time() - self._start_time)
381+
if system_metrics_step == 0
382+
else self._system_metrics_interval,
392383
gpu_percent=_current_system_measure.gpu_percent,
393384
)
394385
self._add_metrics_to_dispatch(
395386
self._emissions_monitor.simvue_metrics(),
396387
join_on_fail=False,
397-
step=emission_metrics_step,
388+
step=system_metrics_step,
398389
)
399390

400391
def _create_heartbeat_callback(
@@ -416,61 +407,29 @@ def _heartbeat(
416407
raise RuntimeError("Expected initialisation of heartbeat")
417408

418409
last_heartbeat: float = 0
419-
last_res_metric_call: float = 0
420-
last_co2_metric_call: float = 0
421-
422-
co2_step: int = 0
423-
res_step: int = 0
410+
last_sys_metric_call: float = 0
424411

425-
initial_ems_metrics_interval: float = time.time() - self._start_time
412+
sys_step: int = 0
426413

427414
while not heartbeat_trigger.is_set():
428415
with self._configuration_lock:
429416
_current_time: float = time.time()
417+
430418
_update_system_metrics: bool = (
431419
self._system_metrics_interval is not None
432-
and _current_time - last_res_metric_call
433-
> self._system_metrics_interval
434-
and self._status == "running"
435-
)
436-
_update_emissions_metrics: bool = (
437-
self._system_metrics_interval is not None
438-
and self._emissions_monitor
439-
and _current_time - last_co2_metric_call
420+
and _current_time - last_sys_metric_call
440421
> self._system_metrics_interval
441422
and self._status == "running"
442423
)
443424

444-
# In order to get a resource metric reading at t=0
445-
# because there is no previous CPU reading yet we cannot
446-
# use the default of None for the interval here, so we measure
447-
# at an interval of 1s. For emissions metrics the first step
448-
# is time since run start
449-
self._get_internal_metrics(
450-
emission_metrics_step=co2_step
451-
if _update_emissions_metrics
452-
else None,
453-
system_metrics_step=res_step
454-
if _update_system_metrics
455-
else None,
456-
res_measure_interval=1 if res_step == 0 else None,
457-
ems_measure_interval=initial_ems_metrics_interval
458-
if co2_step == 0
459-
else self._system_metrics_interval,
460-
)
425+
if _update_system_metrics:
426+
self._get_internal_metrics(system_metrics_step=sys_step)
427+
sys_step += 1
461428

462-
res_step += 1
463-
co2_step += 1
464-
465-
last_res_metric_call = (
429+
last_sys_metric_call = (
466430
_current_time
467431
if _update_system_metrics
468-
else last_res_metric_call
469-
)
470-
last_co2_metric_call = (
471-
_current_time
472-
if _update_emissions_metrics
473-
else last_co2_metric_call
432+
else last_sys_metric_call
474433
)
475434

476435
if time.time() - last_heartbeat < self._heartbeat_interval:
@@ -1055,7 +1014,7 @@ def config(
10551014
queue_blocking: bool | None = None,
10561015
system_metrics_interval: pydantic.PositiveInt | None = None,
10571016
enable_emission_metrics: bool | None = None,
1058-
disable_system_metrics: bool | None = None,
1017+
disable_resources_metrics: bool | None = None,
10591018
storage_id: str | None = None,
10601019
abort_on_alert: typing.Literal["run", "all", "ignore"] | bool | None = None,
10611020
) -> bool:
@@ -1069,10 +1028,10 @@ def config(
10691028
queue_blocking : bool, optional
10701029
block thread queues during metric/event recording
10711030
system_metrics_interval : int, optional
1072-
frequency at which to collect resource metrics
1031+
frequency at which to collect resource and emissions metrics, if enabled
10731032
enable_emission_metrics : bool, optional
10741033
enable monitoring of emission metrics
1075-
disable_system_metrics : bool, optional
1034+
disable_resources_metrics : bool, optional
10761035
disable monitoring of resource metrics
10771036
storage_id : str, optional
10781037
identifier of storage to use, by default None
@@ -1095,17 +1054,30 @@ def config(
10951054
if queue_blocking is not None:
10961055
self._queue_blocking = queue_blocking
10971056

1098-
if system_metrics_interval and disable_system_metrics:
1057+
if system_metrics_interval and disable_resources_metrics:
10991058
self._error(
11001059
"Setting of resource metric interval and disabling resource metrics is ambiguous"
11011060
)
11021061
return False
11031062

1104-
if disable_system_metrics:
1063+
if system_metrics_interval:
1064+
self._system_metrics_interval = system_metrics_interval
1065+
1066+
if disable_resources_metrics:
1067+
if self._emissions_monitor:
1068+
self._error(
1069+
"Emissions metrics require resource metrics collection."
1070+
)
1071+
return False
11051072
self._pid = None
11061073
self._system_metrics_interval = None
11071074

11081075
if enable_emission_metrics:
1076+
if not self._system_metrics_interval:
1077+
self._error(
1078+
"Emissions metrics require resource metrics collection - make sure resource metrics are enabled!"
1079+
)
1080+
return False
11091081
if self._user_config.run.mode == "offline":
11101082
# Create an emissions monitor with no API calls
11111083
self._emissions_monitor = CO2Monitor(
@@ -1130,9 +1102,6 @@ def config(
11301102
elif enable_emission_metrics is False and self._emissions_monitor:
11311103
self._error("Cannot disable emissions monitor once it has been started")
11321104

1133-
if system_metrics_interval:
1134-
self._system_metrics_interval = system_metrics_interval
1135-
11361105
if abort_on_alert is not None:
11371106
if isinstance(abort_on_alert, bool):
11381107
warnings.warn(

tests/conftest.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,14 @@ def clear_out_files() -> None:
5353
@pytest.fixture
5454
def mock_co2_signal(monkeypatch: monkeypatch.MonkeyPatch) -> dict[str, dict | str]:
5555
_mock_data = {
56-
"data": {
57-
"datetime": datetime.datetime.now().isoformat(),
58-
"carbonIntensity": 40,
59-
"fossilFuelPercentage": 39,
60-
},
61-
"_disclaimer": "test disclaimer",
62-
"countryCode": "GB",
63-
"status": "unknown",
64-
"units": {"carbonIntensity": "eqCO2kg/kwh"}
56+
"zone":"GB",
57+
"carbonIntensity":85,
58+
"datetime":"2025-04-04T12:00:00.000Z",
59+
"updatedAt":"2025-04-04T11:41:12.947Z",
60+
"createdAt":"2025-04-01T12:43:58.056Z",
61+
"emissionFactorType":"lifecycle",
62+
"isEstimated":True,
63+
"estimationMethod":"TIME_SLICER_AVERAGE"
6564
}
6665
class MockCo2SignalAPIResponse:
6766
def json(*_, **__) -> dict:

0 commit comments

Comments
 (0)