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.
- greenmining/__init__.py +46 -2
- greenmining/__version__.py +1 -1
- greenmining/analyzers/__init__.py +9 -0
- greenmining/analyzers/metrics_power_correlator.py +165 -0
- greenmining/analyzers/power_regression.py +212 -0
- greenmining/analyzers/version_power_analyzer.py +246 -0
- greenmining/config.py +46 -34
- greenmining/dashboard/__init__.py +5 -0
- greenmining/dashboard/app.py +200 -0
- greenmining/energy/__init__.py +8 -1
- greenmining/energy/base.py +45 -35
- greenmining/energy/carbon_reporter.py +242 -0
- greenmining/energy/codecarbon_meter.py +25 -24
- greenmining/energy/cpu_meter.py +144 -0
- greenmining/energy/rapl.py +30 -36
- greenmining/services/__init__.py +13 -3
- greenmining/services/commit_extractor.py +9 -5
- greenmining/services/github_fetcher.py +16 -18
- greenmining/services/github_graphql_fetcher.py +45 -55
- greenmining/services/local_repo_analyzer.py +325 -63
- greenmining/services/reports.py +5 -8
- {greenmining-1.0.4.dist-info → greenmining-1.0.6.dist-info}/METADATA +65 -54
- greenmining-1.0.6.dist-info/RECORD +44 -0
- greenmining-1.0.4.dist-info/RECORD +0 -37
- {greenmining-1.0.4.dist-info → greenmining-1.0.6.dist-info}/WHEEL +0 -0
- {greenmining-1.0.4.dist-info → greenmining-1.0.6.dist-info}/licenses/LICENSE +0 -0
- {greenmining-1.0.4.dist-info → greenmining-1.0.6.dist-info}/top_level.txt +0 -0
greenmining/energy/base.py
CHANGED
|
@@ -12,36 +12,36 @@ import time
|
|
|
12
12
|
|
|
13
13
|
class EnergyBackend(Enum):
|
|
14
14
|
# Supported energy measurement backends.
|
|
15
|
-
|
|
16
|
-
RAPL = "rapl"
|
|
15
|
+
|
|
16
|
+
RAPL = "rapl" # Intel RAPL (Linux)
|
|
17
17
|
CODECARBON = "codecarbon" # CodeCarbon (cross-platform)
|
|
18
|
-
CPU_METER = "cpu_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
|
|
27
|
-
watts_avg: float = 0.0
|
|
28
|
-
watts_peak: float = 0.0
|
|
29
|
-
duration_seconds: float = 0.0
|
|
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
|
|
33
|
-
dram_energy_joules: float = 0.0
|
|
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
|
|
38
|
-
carbon_intensity: Optional[float] = None
|
|
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
|
|
70
|
-
energy_delta: float = 0.0
|
|
71
|
-
energy_regression: bool = False
|
|
72
|
-
regression_percentage: float = 0.0
|
|
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
|
+
)
|