greenmining 1.0.4__py3-none-any.whl → 1.0.6__py3-none-any.whl

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.
@@ -12,36 +12,36 @@ import time
12
12
 
13
13
  class EnergyBackend(Enum):
14
14
  # Supported energy measurement backends.
15
-
16
- RAPL = "rapl" # Intel RAPL (Linux)
15
+
16
+ RAPL = "rapl" # Intel RAPL (Linux)
17
17
  CODECARBON = "codecarbon" # CodeCarbon (cross-platform)
18
- CPU_METER = "cpu_meter" # CPU Energy Meter
18
+ CPU_METER = "cpu_meter" # CPU Energy Meter
19
19
 
20
20
 
21
21
  @dataclass
22
22
  class EnergyMetrics:
23
23
  # Energy measurement results from a profiling session.
24
-
24
+
25
25
  # Core energy metrics
26
- joules: float = 0.0 # Total energy consumed
27
- watts_avg: float = 0.0 # Average power draw
28
- watts_peak: float = 0.0 # Peak power draw
29
- duration_seconds: float = 0.0 # Measurement duration
30
-
26
+ joules: float = 0.0 # Total energy consumed
27
+ watts_avg: float = 0.0 # Average power draw
28
+ watts_peak: float = 0.0 # Peak power draw
29
+ duration_seconds: float = 0.0 # Measurement duration
30
+
31
31
  # Component-specific energy (if available)
32
- cpu_energy_joules: float = 0.0 # CPU-specific energy
33
- dram_energy_joules: float = 0.0 # Memory energy
32
+ cpu_energy_joules: float = 0.0 # CPU-specific energy
33
+ dram_energy_joules: float = 0.0 # Memory energy
34
34
  gpu_energy_joules: Optional[float] = None # GPU energy if available
35
-
35
+
36
36
  # Carbon footprint (if carbon tracking enabled)
37
- carbon_grams: Optional[float] = None # CO2 equivalent in grams
38
- carbon_intensity: Optional[float] = None # gCO2/kWh of grid
39
-
37
+ carbon_grams: Optional[float] = None # CO2 equivalent in grams
38
+ carbon_intensity: Optional[float] = None # gCO2/kWh of grid
39
+
40
40
  # Metadata
41
41
  backend: str = ""
42
42
  start_time: Optional[datetime] = None
43
43
  end_time: Optional[datetime] = None
44
-
44
+
45
45
  def to_dict(self) -> Dict[str, Any]:
46
46
  # Convert to dictionary.
