codecarbon 3.3.0__tar.gz → 3.3.1__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.
- {codecarbon-3.3.0 → codecarbon-3.3.1}/PKG-INFO +1 -1
- codecarbon-3.3.1/codecarbon/_version.py +1 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/cli/auth.py +10 -2
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/cli/main.py +48 -7
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/cli/monitor.py +8 -4
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/api_client.py +7 -2
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/config.py +85 -30
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/cpu.py +80 -1
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/powermetrics.py +91 -46
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/rapl.py +48 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/windows_emi.py +4 -23
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/hardware/cpu_power.csv +13 -0
- codecarbon-3.3.1/codecarbon/data/private_infra/nordic_emissions.json +89 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/emissions_tracker.py +37 -7
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/file.py +2 -1
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon.egg-info/PKG-INFO +1 -1
- {codecarbon-3.3.0 → codecarbon-3.3.1}/pyproject.toml +5 -6
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_api_call.py +33 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_config.py +61 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_cpu.py +55 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_emissions.py +4 -2
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_emissions_tracker.py +45 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_emissions_tracker_constant.py +33 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_powermetrics.py +81 -8
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_rapl_mmio_scanning.py +179 -0
- codecarbon-3.3.0/codecarbon/_version.py +0 -1
- codecarbon-3.3.0/codecarbon/data/private_infra/nordic_emissions.json +0 -69
- {codecarbon-3.3.0 → codecarbon-3.3.1}/LICENSE +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/README.md +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/__init__.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/cli/__init__.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/cli/cli_utils.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/__init__.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/cloud.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/electricitymaps_api.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/emissions.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/gpu.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/gpu_amd.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/gpu_device.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/gpu_nvidia.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/hardware_cache.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/measure.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/resource_tracker.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/schemas.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/telemetry_client.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/telemetry_schemas.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/units.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/core/util.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/canada_provinces.geojson +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/cloud/impact.csv +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/hardware/cpu_dataset_builder/amd_cpu_scrapper.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/hardware/cpu_dataset_builder/intel_cpu_scrapper.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/hardware/cpu_dataset_builder/merge_scrapped_cpu_power.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/private_infra/2016/usa_emissions.json +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/private_infra/2023/canada_energy_mix.json +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/private_infra/carbon_intensity_per_source.json +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/data/private_infra/global_energy_mix.json +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/__init__.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/geography.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/hardware.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/logger.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/ram.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/scheduler.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/external/task.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/input.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/lock.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/__init__.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/base_output.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/boamps/__init__.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/boamps/mapper.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/boamps/models.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/boamps/output.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/emissions_data.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/http.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/logger.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/metrics/__init__.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/metrics/logfire.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/metrics/metric_docs.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/output_methods/metrics/prometheus.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/py.typed +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/__init__.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/assets/__init__.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/assets/car_icon.png +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/assets/house_icon.png +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/assets/tv_icon.png +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/carbonboard.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/carbonboard_on_api.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/components.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/data.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon/viz/units.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon.egg-info/SOURCES.txt +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon.egg-info/dependency_links.txt +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon.egg-info/entry_points.txt +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon.egg-info/requires.txt +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/codecarbon.egg-info/top_level.txt +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/setup.cfg +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_boamps_output.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_cloud.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_core_util.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_cpu_load.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_custom_handler.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_docs_examples.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_electricitymaps_api.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_electricitymaps_backward_compatibility.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_electricitymaps_config_backward_compatibility.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_emissions_tracker_flush.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_energy.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_geography.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_gpu.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_gpu_amd.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_gpu_nvidia.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_hardware_cache.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_input.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_lock.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_logger.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_logging_output.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_offline_emissions_tracker.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_package_integrity.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_ram.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_rapl_parameters.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_rapl_permissions.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_resource_tracker.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_telemetry_client.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_tracking_inference.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_unsupported_gpu.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_utilization_tracking.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_viz_data.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_viz_units.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/test_windows_emi.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/testdata.py +0 -0
- {codecarbon-3.3.0 → codecarbon-3.3.1}/tests/testutils.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "3.3.1"
|
|
@@ -86,8 +86,15 @@ def _discover_endpoints():
|
|
|
86
86
|
|
|
87
87
|
def _save_credentials(tokens):
|
|
88
88
|
"""Save OAuth tokens to the local credentials file."""
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
try:
|
|
90
|
+
with open(_CREDENTIALS_FILE, "w") as f:
|
|
91
|
+
json.dump(tokens, f)
|
|
92
|
+
except OSError as e:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"Could not write the credentials file {_CREDENTIALS_FILE.resolve()} "
|
|
95
|
+
f"(error: {e}). Please run the command from a directory you can "
|
|
96
|
+
"write to."
|
|
97
|
+
)
|
|
91
98
|
|
|
92
99
|
|
|
93
100
|
def _load_credentials():
|
|
@@ -169,6 +176,7 @@ def authorize():
|
|
|
169
176
|
server = HTTPServer(("localhost", _REDIRECT_PORT), _CallbackHandler)
|
|
170
177
|
|
|
171
178
|
print("Opening browser for authentication...")
|
|
179
|
+
print(f"If no browser opens, copy this URL into your browser:\n{uri}")
|
|
172
180
|
webbrowser.open(uri)
|
|
173
181
|
|
|
174
182
|
server.handle_request()
|
|
@@ -358,6 +358,34 @@ def config():
|
|
|
358
358
|
)
|
|
359
359
|
|
|
360
360
|
|
|
361
|
+
def _cli_provided(ctx, name: str) -> bool:
|
|
362
|
+
"""
|
|
363
|
+
Whether the option `name` was typed on the command line (or set through its
|
|
364
|
+
environment variable) rather than left at its Typer default.
|
|
365
|
+
|
|
366
|
+
Options left at their default must not be forwarded to the tracker: doing so
|
|
367
|
+
would silently override the values coming from `.codecarbon.config` and the
|
|
368
|
+
`CODECARBON_*` environment variables, which the tracker reads itself.
|
|
369
|
+
"""
|
|
370
|
+
get_source = getattr(ctx, "get_parameter_source", None)
|
|
371
|
+
if get_source is None:
|
|
372
|
+
# `monitor` called directly from Python, not through Click: every value
|
|
373
|
+
# given is explicit.
|
|
374
|
+
return True
|
|
375
|
+
source = get_source(name)
|
|
376
|
+
return source is None or source.name in ("COMMANDLINE", "ENVIRONMENT")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _external_config() -> dict:
|
|
380
|
+
"""The configuration files and CODECARBON_* variables, as a plain dict."""
|
|
381
|
+
from codecarbon.core.config import get_hierarchical_config
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
return dict(get_hierarchical_config())
|
|
385
|
+
except Exception:
|
|
386
|
+
return {}
|
|
387
|
+
|
|
388
|
+
|
|
361
389
|
@codecarbon.command(
|
|
362
390
|
"monitor",
|
|
363
391
|
short_help="Monitor your machine's carbon emissions.",
|
|
@@ -393,15 +421,28 @@ def monitor(
|
|
|
393
421
|
):
|
|
394
422
|
"""Monitor your machine's carbon emissions."""
|
|
395
423
|
|
|
396
|
-
|
|
424
|
+
external_conf = _external_config()
|
|
425
|
+
|
|
426
|
+
# Shared tracker args so monitor and run_and_monitor behave the same.
|
|
427
|
+
# Only the options actually given are forwarded: the others are left to the
|
|
428
|
+
# tracker, which resolves them from the configuration file and environment.
|
|
429
|
+
cli_defaults = (
|
|
430
|
+
("measure_power_secs", measure_power_secs),
|
|
431
|
+
("api_call_interval", api_call_interval),
|
|
432
|
+
("log_level", log_level),
|
|
433
|
+
)
|
|
397
434
|
tracker_args = {
|
|
398
|
-
|
|
399
|
-
"api_call_interval": api_call_interval,
|
|
400
|
-
"log_level": log_level,
|
|
435
|
+
name: value for name, value in cli_defaults if _cli_provided(ctx, name)
|
|
401
436
|
}
|
|
437
|
+
for name, value in cli_defaults:
|
|
438
|
+
if name not in tracker_args and name not in external_conf:
|
|
439
|
+
# Nothing configures it: keep the defaults advertised by `--help`
|
|
440
|
+
# (and an unattended monitor quiet) instead of the tracker's own.
|
|
441
|
+
tracker_args[name] = value
|
|
442
|
+
|
|
402
443
|
# Set up the tracker arguments based on mode (offline vs online) and validate required args for each mode
|
|
403
444
|
if offline:
|
|
404
|
-
if not country_iso_code:
|
|
445
|
+
if not country_iso_code and not external_conf.get("country_iso_code"):
|
|
405
446
|
print(
|
|
406
447
|
"ERROR: Country ISO code is required for offline mode. Add it to your configuration or provide it via the command line: `--country-iso-code FRA`",
|
|
407
448
|
file=sys.stderr,
|
|
@@ -410,8 +451,8 @@ def monitor(
|
|
|
410
451
|
|
|
411
452
|
tracker_args = {
|
|
412
453
|
**tracker_args,
|
|
413
|
-
"country_iso_code": country_iso_code,
|
|
414
|
-
"region": region,
|
|
454
|
+
**({"country_iso_code": country_iso_code} if country_iso_code else {}),
|
|
455
|
+
**({"region": region} if region else {}),
|
|
415
456
|
}
|
|
416
457
|
else:
|
|
417
458
|
experiment_id = get_existing_exp_id()
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import os
|
|
4
4
|
import subprocess
|
|
5
5
|
import sys
|
|
6
|
+
from typing import Optional
|
|
6
7
|
|
|
7
8
|
import typer
|
|
8
9
|
from rich import print
|
|
@@ -12,9 +13,9 @@ from typing_extensions import Annotated
|
|
|
12
13
|
def run_and_monitor(
|
|
13
14
|
ctx: typer.Context,
|
|
14
15
|
log_level: Annotated[
|
|
15
|
-
str,
|
|
16
|
+
Optional[str],
|
|
16
17
|
typer.Option(help="Log level (critical, error, warning, info, debug)"),
|
|
17
|
-
] =
|
|
18
|
+
] = None,
|
|
18
19
|
offline: bool = False,
|
|
19
20
|
**tracker_args,
|
|
20
21
|
):
|
|
@@ -51,7 +52,11 @@ def run_and_monitor(
|
|
|
51
52
|
from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker
|
|
52
53
|
from codecarbon.external.logger import set_logger_level
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
# `log_level` is None when nothing set it: leave it to the tracker, which
|
|
56
|
+
# resolves it from the configuration file and the environment.
|
|
57
|
+
if log_level is not None:
|
|
58
|
+
set_logger_level(log_level)
|
|
59
|
+
tracker_args["log_level"] = log_level
|
|
55
60
|
|
|
56
61
|
# Get the command from remaining args (strip nested subcommand / `--` leftovers)
|
|
57
62
|
command = list(getattr(ctx, "args", None) or [])
|
|
@@ -67,7 +72,6 @@ def run_and_monitor(
|
|
|
67
72
|
|
|
68
73
|
tracker_cls = OfflineEmissionsTracker if offline else EmissionsTracker
|
|
69
74
|
tracker = tracker_cls(
|
|
70
|
-
log_level=log_level,
|
|
71
75
|
save_to_logger=False,
|
|
72
76
|
tracking_mode="process",
|
|
73
77
|
**tracker_args,
|
|
@@ -22,6 +22,11 @@ from codecarbon.core.schemas import (
|
|
|
22
22
|
from codecarbon.external.logger import logger
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def _round_or_none(value: float | None) -> float | None:
|
|
26
|
+
"""Round a coordinate, keeping None when it is unknown."""
|
|
27
|
+
return None if value is None else round(value, 1)
|
|
28
|
+
|
|
29
|
+
|
|
25
30
|
def get_datetime_with_timezone():
|
|
26
31
|
import arrow
|
|
27
32
|
|
|
@@ -242,8 +247,8 @@ class ApiClient: # (AsyncClient)
|
|
|
242
247
|
gpu_count=self.conf.get("gpu_count"),
|
|
243
248
|
gpu_model=self.conf.get("gpu_model"),
|
|
244
249
|
# Reduce precision for Privacy
|
|
245
|
-
longitude=
|
|
246
|
-
latitude=
|
|
250
|
+
longitude=_round_or_none(self.conf.get("longitude")),
|
|
251
|
+
latitude=_round_or_none(self.conf.get("latitude")),
|
|
247
252
|
region=self.conf.get("region"),
|
|
248
253
|
provider=self.conf.get("provider"),
|
|
249
254
|
ram_total_size=self.conf.get("ram_total_size"),
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import configparser
|
|
2
2
|
import os
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import List, Optional, Union
|
|
4
|
+
from typing import Dict, List, Optional, Tuple, Union
|
|
5
5
|
|
|
6
6
|
from codecarbon.external.logger import logger
|
|
7
7
|
|
|
8
|
+
CONFIG_FILE_NAME = ".codecarbon.config"
|
|
9
|
+
CONFIG_SECTION = "codecarbon"
|
|
10
|
+
|
|
8
11
|
|
|
9
12
|
def clean_env_key(k: str) -> str:
|
|
10
13
|
"""
|
|
@@ -36,7 +39,7 @@ def parse_env_config() -> dict:
|
|
|
36
39
|
dict: existing relevant environment variables mapped into a dict
|
|
37
40
|
"""
|
|
38
41
|
return {
|
|
39
|
-
|
|
42
|
+
CONFIG_SECTION: {
|
|
40
43
|
clean_env_key(k): v
|
|
41
44
|
for k, v in os.environ.items()
|
|
42
45
|
if k.lower().startswith("codecarbon_")
|
|
@@ -110,10 +113,81 @@ def normalize_gpu_ids(
|
|
|
110
113
|
return None
|
|
111
114
|
|
|
112
115
|
|
|
116
|
+
def read_config_file(path: str) -> dict:
|
|
117
|
+
"""
|
|
118
|
+
Read the `[codecarbon]` section of a single configuration file.
|
|
119
|
+
|
|
120
|
+
A file that does not exist, or that cannot be read, is ignored, like
|
|
121
|
+
configparser.ConfigParser.read does.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
path (str): Path of the configuration file to read.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
dict: The keys defined in this file only, empty if there is nothing to read.
|
|
128
|
+
"""
|
|
129
|
+
config = configparser.ConfigParser()
|
|
130
|
+
config.read(path)
|
|
131
|
+
if not config.has_section(CONFIG_SECTION):
|
|
132
|
+
return {}
|
|
133
|
+
return dict(config[CONFIG_SECTION])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def log_config_sources(sources: Dict[str, str]) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Log a single line telling, for every configuration key, which layer set it.
|
|
139
|
+
|
|
140
|
+
Only the key names and their source are logged, never the values, because
|
|
141
|
+
this configuration holds secrets such as `api_key` and `experiment_id`.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
sources (dict): The key to source mapping built by
|
|
145
|
+
get_hierarchical_config_with_sources.
|
|
146
|
+
"""
|
|
147
|
+
if not sources:
|
|
148
|
+
return
|
|
149
|
+
logger.info(
|
|
150
|
+
"Codecarbon configuration: "
|
|
151
|
+
+ ", ".join(f"{key} from {source}" for key, source in sorted(sources.items()))
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_hierarchical_config_with_sources() -> Tuple[dict, Dict[str, str]]:
|
|
156
|
+
"""
|
|
157
|
+
Same as get_hierarchical_config, but also tells where each key comes from.
|
|
158
|
+
|
|
159
|
+
The layers are read from the lowest to the highest priority: the global
|
|
160
|
+
config file, then the local config file, then the environment variables.
|
|
161
|
+
The last layer defining a key wins, and is the one reported as its source.
|
|
162
|
+
A source is either the path of the config file that set the key, or the
|
|
163
|
+
name of the environment variable, e.g. `CODECARBON_PROJECT_NAME`.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
tuple: The (config, sources) dicts, which share the same keys. `config`
|
|
167
|
+
maps a key to its value (**all values are strings**) and `sources` maps
|
|
168
|
+
a key to the layer it was read from.
|
|
169
|
+
"""
|
|
170
|
+
global_path = str((Path.home() / CONFIG_FILE_NAME).expanduser().resolve())
|
|
171
|
+
local_path = str((Path.cwd() / CONFIG_FILE_NAME).expanduser().resolve())
|
|
172
|
+
|
|
173
|
+
config = {}
|
|
174
|
+
sources = {}
|
|
175
|
+
|
|
176
|
+
for path in (global_path, local_path):
|
|
177
|
+
for key, value in read_config_file(path).items():
|
|
178
|
+
config[key] = value
|
|
179
|
+
sources[key] = path
|
|
180
|
+
|
|
181
|
+
for key, value in parse_env_config()[CONFIG_SECTION].items():
|
|
182
|
+
config[key] = value
|
|
183
|
+
sources[key] = f"CODECARBON_{key.upper()}"
|
|
184
|
+
|
|
185
|
+
return config, sources
|
|
186
|
+
|
|
187
|
+
|
|
113
188
|
def get_hierarchical_config():
|
|
114
189
|
"""
|
|
115
|
-
Get the user-defined codecarbon configuration
|
|
116
|
-
(actually a configparser.SectionProxy instance).
|
|
190
|
+
Get the user-defined codecarbon configuration as a dict.
|
|
117
191
|
|
|
118
192
|
```
|
|
119
193
|
>>> from codecarbon.core.config import get_hierarchical_config
|
|
@@ -121,10 +195,7 @@ def get_hierarchical_config():
|
|
|
121
195
|
>>> print(conf)
|
|
122
196
|
```
|
|
123
197
|
|
|
124
|
-
|
|
125
|
-
and getboolean(key) to automatically parse strings into those types.
|
|
126
|
-
|
|
127
|
-
All values (outputs of get(key)) are strings.
|
|
198
|
+
All values are strings.
|
|
128
199
|
|
|
129
200
|
It looks for, and reads, a config file .codecarbon.config in the user's $HOME.
|
|
130
201
|
It then looks for, reads, and updates the previous configuration from a config
|
|
@@ -133,29 +204,13 @@ def get_hierarchical_config():
|
|
|
133
204
|
with `CODECARBON_` (for instance if `CODECARBON_PROJECT_NAME` is `your-project`
|
|
134
205
|
then the resulting configuration key `project_name` will have value `your-project`)
|
|
135
206
|
|
|
207
|
+
The source of every resolved key is logged, so that a surprising value can be
|
|
208
|
+
traced back to the file or the environment variable that set it.
|
|
209
|
+
|
|
136
210
|
Returns:
|
|
137
211
|
dict: The final configuration dict parsed from global,
|
|
138
212
|
local and environment configurations. **All values are strings**.
|
|
139
213
|
"""
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
cwd = Path.cwd()
|
|
144
|
-
home = Path.home()
|
|
145
|
-
global_path = str((home / ".codecarbon.config").expanduser().resolve())
|
|
146
|
-
local_path = str((cwd / ".codecarbon.config").expanduser().resolve())
|
|
147
|
-
if Path(global_path).exists():
|
|
148
|
-
logger.info(
|
|
149
|
-
f"Codecarbon is taking the configuration from global file: {global_path}"
|
|
150
|
-
)
|
|
151
|
-
if Path(local_path).exists():
|
|
152
|
-
logger.info(f"Some variables are overriden by the local file: {local_path}")
|
|
153
|
-
elif Path(local_path).exists():
|
|
154
|
-
logger.info(
|
|
155
|
-
f"Codecarbon is taking the configuration from the local file {local_path}"
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
config.read([global_path, local_path])
|
|
159
|
-
config.read_dict(parse_env_config())
|
|
160
|
-
|
|
161
|
-
return dict(config["codecarbon"])
|
|
214
|
+
config, sources = get_hierarchical_config_with_sources()
|
|
215
|
+
log_config_sources(sources)
|
|
216
|
+
return config
|
|
@@ -17,7 +17,12 @@ from typing import TYPE_CHECKING, Dict, Optional, Tuple
|
|
|
17
17
|
import psutil
|
|
18
18
|
from rapidfuzz import fuzz, process, utils
|
|
19
19
|
|
|
20
|
-
from codecarbon.core.rapl import
|
|
20
|
+
from codecarbon.core.rapl import (
|
|
21
|
+
SEQUENTIAL_READ_TOLERANCE_KWH,
|
|
22
|
+
RAPLFile,
|
|
23
|
+
counters_match,
|
|
24
|
+
find_mirrored_counters,
|
|
25
|
+
)
|
|
21
26
|
from codecarbon.core.units import Time
|
|
22
27
|
from codecarbon.core.util import count_cpus, detect_cpu_model
|
|
23
28
|
from codecarbon.external.logger import logger
|
|
@@ -450,6 +455,10 @@ class IntelRAPL:
|
|
|
450
455
|
self._lin_rapl_dir = rapl_dir
|
|
451
456
|
self._system = sys.platform.lower()
|
|
452
457
|
self._rapl_files = []
|
|
458
|
+
# Files that look like a duplicate of another counter, but whose
|
|
459
|
+
# energy deltas have not confirmed it yet. They are left out of the
|
|
460
|
+
# measurement while pending. Maps file path -> mirrored file path.
|
|
461
|
+
self._mirrored_candidates: Dict[str, str] = {}
|
|
453
462
|
self.rapl_include_dram = rapl_include_dram
|
|
454
463
|
self.rapl_prefer_psys = rapl_prefer_psys
|
|
455
464
|
self._setup_rapl()
|
|
@@ -842,7 +851,12 @@ class IntelRAPL:
|
|
|
842
851
|
for rapl_file in self._rapl_files:
|
|
843
852
|
rapl_file.delta(duration)
|
|
844
853
|
|
|
854
|
+
self._confirm_mirrored_files()
|
|
855
|
+
|
|
845
856
|
for rapl_file in self._rapl_files:
|
|
857
|
+
if rapl_file.path in self._mirrored_candidates:
|
|
858
|
+
# Still suspected of duplicating another counter
|
|
859
|
+
continue
|
|
846
860
|
logger.debug(rapl_file)
|
|
847
861
|
cpu_details[rapl_file.name] = rapl_file.energy_delta.kWh
|
|
848
862
|
# We fake the name used by Power Gadget when using RAPL
|
|
@@ -874,6 +888,71 @@ class IntelRAPL:
|
|
|
874
888
|
"""
|
|
875
889
|
for rapl_file in self._rapl_files:
|
|
876
890
|
rapl_file.start()
|
|
891
|
+
# DRAM domains are a different measurement, never a duplicate
|
|
892
|
+
counters = [
|
|
893
|
+
(rapl_file.path, float(rapl_file.last_energy))
|
|
894
|
+
for rapl_file in self._rapl_files
|
|
895
|
+
if "dram" not in rapl_file.name.lower()
|
|
896
|
+
]
|
|
897
|
+
self._mirrored_candidates = find_mirrored_counters(
|
|
898
|
+
counters, SEQUENTIAL_READ_TOLERANCE_KWH
|
|
899
|
+
)
|
|
900
|
+
for path, reference_path in self._mirrored_candidates.items():
|
|
901
|
+
logger.warning(
|
|
902
|
+
"\tRAPL - Energy counter at %s looks like a mirror of %s, it is "
|
|
903
|
+
"left out of the measurement to avoid double-counting",
|
|
904
|
+
path,
|
|
905
|
+
reference_path,
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
def _confirm_mirrored_files(self) -> None:
|
|
909
|
+
"""
|
|
910
|
+
Decide the fate of the files flagged as duplicates at ``start()``.
|
|
911
|
+
|
|
912
|
+
Two independent meters could hold indistinguishable counters at the
|
|
913
|
+
single instant of the initial reading, so a candidate is only dropped
|
|
914
|
+
for good once it has also accumulated the very same energy over a
|
|
915
|
+
measurement interval. A candidate whose energy delta differs is a real
|
|
916
|
+
meter and is restored, and a candidate that has not accumulated
|
|
917
|
+
anything yet stays pending until an interval is conclusive.
|
|
918
|
+
"""
|
|
919
|
+
if not self._mirrored_candidates:
|
|
920
|
+
return
|
|
921
|
+
files_by_path = {rapl_file.path: rapl_file for rapl_file in self._rapl_files}
|
|
922
|
+
pending = {}
|
|
923
|
+
confirmed = set()
|
|
924
|
+
for path, reference_path in self._mirrored_candidates.items():
|
|
925
|
+
rapl_file = files_by_path.get(path)
|
|
926
|
+
reference = files_by_path.get(reference_path)
|
|
927
|
+
if rapl_file is None or reference is None:
|
|
928
|
+
continue
|
|
929
|
+
delta = float(rapl_file.energy_delta)
|
|
930
|
+
reference_delta = float(reference.energy_delta)
|
|
931
|
+
if delta <= 0 and reference_delta <= 0:
|
|
932
|
+
# Nothing was accumulated: the interval is not conclusive
|
|
933
|
+
pending[path] = reference_path
|
|
934
|
+
elif counters_match(delta, reference_delta, SEQUENTIAL_READ_TOLERANCE_KWH):
|
|
935
|
+
confirmed.add(path)
|
|
936
|
+
logger.warning(
|
|
937
|
+
"\tRAPL - Energy counter at %s mirrors the one at %s, it is "
|
|
938
|
+
"dropped to avoid double-counting the CPU power (multi-die "
|
|
939
|
+
"CPUs mirror the package counter on each die)",
|
|
940
|
+
path,
|
|
941
|
+
reference_path,
|
|
942
|
+
)
|
|
943
|
+
else:
|
|
944
|
+
logger.info(
|
|
945
|
+
"\tRAPL - Energy counter at %s accumulates energy on its own, "
|
|
946
|
+
"it is an independent meter and is measured again",
|
|
947
|
+
path,
|
|
948
|
+
)
|
|
949
|
+
self._mirrored_candidates = pending
|
|
950
|
+
if confirmed:
|
|
951
|
+
self._rapl_files = [
|
|
952
|
+
rapl_file
|
|
953
|
+
for rapl_file in self._rapl_files
|
|
954
|
+
if rapl_file.path not in confirmed
|
|
955
|
+
]
|
|
877
956
|
|
|
878
957
|
|
|
879
958
|
class TDP:
|
|
@@ -30,6 +30,26 @@ def clear_powermetrics_cache() -> None:
|
|
|
30
30
|
is_powermetrics_available.cache_clear()
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def _kill_process(process: subprocess.Popen) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Kill a Powermetrics subprocess, tolerating a failure to do so.
|
|
36
|
+
|
|
37
|
+
The command runs through `sudo`, so the child may be owned by root and
|
|
38
|
+
refuse our signal. Letting that raise would replace the timeout we are
|
|
39
|
+
handling with a PermissionError, and waiting on it without a bound would
|
|
40
|
+
hang for as long as the child lives.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
process.kill()
|
|
44
|
+
process.wait(timeout=1)
|
|
45
|
+
except (OSError, subprocess.SubprocessError):
|
|
46
|
+
logger.debug("Could not kill the Powermetrics process.")
|
|
47
|
+
finally:
|
|
48
|
+
for stream in (process.stdout, process.stderr):
|
|
49
|
+
if stream is not None:
|
|
50
|
+
stream.close()
|
|
51
|
+
|
|
52
|
+
|
|
33
53
|
def _has_powermetrics_sudo() -> bool:
|
|
34
54
|
if shutil.which("sudo") is None:
|
|
35
55
|
logger.debug("sudo not available, we won't use Apple PowerMetrics.")
|
|
@@ -40,7 +60,9 @@ def _has_powermetrics_sudo() -> bool:
|
|
|
40
60
|
)
|
|
41
61
|
return False
|
|
42
62
|
|
|
43
|
-
|
|
63
|
+
# No context manager here: Popen.__exit__ would wait() without a timeout,
|
|
64
|
+
# which is exactly the hang we are trying to avoid when the kill fails.
|
|
65
|
+
process = subprocess.Popen(
|
|
44
66
|
[
|
|
45
67
|
"sudo",
|
|
46
68
|
"powermetrics",
|
|
@@ -53,30 +75,31 @@ def _has_powermetrics_sudo() -> bool:
|
|
|
53
75
|
"-o",
|
|
54
76
|
"/dev/null",
|
|
55
77
|
],
|
|
78
|
+
stdin=subprocess.DEVNULL,
|
|
56
79
|
stdout=subprocess.PIPE,
|
|
57
80
|
stderr=subprocess.PIPE,
|
|
58
81
|
text=True,
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if re.search(r"[sudo].*password", stderr):
|
|
70
|
-
logger.debug("""Not using PowerMetrics, sudo password prompt detected.
|
|
71
|
-
If you want to enable Powermetrics please modify your sudoers file
|
|
72
|
-
as described in :
|
|
73
|
-
https://docs.codecarbon.io/latest/explanation/methodology/#power-usage
|
|
74
|
-
""")
|
|
75
|
-
return False
|
|
76
|
-
if process.returncode != 0:
|
|
77
|
-
raise Exception("Return code != 0")
|
|
82
|
+
)
|
|
83
|
+
deadline = time.time() + 3
|
|
84
|
+
while process.poll() is None and time.time() < deadline:
|
|
85
|
+
time.sleep(0.05)
|
|
86
|
+
if process.poll() is None:
|
|
87
|
+
logger.debug("PowerMetrics sudo check timed out.")
|
|
88
|
+
_kill_process(process)
|
|
89
|
+
return False
|
|
90
|
+
_, stderr = process.communicate()
|
|
78
91
|
|
|
79
|
-
|
|
92
|
+
if re.search(r"[sudo].*password", stderr):
|
|
93
|
+
logger.debug("""Not using PowerMetrics, sudo password prompt detected.
|
|
94
|
+
If you want to enable Powermetrics please modify your sudoers file
|
|
95
|
+
as described in :
|
|
96
|
+
https://docs.codecarbon.io/latest/explanation/methodology/#power-usage
|
|
97
|
+
""")
|
|
98
|
+
return False
|
|
99
|
+
if process.returncode != 0:
|
|
100
|
+
raise Exception("Return code != 0")
|
|
101
|
+
|
|
102
|
+
return True
|
|
80
103
|
|
|
81
104
|
|
|
82
105
|
class ApplePowermetrics:
|
|
@@ -130,47 +153,69 @@ class ApplePowermetrics:
|
|
|
130
153
|
else:
|
|
131
154
|
raise SystemError("Platform not supported by Powermetrics")
|
|
132
155
|
|
|
133
|
-
def _log_values(self) ->
|
|
156
|
+
def _log_values(self) -> bool:
|
|
134
157
|
"""
|
|
135
158
|
Logs output from Powermetrics to a file
|
|
136
|
-
"""
|
|
137
|
-
returncode = None
|
|
138
159
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
str(self._n_points),
|
|
146
|
-
"",
|
|
147
|
-
"--samplers",
|
|
148
|
-
"cpu_power",
|
|
149
|
-
"--format",
|
|
150
|
-
"csv",
|
|
151
|
-
"-i",
|
|
152
|
-
str(self._interval),
|
|
153
|
-
"-o",
|
|
154
|
-
self._log_file_path,
|
|
155
|
-
]
|
|
156
|
-
returncode = subprocess.call(cmd, universal_newlines=True)
|
|
160
|
+
:return: False when the command did not run, so that the caller knows
|
|
161
|
+
the log file was not refreshed and still holds the previous
|
|
162
|
+
measure.
|
|
163
|
+
"""
|
|
164
|
+
if not self._system.startswith("darwin"):
|
|
165
|
+
return False
|
|
157
166
|
|
|
158
|
-
|
|
159
|
-
|
|
167
|
+
# Run the powermetrics command with sudo and capture its output
|
|
168
|
+
cmd = [
|
|
169
|
+
"sudo",
|
|
170
|
+
"powermetrics",
|
|
171
|
+
"-n",
|
|
172
|
+
str(self._n_points),
|
|
173
|
+
"",
|
|
174
|
+
"--samplers",
|
|
175
|
+
"cpu_power",
|
|
176
|
+
"--format",
|
|
177
|
+
"csv",
|
|
178
|
+
"-i",
|
|
179
|
+
str(self._interval),
|
|
180
|
+
"-o",
|
|
181
|
+
self._log_file_path,
|
|
182
|
+
]
|
|
183
|
+
# _n_points samples of _interval ms is the nominal runtime of the
|
|
184
|
+
# command. Double it to absorb sampler overhead and scheduling jitter,
|
|
185
|
+
# and add a floor so short configurations keep a usable margin. This is
|
|
186
|
+
# a heuristic: it only has to be loose enough that a healthy run never
|
|
187
|
+
# trips it, because its only job is to bound a hang.
|
|
188
|
+
timeout = self._n_points * self._interval / 1000 * 2 + 5
|
|
189
|
+
# DEVNULL so that sudo gets EOF and fails fast instead of blocking
|
|
190
|
+
# forever on a password prompt when it has no cached credential.
|
|
191
|
+
# No context manager: Popen.__exit__ would wait() without a timeout.
|
|
192
|
+
process = subprocess.Popen(
|
|
193
|
+
cmd, universal_newlines=True, stdin=subprocess.DEVNULL
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
returncode = process.wait(timeout=timeout)
|
|
197
|
+
except subprocess.TimeoutExpired:
|
|
198
|
+
logger.warning(
|
|
199
|
+
f"Powermetrics did not complete within {timeout:g} seconds, "
|
|
200
|
+
"skipping this measure."
|
|
201
|
+
)
|
|
202
|
+
_kill_process(process)
|
|
203
|
+
return False
|
|
160
204
|
|
|
161
205
|
if returncode != 0:
|
|
162
206
|
logger.warning(
|
|
163
207
|
"Returncode while logging power values using "
|
|
164
208
|
+ f"Powermetrics: {returncode}"
|
|
165
209
|
)
|
|
166
|
-
return
|
|
210
|
+
return True
|
|
167
211
|
|
|
168
212
|
def get_details(self) -> Dict:
|
|
169
213
|
"""
|
|
170
214
|
Fetches the CPU Power Details by fetching values from a logged csv file
|
|
171
215
|
in _log_values function
|
|
172
216
|
"""
|
|
173
|
-
self._log_values()
|
|
217
|
+
if not self._log_values():
|
|
218
|
+
return dict()
|
|
174
219
|
details = dict()
|
|
175
220
|
try:
|
|
176
221
|
with open(self._log_file_path) as f:
|
|
@@ -3,6 +3,54 @@ from dataclasses import dataclass, field
|
|
|
3
3
|
from codecarbon.core.units import Energy, Power, Time
|
|
4
4
|
from codecarbon.external.logger import logger
|
|
5
5
|
|
|
6
|
+
# Maximum relative difference between two cumulative energy counters for them
|
|
7
|
+
# to be considered two views of the same hardware counter. Multi-die CPUs
|
|
8
|
+
# (e.g. AMD Ryzen Threadripper / EPYC) can expose one package domain per die
|
|
9
|
+
# that all mirror the same socket-wide counter, which would multiply the
|
|
10
|
+
# reported CPU power by the number of dies.
|
|
11
|
+
# See https://github.com/mlco2/codecarbon/issues/1274
|
|
12
|
+
MIRRORED_COUNTER_TOLERANCE = 1e-6
|
|
13
|
+
|
|
14
|
+
# Unlike EMI, which snapshots all the channels of a device atomically, the
|
|
15
|
+
# powercap sysfs files are read one after the other: two views of the same
|
|
16
|
+
# hardware counter differ by the energy the package accrued between the two
|
|
17
|
+
# reads. One joule covers several milliseconds at full load, while staying
|
|
18
|
+
# far below what independent packages drift apart by over a measurement
|
|
19
|
+
# interval.
|
|
20
|
+
SEQUENTIAL_READ_TOLERANCE_KWH = float(Energy.from_ujoules(1_000_000)) # 1 J
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def counters_match(first, second, abs_tolerance=0.0) -> bool:
|
|
24
|
+
"""Tell whether two cumulative counter values are indistinguishable."""
|
|
25
|
+
return abs(first - second) <= max(
|
|
26
|
+
MIRRORED_COUNTER_TOLERANCE * max(first, second), abs_tolerance
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_mirrored_counters(counters, abs_tolerance=0.0) -> dict:
|
|
31
|
+
"""
|
|
32
|
+
Identify the counters that report the same underlying energy value.
|
|
33
|
+
|
|
34
|
+
``counters`` is an ordered list of ``(key, absolute_energy)`` pairs whose
|
|
35
|
+
energies share a unit. Two independent meters are extremely unlikely to
|
|
36
|
+
hold the very same cumulative count, so equal counters point at duplicated
|
|
37
|
+
ones. A zero counter carries no information and is never flagged.
|
|
38
|
+
|
|
39
|
+
Returns a mapping of each duplicating key to the earlier key it mirrors.
|
|
40
|
+
"""
|
|
41
|
+
references = []
|
|
42
|
+
mirrored = {}
|
|
43
|
+
for key, energy in counters:
|
|
44
|
+
if energy <= 0:
|
|
45
|
+
continue
|
|
46
|
+
for reference_key, reference in references:
|
|
47
|
+
if counters_match(energy, reference, abs_tolerance):
|
|
48
|
+
mirrored[key] = reference_key
|
|
49
|
+
break
|
|
50
|
+
else:
|
|
51
|
+
references.append((key, energy))
|
|
52
|
+
return mirrored
|
|
53
|
+
|
|
6
54
|
|
|
7
55
|
@dataclass
|
|
8
56
|
class RAPLFile:
|