47
47
  return {
@@ -63,14 +63,14 @@ class EnergyMetrics:
63
63
  @dataclass
64
64
  class CommitEnergyProfile:
65
65
  # Energy profile for a specific commit.
66
-
66
+
67
67
  commit_hash: str
68
68
  energy_before: Optional[EnergyMetrics] = None # Parent commit energy
69
- energy_after: Optional[EnergyMetrics] = None # This commit energy
70
- energy_delta: float = 0.0 # Change in joules
71
- energy_regression: bool = False # True if energy increased
72
- regression_percentage: float = 0.0 # % change
73
-
69
+ energy_after: Optional[EnergyMetrics] = None # This commit energy
70
+ energy_delta: float = 0.0 # Change in joules
71
+ energy_regression: bool = False # True if energy increased
72
+ regression_percentage: float = 0.0 # % change
73
+
74
74
  def to_dict(self) -> Dict[str, Any]:
75
75
  # Convert to dictionary.
76
76
  return {
@@ -85,29 +85,29 @@ class CommitEnergyProfile:
85
85
 
86
86
  class EnergyMeter(ABC):
87
87
  # Abstract base class for energy measurement backends.
88
-
88
+
89
89
  def __init__(self, backend: EnergyBackend):
90
90
  # Initialize the energy meter.
91
91
  self.backend = backend
92
92
  self._is_measuring = False
93
93
  self._start_time: Optional[float] = None
94
94
  self._measurements: List[float] = []
95
-
95
+
96
96
  @abstractmethod
97
97
  def is_available(self) -> bool:
98
98
  # Check if this energy measurement backend is available on the system.
99
99
  pass
100
-
100
+
101
101
  @abstractmethod
102
102
  def start(self) -> None:
103
103
  # Start energy measurement.
104
104
  pass
105
-
105
+
106
106
  @abstractmethod
107
107
  def stop(self) -> EnergyMetrics:
108
108
  # Stop energy measurement and return results.
109
109
  pass
110
-
110
+
111
111
  def measure(self, func: Callable, *args, **kwargs) -> tuple[Any, EnergyMetrics]:
112
112
  # Measure energy consumption of a function call.
113
113
  self.start()
@@ -116,11 +116,11 @@ class EnergyMeter(ABC):
116
116
  finally:
117
117
  metrics = self.stop()
118
118
  return result, metrics
119
-
119
+
120
120
  def measure_command(self, command: str, timeout: Optional[int] = None) -> EnergyMetrics:
121
121
  # Measure energy consumption of a shell command.
122
122
  import subprocess
123
-
123
+
124
124
  self.start()
125
125
  try:
126
126
  subprocess.run(
@@ -133,12 +133,12 @@ class EnergyMeter(ABC):
133
133
  finally:
134
134
  metrics = self.stop()
135
135
  return metrics
136
-
136
+
137
137
  def __enter__(self):
138
138
  # Context manager entry.
139
139
  self.start()
140
140
  return self
141
-
141
+
142
142
  def __exit__(self, exc_type, exc_val, exc_tb):
143
143
  # Context manager exit.
144
144
  self.stop()
@@ -147,19 +147,29 @@ class EnergyMeter(ABC):
147
147
 
148
148
  def get_energy_meter(backend: str = "rapl") -> EnergyMeter:
149
149
  # Factory function to get an energy meter instance.
150
+ # Supported backends: rapl, codecarbon, cpu_meter, auto
150
151
  from .rapl import RAPLEnergyMeter
151
152
  from .codecarbon_meter import CodeCarbonMeter
152
-
153
+ from .cpu_meter import CPUEnergyMeter
154
+
153
155
  backend_lower = backend.lower()
154
-
156
+
155
157
  if backend_lower == "rapl":
156
158
  meter = RAPLEnergyMeter()
157
159
  elif backend_lower == "codecarbon":
158
160
  meter = CodeCarbonMeter()
161
+ elif backend_lower in ("cpu_meter", "cpu"):
162
+ meter = CPUEnergyMeter()
163
+ elif backend_lower == "auto":
164
+ # Try RAPL first (most accurate), fall back to CPU meter
165
+ rapl = RAPLEnergyMeter()
166
+ if rapl.is_available():
167
+ return rapl
168
+ meter = CPUEnergyMeter()
159
169
  else:
160
170
  raise ValueError(f"Unsupported energy backend: {backend}")
161
-
171
+
162
172
  if not meter.is_available():
163
173
  raise ValueError(f"Energy backend '{backend}' is not available on this system")
164
-
174
+
165
175
  return meter
@@ -0,0 +1,242 @@
1
+ # Carbon footprint reporter for estimating CO2 emissions from energy measurements.
2
+ # Converts energy consumption to carbon equivalents using regional grid intensity data.
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from .base import EnergyMetrics
10
+
11
+ # Average carbon intensity by country (gCO2/kWh) - 2024 data
12
+ # Source: Electricity Maps, IEA
13
+ CARBON_INTENSITY_BY_COUNTRY = {
14
+ "USA": 379,
15
+ "GBR": 207,
16
+ "DEU": 338,
17
+ "FRA": 56,
18
+ "SWE": 25,
19
+ "NOR": 17,
20
+ "CAN": 120,
21
+ "AUS": 548,
22
+ "JPN": 432,
23
+ "CHN": 555,
24
+ "IND": 632,
25
+ "BRA": 75,
26
+ "ITA": 315,
27
+ "ESP": 175,
28
+ "NLD": 328,
29
+ "KOR": 415,
30
+ "POL": 614,
31
+ "ZAF": 709,
32
+ "MEX": 391,
33
+ "TUR": 377,
34
+ }
35
+
36
+ # Carbon intensity by cloud provider region (gCO2/kWh)
37
+ CLOUD_REGION_INTENSITY = {
38
+ "aws": {
39
+ "us-east-1": 379,
40
+ "us-east-2": 425,
41
+ "us-west-1": 230,
42
+ "us-west-2": 118,
43
+ "eu-west-1": 316,
44
+ "eu-west-2": 207,
45
+ "eu-west-3": 56,
46
+ "eu-north-1": 25,
47
+ "eu-central-1": 338,
48
+ "ap-northeast-1": 432,
49
+ "ap-southeast-1": 379,
50
+ "ap-south-1": 632,
51
+ "ca-central-1": 120,
52
+ "sa-east-1": 75,
53
+ },
54
+ "gcp": {
55
+ "us-central1": 425,
56
+ "us-east1": 379,
57
+ "us-west1": 118,
58
+ "europe-west1": 175,
59
+ "europe-west4": 328,
60
+ "europe-north1": 25,
61
+ "asia-east1": 509,
62
+ "asia-northeast1": 432,
63
+ "australia-southeast1": 548,
64
+ },
65
+ "azure": {
66
+ "eastus": 379,
67
+ "westus2": 118,
68
+ "westeurope": 328,
69
+ "northeurope": 316,
70
+ "swedencentral": 25,
71
+ "francecentral": 56,
72
+ "japaneast": 432,
73
+ "australiaeast": 548,
74
+ },
75
+ }
76
+
77
+
78
+ @dataclass
79
+ class CarbonReport:
80
+ # Carbon emissions report from energy measurements.
81
+
82
+ total_energy_kwh: float = 0.0
83
+ total_emissions_kg: float = 0.0
84
+ carbon_intensity_gco2_kwh: float = 0.0
85
+ country_iso: str = ""
86
+ cloud_provider: str = ""
87
+ cloud_region: str = ""
88
+ tree_months: float = 0.0 # Equivalent tree-months to offset
89
+ smartphone_charges: float = 0.0 # Equivalent smartphone charges
90
+ km_driven: float = 0.0 # Equivalent km driven in average car
91
+ analysis_results: List[Dict[str, Any]] = field(default_factory=list)
92
+
93
+ def to_dict(self) -> Dict[str, Any]:
94
+ return {
95
+ "total_energy_kwh": round(self.total_energy_kwh, 6),
96
+ "total_emissions_kg": round(self.total_emissions_kg, 6),
97
+ "total_emissions_grams": round(self.total_emissions_kg * 1000, 4),
98
+ "carbon_intensity_gco2_kwh": self.carbon_intensity_gco2_kwh,
99
+ "country_iso": self.country_iso,
100
+ "cloud_provider": self.cloud_provider,
101
+ "cloud_region": self.cloud_region,
102
+ "equivalents": {
103
+ "tree_months": round(self.tree_months, 2),
104
+ "smartphone_charges": round(self.smartphone_charges, 1),
105
+ "km_driven": round(self.km_driven, 3),
106
+ },
107
+ "analysis_results": self.analysis_results,
108
+ }
109
+
110
+ def summary(self) -> str:
111
+ # Generate human-readable summary.
112
+ lines = [
113
+ "Carbon Footprint Report",
114
+ "-" * 40,
115
+ f"Total Energy: {self.total_energy_kwh:.6f} kWh",
116
+ f"CO2 Emissions: {self.total_emissions_kg * 1000:.4f} grams",
117
+ f"Carbon Intensity: {self.carbon_intensity_gco2_kwh} gCO2/kWh",
118
+ ]
119
+ if self.country_iso:
120
+ lines.append(f"Region: {self.country_iso}")
121
+ if self.cloud_provider and self.cloud_region:
122
+ lines.append(f"Cloud: {self.cloud_provider} ({self.cloud_region})")
123
+ lines.extend(
124
+ [
125
+ "",
126
+ "Equivalents:",
127
+ f" {self.tree_months:.2f} tree-months to offset",
128
+ f" {self.smartphone_charges:.1f} smartphone charges",
129
+ f" {self.km_driven:.3f} km driven (average car)",
130
+ ]
131
+ )
132
+ return "\n".join(lines)
133
+
134
+
135
+ class CarbonReporter:
136
+ # Generate carbon footprint reports from energy measurements.
137
+
138
+ # Constants for equivalence calculations
139
+ TREE_ABSORPTION_KG_PER_MONTH = 1.0 # ~12 kg CO2/year per tree
140
+ SMARTPHONE_CHARGE_KWH = 0.012 # ~12 Wh per charge
141
+ CAR_EMISSIONS_KG_PER_KM = 0.12 # Average ICE car
142
+
143
+ def __init__(
144
+ self,
145
+ country_iso: str = "USA",
146
+ cloud_provider: Optional[str] = None,
147
+ region: Optional[str] = None,
148
+ ):
149
+ # Initialize carbon reporter.
150
+ # Args:
151
+ # country_iso: ISO 3166-1 alpha-3 country code
152
+ # cloud_provider: Cloud provider (aws, gcp, azure)
153
+ # region: Cloud region (e.g., us-east-1)
154
+ self.country_iso = country_iso.upper()
155
+ self.cloud_provider = (cloud_provider or "").lower()
156
+ self.region = region or ""
157
+ self.carbon_intensity = self._get_carbon_intensity()
158
+
159
+ def _get_carbon_intensity(self) -> float:
160
+ # Determine carbon intensity based on cloud region or country.
161
+ # Cloud region takes priority
162
+ if self.cloud_provider and self.region:
163
+ provider_regions = CLOUD_REGION_INTENSITY.get(self.cloud_provider, {})
164
+ if self.region in provider_regions:
165
+ return provider_regions[self.region]
166
+
167
+ # Fall back to country average
168
+ return CARBON_INTENSITY_BY_COUNTRY.get(self.country_iso, 400)
169
+
170
+ def generate_report(
171
+ self,
172
+ energy_metrics: Optional[EnergyMetrics] = None,
173
+ analysis_results: Optional[List[Dict[str, Any]]] = None,
174
+ total_joules: Optional[float] = None,
175
+ ) -> CarbonReport:
176
+ # Generate a carbon footprint report.
177
+ # Args:
178
+ # energy_metrics: EnergyMetrics object from measurement
179
+ # analysis_results: List of analysis result dicts with energy data
180
+ # total_joules: Direct energy input in joules
181
+
182
+ total_energy_joules = 0.0
183
+ result_summaries = []
184
+
185
+ if energy_metrics:
186
+ total_energy_joules += energy_metrics.joules
187
+
188
+ if total_joules:
189
+ total_energy_joules += total_joules
190
+
191
+ if analysis_results:
192
+ for result in analysis_results:
193
+ energy = result.get("energy_metrics", {})
194
+ if energy:
195
+ joules = energy.get("joules", 0)
196
+ total_energy_joules += joules
197
+ result_summaries.append(
198
+ {
199
+ "name": result.get("name", "unknown"),
200
+ "energy_joules": joules,
201
+ "duration_seconds": energy.get("duration_seconds", 0),
202
+ }
203
+ )
204
+
205
+ # Convert joules to kWh
206
+ total_kwh = total_energy_joules / 3_600_000
207
+
208
+ # Calculate emissions
209
+ emissions_grams = total_kwh * self.carbon_intensity
210
+ emissions_kg = emissions_grams / 1000
211
+
212
+ # Calculate equivalents
213
+ tree_months = emissions_kg / self.TREE_ABSORPTION_KG_PER_MONTH if emissions_kg > 0 else 0
214
+ smartphone_charges = total_kwh / self.SMARTPHONE_CHARGE_KWH if total_kwh > 0 else 0
215
+ km_driven = emissions_kg / self.CAR_EMISSIONS_KG_PER_KM if emissions_kg > 0 else 0
216
+
217
+ return CarbonReport(
218
+ total_energy_kwh=total_kwh,
219
+ total_emissions_kg=emissions_kg,
220
+ carbon_intensity_gco2_kwh=self.carbon_intensity,
221
+ country_iso=self.country_iso,
222
+ cloud_provider=self.cloud_provider,
223
+ cloud_region=self.region,
224
+ tree_months=tree_months,
225
+ smartphone_charges=smartphone_charges,
226
+ km_driven=km_driven,
227
+ analysis_results=result_summaries,
228
+ )
229
+
230
+ def get_carbon_intensity(self) -> float:
231
+ # Get the configured carbon intensity.
232
+ return self.carbon_intensity
233
+
234
+ @staticmethod
235
+ def get_supported_countries() -> List[str]:
236
+ # Get list of supported country ISO codes.
237
+ return list(CARBON_INTENSITY_BY_COUNTRY.keys())
238
+
239
+ @staticmethod
240
+ def get_supported_cloud_regions(provider: str) -> List[str]:
241
+ # Get list of supported cloud regions for a provider.
242
+ return list(CLOUD_REGION_INTENSITY.get(provider.lower(), {}).keys())
@@ -11,7 +11,7 @@ from .base import EnergyMeter, EnergyMetrics, EnergyBackend
11
11
 
12
12
  class CodeCarbonMeter(EnergyMeter):
13
13
  # Energy measurement using CodeCarbon library.
14
-
14
+
15
15
  def __init__(
16
16
  self,
17
17
  project_name: str = "greenmining",
@@ -26,32 +26,33 @@ class CodeCarbonMeter(EnergyMeter):
26
26
  self._tracker = None
27
27
  self._start_time: Optional[float] = None
28
28
  self._codecarbon_available = self._check_codecarbon()
29
-
29
+
30
30
  def _check_codecarbon(self) -> bool:
31
31
  # Check if CodeCarbon is installed.
32
32
  try:
33
33
  from codecarbon import EmissionsTracker
34
+
34
35
  return True
35
36
  except ImportError:
36
37
  return False
37
-
38
+
38
39
  def is_available(self) -> bool:
39
40
  # Check if CodeCarbon is available.
40
41
  return self._codecarbon_available
41
-
42
+
42
43
  def start(self) -> None:
43
44
  # Start energy measurement.
44
45
  if not self._codecarbon_available:
45
46
  raise RuntimeError("CodeCarbon is not installed. Run: pip install codecarbon")
46
-
47
+
47
48
  if self._is_measuring:
48
49
  raise RuntimeError("Already measuring energy")
49
-
50
+
50
51
  from codecarbon import EmissionsTracker
51
-
52
+
52
53
  self._is_measuring = True
53
54
  self._start_time = time.time()
54
-
55
+
55
56
  # Create emissions tracker
56
57
  tracker_kwargs = {
57
58
  "project_name": self.project_name,
@@ -59,27 +60,27 @@ class CodeCarbonMeter(EnergyMeter):
59
60
  "save_to_file": self.save_to_file,
60
61
  "log_level": "error", # Suppress verbose output
61
62
  }
62
-
63
+
63
64
  if self.output_dir:
64
65
  tracker_kwargs["output_dir"] = self.output_dir
65
-
66
+
66
67
  self._tracker = EmissionsTracker(**tracker_kwargs)
67
68
  self._tracker.start()
68
-
69
+
69
70
  def stop(self) -> EnergyMetrics:
70
71
  # Stop energy measurement and return results.
71
72
  if not self._is_measuring:
72
73
  raise RuntimeError("Not currently measuring energy")
73
-
74
+
74
75
  end_time = time.time()
75
76
  self._is_measuring = False
76
-
77
+
77
78
  # Stop tracker and get emissions
78
79
  emissions_kg = self._tracker.stop()
79
-
80
+
80
81
  # Get detailed data from tracker
81
82
  duration = end_time - self._start_time
82
-
83
+
83
84
  # CodeCarbon stores data in tracker._total_energy (kWh)
84
85
  # In v3.x it may return an Energy object, extract the value
85
86
  energy_raw = getattr(self._tracker, "_total_energy", 0) or 0
@@ -87,13 +88,13 @@ class CodeCarbonMeter(EnergyMeter):
87
88
  energy_kwh = float(energy_raw.kWh)
88
89
  else:
89
90
  energy_kwh = float(energy_raw) if energy_raw else 0.0
90
-
91
+
91
92
  # Convert kWh to joules (1 kWh = 3,600,000 J)
92
93
  energy_joules = energy_kwh * 3_600_000
93
-
94
+
94
95
  # Calculate average power
95
96
  watts_avg = (energy_joules / duration) if duration > 0 else 0
96
-
97
+
97
98
  # Get carbon intensity if available
98
99
  carbon_intensity = None
99
100
  try:
@@ -102,12 +103,12 @@ class CodeCarbonMeter(EnergyMeter):
102
103
  carbon_intensity = float(carbon_intensity.value)
103
104
  except Exception:
104
105
  pass
105
-
106
+
106
107
  # Convert emissions from kg to grams (handle Energy objects)
107
108
  if hasattr(emissions_kg, "value"):
108
109
  emissions_kg = float(emissions_kg.value)
109
110
  carbon_grams = float(emissions_kg or 0) * 1000
110
-
111
+
111
112
  return EnergyMetrics(
112
113
  joules=energy_joules,
113
114
  watts_avg=watts_avg,
@@ -122,15 +123,15 @@ class CodeCarbonMeter(EnergyMeter):
122
123
  start_time=datetime.fromtimestamp(self._start_time),
123
124
  end_time=datetime.fromtimestamp(end_time),
124
125
  )
125
-
126
+
126
127
  def get_carbon_intensity(self) -> Optional[float]:
127
128
  # Get current carbon intensity for the configured region.
128
129
  if not self._codecarbon_available:
129
130
  return None
130
-
131
+
131
132
  try:
132
133
  from codecarbon import EmissionsTracker
133
-
134
+
134
135
  # Create temporary tracker to get carbon intensity
135
136
  tracker = EmissionsTracker(
136
137
  project_name="carbon_check",
@@ -140,7 +141,7 @@ class CodeCarbonMeter(EnergyMeter):
140
141
  )
141
142
  tracker.start()
142
143
  tracker.stop()
143
-
144
+
144
145
  return getattr(tracker, "_carbon_intensity", None)
145
146
  except Exception:
146
147
  return None
@@ -0,0 +1,144 @@
1
+ # Cross-platform CPU energy meter using system resource monitoring.
2
+ # Supports Linux, macOS, and Windows by estimating power from CPU utilization.
3
+
4
+ from __future__ import annotations
5
+
6
+ import time
7
+ import platform
8
+ from datetime import datetime
9
+ from typing import Dict, List, Optional
10
+
11
+ from .base import EnergyMeter, EnergyMetrics, EnergyBackend
12
+
13
+
14
+ class CPUEnergyMeter(EnergyMeter):
15
+ # Cross-platform CPU energy estimation using utilization-based modeling.
16
+ # Uses CPU utilization percentage to estimate power draw based on TDP.
17
+ # Falls back to RAPL on Linux when available for direct measurement.
18
+
19
+ # Default TDP values by platform (in watts)
20
+ DEFAULT_TDP = {
21
+ "Linux": 65.0,
22
+ "Darwin": 30.0, # Apple Silicon typical
23
+ "Windows": 65.0,
24
+ }
25
+
26
+ def __init__(self, tdp_watts: Optional[float] = None, sample_interval: float = 0.5):
27
+ # Initialize CPU energy meter.
28
+ # Args:
29
+ # tdp_watts: CPU Thermal Design Power in watts (auto-detected if None)
30
+ # sample_interval: Sampling interval in seconds
31
+ super().__init__(EnergyBackend.CPU_METER)
32
+ self.tdp_watts = tdp_watts or self._detect_tdp()
33
+ self.sample_interval = sample_interval
34
+ self._start_time: Optional[float] = None
35
+ self._samples: List[float] = []
36
+ self._platform = platform.system()
37
+ self._psutil_available = self._check_psutil()
38
+
39
+ def _check_psutil(self) -> bool:
40
+ # Check if psutil is available.
41
+ try:
42
+ import psutil
43
+
44
+ return True
45
+ except ImportError:
46
+ return False
47
+
48
+ def _detect_tdp(self) -> float:
49
+ # Auto-detect CPU TDP based on platform.
50
+ system = platform.system()
51
+
52
+ # Try to read from Linux sysfs
53
+ if system == "Linux":
54
+ try:
55
+ import os
56
+
57
+ rapl_path = "/sys/class/powercap/intel-rapl/intel-rapl:0/constraint_0_max_power_uw"
58
+ if os.path.exists(rapl_path):
59
+ with open(rapl_path) as f:
60
+ return int(f.read().strip()) / 1_000_000 # Convert uW to W
61
+ except Exception:
62
+ pass
63
+
64
+ return self.DEFAULT_TDP.get(system, 65.0)
65
+
66
+ def _get_cpu_percent(self) -> float:
67
+ # Get current CPU utilization percentage.
68
+ if self._psutil_available:
69
+ import psutil
70
+
71
+ return psutil.cpu_percent(interval=None)
72
+
73
+ # Fallback: read from /proc/stat on Linux
74
+ if self._platform == "Linux":
75
+ try:
76
+ with open("/proc/stat") as f:
77
+ line = f.readline()
78
+ parts = line.split()
79
+ idle = int(parts[4])
80
+ total = sum(int(p) for p in parts[1:])
81
+ return (1 - idle / total) * 100 if total > 0 else 0.0
82
+ except Exception:
83
+ pass
84
+
85
+ return 50.0 # Default estimate
86
+
87
+ def is_available(self) -> bool:
88
+ # CPU energy estimation is available on all platforms.
89
+ return True
90
+
91
+ def start(self) -> None:
92
+ # Start energy measurement.
93
+ if self._is_measuring:
94
+ raise RuntimeError("Already measuring energy")
95
+ self._is_measuring = True
96
+ self._start_time = time.time()
97
+ self._samples = []
98
+ # Prime the CPU percent measurement
99
+ if self._psutil_available:
100
+ import psutil
101
+
102
+ psutil.cpu_percent(interval=None)
103
+
104
+ def stop(self) -> EnergyMetrics:
105
+ # Stop energy measurement and return results.
106
+ if not self._is_measuring:
107
+ raise RuntimeError("Not currently measuring energy")
108
+
109
+ end_time = time.time()
110
+ self._is_measuring = False
111
+ duration = end_time - self._start_time
112
+
113
+ # Get final CPU utilization sample
114
+ cpu_percent = self._get_cpu_percent()
115
+ self._samples.append(cpu_percent)
116
+
117
+ # Estimate energy from CPU utilization and TDP
118
+ # Power model: P = P_idle + (P_max - P_idle) * utilization
119
+ # Typical idle power is ~30% of TDP
120
+ idle_fraction = 0.3
121
+ p_idle = self.tdp_watts * idle_fraction
122
+ avg_utilization = sum(self._samples) / len(self._samples) / 100.0
123
+
124
+ estimated_power = p_idle + (self.tdp_watts - p_idle) * avg_utilization
125
+ estimated_joules = estimated_power * duration
126
+
127
+ return EnergyMetrics(
128
+ joules=estimated_joules,
129
+ watts_avg=estimated_power,
130
+ watts_peak=(
131
+ self.tdp_watts * max(s / 100.0 for s in self._samples)
132
+ if self._samples
133
+ else estimated_power
134
+ ),
135
+ duration_seconds=duration,
136
+ cpu_energy_joules=estimated_joules,
137
+ dram_energy_joules=0,
138
+ gpu_energy_joules=None,
139
+ carbon_grams=None,
140
+ carbon_intensity=None,
141
+ backend="cpu_meter",
142
+ start_time=datetime.fromtimestamp(self._start_time),
143
+ end_time=datetime.fromtimestamp(end_time),
144
+ )