From 213d44aa2f69d97f78e06feeb8287bcb2a403a3f Mon Sep 17 00:00:00 2001 From: Pavel Vondruska Date: Mon, 17 Aug 2026 14:41:18 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Added=20configurable=20CO2=20API=20backend:?= =?UTF-8?q?=20Nowtricity=20(default)=20or=20Wattnet.=20-=20Introduced=20[C?= =?UTF-8?q?O2=5FAPI]=20section=20with=20TYPE=20selector=20(nowtricity|watt?= =?UTF-8?q?net).=20-=20Nowtricity=20settings:=20BASE=5FURL,=20ZONE,=20USER?= =?UTF-8?q?=5FAGENT,=20API=5FKEY=20(unified=20prefix).=20-=20Wattnet=20set?= =?UTF-8?q?tings:=20URL,=20API=5FKEY,=20ZONE,=20FOOTPRINT=5FTYPE,=20SCOPE.?= =?UTF-8?q?=20-=20Removed=20hardcoded=20Nowtricity=20API=20URLs=20?= =?UTF-8?q?=E2=80=94=20configurable=20via=20config.ini.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 2 +- debian/changelog | 10 ++ setup.py | 2 +- src/hpc_eff/config.ini.example | 21 ++++- src/hpc_eff/utils/co2_value.py | 156 +++++++++++++++++++++++++++++--- src/hpc_eff/utils/controller.py | 6 +- 6 files changed, 172 insertions(+), 25 deletions(-) diff --git a/Makefile b/Makefile index 08b13e3..b86ffa5 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ NAME = hpc_eff -VERSION = 0.4 +VERSION = 0.5 RELEASE = 1 RPMDIR = $(HOME)/rpmbuild TARBALL = dist/$(NAME)-$(VERSION).tar.gz diff --git a/debian/changelog b/debian/changelog index c576ab4..20a8118 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,13 @@ +hpc-eff (0.5-1) experimental; urgency=medium + + * Added configurable CO2 API backend: Nowtricity (default) or Wattnet. + * Introduced [CO2_API] section with TYPE selector (nowtricity|wattnet). + * Nowtricity settings: BASE_URL, ZONE, USER_AGENT, API_KEY (unified prefix). + * Wattnet settings: URL, API_KEY, ZONE, FOOTPRINT_TYPE, SCOPE. + * Removed hardcoded Nowtricity API URLs — configurable via config.ini. + + -- CESNET Mon, 17 Aug 2026 13:30:00 +0200 + hpc-eff (0.4-1) experimental; urgency=medium * Unified CO₂/price and temperature regulation into single codebase. diff --git a/setup.py b/setup.py index 6ce48d5..e5fd5ce 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name="hpc_eff", - version="0.4", + version="0.5", description="Energy Optimization Governor", long_description_content_type="text/markdown", author="CESNET", diff --git a/src/hpc_eff/config.ini.example b/src/hpc_eff/config.ini.example index 77f8d30..08c9aca 100644 --- a/src/hpc_eff/config.ini.example +++ b/src/hpc_eff/config.ini.example @@ -4,10 +4,23 @@ SCORE=3.8 POWERREADINGCMD=ipmitool dcmi power reading DEBUG=yes -[API] -# Credentials for the CO2 intensity API (https://www.nowtricity.com/) -USER_AGENT=UserAgentString -API_KEY=XXX +[CO2_API] +# Which CO2 intensity API to use: nowtricity | wattnet +TYPE=nowtricity + +# Nowtricity API credentials (https://www.nowtricity.com/) +NOWTRICITY_USER_AGENT=UserAgentString +NOWTRICITY_API_KEY=*** +NOWTRICITY_BASE_URL=https://www.nowtricity.com/api +NOWTRICITY_ZONE=czech-republic + +# Wattnet API settings (used only when TYPE=wattnet) +# Start/end timestamps are calculated automatically for the trailing 24h +WATTNET_URL=https://api.wattnet.eu/v1/footprints +WATTNET_API_KEY=*** +WATTNET_ZONE=CZ +WATTNET_FOOTPRINT_TYPE=carbon +WATTNET_SCOPE=operational [logging] db_path=/var/lib/hpc_eff/history.db diff --git a/src/hpc_eff/utils/co2_value.py b/src/hpc_eff/utils/co2_value.py index 14c4eac..1a546f4 100644 --- a/src/hpc_eff/utils/co2_value.py +++ b/src/hpc_eff/utils/co2_value.py @@ -1,48 +1,150 @@ import requests import statistics -import configparser +from datetime import datetime, timedelta -def fetch_emissions_data(api_headers): + +def _nowtricity_url(config, endpoint): + """Build Nowtricity API URL from config: BASE_URL/endpoint/ZONE/""" + base = config.get('CO2_API', 'NOWTRICITY_BASE_URL', + fallback='https://www.nowtricity.com/api').rstrip('/') + zone = config.get('CO2_API', 'NOWTRICITY_ZONE', fallback='czech-republic') + return f'{base}/{endpoint}/{zone}/' + + +def fetch_emissions_data(api_headers, config): """ Fetch emissions data for the past 24 hours from the Nowtricity API. Returns: list of int: A list of emission values (in g CO2eq/kWh). """ - url = 'https://www.nowtricity.com/api/emissions-previous-24h/czech-republic/' + url = _nowtricity_url(config, 'emissions-previous-24h') response = requests.get(url, headers=api_headers, timeout=10) response.raise_for_status() data = response.json() - + # Extract emission values return [entry['value'] for entry in data['emissions']] -def fetch_current_emission(api_headers): +def fetch_current_emission(api_headers, config): """ Fetch the current emission value from the Nowtricity API. Returns: int: The current emission value (in g CO2eq/kWh). """ - url = 'https://www.nowtricity.com/api/current-emissions/czech-republic/' + url = _nowtricity_url(config, 'current-emissions') response = requests.get(url, headers=api_headers, timeout=10) response.raise_for_status() data = response.json() - + return data['emissions']['value'] +def fetch_wattnet_24h(config): + """ + Fetch emissions data for the past 24 hours from Wattnet API. + + Calls GET /v1/footprints with start/end parameters and parses + the 15-minute interval data into 24 hourly averages. + + Config keys used from [CO2_API]: + - WATTNET_URL: base URL (e.g. https://api.wattnet.eu/v1/footprints) + - WATTNET_API_KEY: Bearer token + - WATTNET_ZONE: default CZ + - WATTNET_FOOTPRINT_TYPE: default carbon + - WATTNET_SCOPE: default operational + + Returns: + tuple: (hourly_values, current_hourly) + - hourly_values: list of int, 24 hourly averages (newest first) + - current_hourly: int, most recent hourly average (or None if no data) + """ + base_url = config.get('CO2_API', 'WATTNET_URL', fallback='https://api.wattnet.eu/v1/footprints') + api_key = config.get('CO2_API', 'WATTNET_API_KEY', fallback=None) + zone = config.get('CO2_API', 'WATTNET_ZONE', fallback='CZ') + footprint_type = config.get('CO2_API', 'WATTNET_FOOTPRINT_TYPE', fallback='carbon') + scope = config.get('CO2_API', 'WATTNET_SCOPE', fallback='operational') + + # Calculate time window: previous 24 hours (UTC) + end_time = datetime.utcnow() + start_time = end_time - timedelta(hours=24) + + # Format as ISO 8601 with Z suffix (Wattnet expects this format) + iso_format = "%Y-%m-%dT%H:%M:%SZ" + params = { + 'zone': zone, + 'footprint_type': footprint_type, + 'scope': scope, + 'start': start_time.strftime(iso_format), + 'end': end_time.strftime(iso_format), + 'aggregate': 'false', + 'use_global': 'false' + } + + headers = { + 'Accept': 'application/json' + } + if api_key and api_key != 'YOUR_API_KEY_HERE': + headers['Authorization'] = f'Bearer {api_key}' + + response = requests.get(base_url, params=params, headers=headers, timeout=15) + response.raise_for_status() + data = response.json() + + # Parse wattnet structure: [{"series": [{"values": [[timestamp, value], ...]}]}] + try: + values_15min = data[0]['series'][0]['values'] + except (KeyError, IndexError, TypeError) as e: + raise ValueError( + f"Unexpected wattnet API structure: {e}. " + f"Expected: data[0]['series'][0]['values'] = [[ts, value], ...]" + ) + + if not values_15min: + raise ValueError("Wattnet API returned empty values array") + + # Sort by timestamp ascending (oldest first) for proper chunking + values_15min.sort(key=lambda x: x[0]) + + # Convert 15-minute intervals to hourly averages (4 values per hour) + hourly_values = [] + for i in range(0, len(values_15min), 4): + chunk = values_15min[i:i + 4] + if chunk: + avg = sum(float(v[1]) for v in chunk) / len(chunk) + hourly_values.append(round(avg)) + + # Most recent hourly average is the "current" value for wattnet + # (consistent with nowtricity where current ≈ history[0]) + current_hourly = hourly_values[0] if hourly_values else None + + # Reverse to newest first (to match Nowtricity order) and take last 24 + result = hourly_values[::-1][:24] + + if len(result) < 24: + # Not enough data (API may not return full 24h if data is missing) + # Pad with the last known value or repeat the available data + if result: + while len(result) < 24: + result.append(result[-1]) + else: + raise ValueError("No CO2 data available from Wattnet API") + + return result, current_hourly + + def assign_grade(current_value, historical_values): """ Assign a grade (1 to 10) based on the position of the current value within the distribution of historical values. Args: - current_value (int): The current emission value. - historical_values (list of int): Historical emission values. + current_value (int|float): The current emission value. + historical_values (list of int|float): Historical emission values. Returns: int: Grade from 1 (low) to 10 (high). @@ -53,18 +155,42 @@ def assign_grade(current_value, historical_values): sorted_values = sorted(historical_values) position = sum(1 for v in sorted_values if v < current_value) percentile = position / len(sorted_values) - + # Scale percentile to 1–10 range return round(percentile * 9) + 1 -def co2_value(api_headers): +def co2_value(config): """ - Main execution function. - Fetches data, calculates median, compares current value, and returns all. + Main execution function - unified for both Nowtricity and Wattnet APIs. + + Args: + config: configparser.ConfigParser instance with [CO2_API] section + + Returns: + tuple: (historical_values, current_value, median_value, grade) + - historical_values: list of 24 hourly values (newest first) + - current_value: current CO2 value + - median_value: median of historical_values + - grade: 1-10 rating based on percentile """ - historical_values = fetch_emissions_data(api_headers) - current_value = fetch_current_emission(api_headers) + api_type = config.get('CO2_API', 'TYPE', fallback='nowtricity').strip().lower() + + if api_type == "wattnet": + historical_values, current_hourly = fetch_wattnet_24h(config) + # For current value, use the most recent hourly average + # (consistent with nowtricity where current ≈ history[0]) + current_value = current_hourly if current_hourly is not None else historical_values[0] + else: + # Nowtricity: read credentials from [CO2_API] + headers = { + 'User-Agent': config.get('CO2_API', 'NOWTRICITY_USER_AGENT', fallback='HPC-Eff-Agent'), + 'X-Api-Key': config.get('CO2_API', 'NOWTRICITY_API_KEY') + } + + historical_values = fetch_emissions_data(headers, config) + current_value = fetch_current_emission(headers, config) + median_value = statistics.median(historical_values) grade = assign_grade(current_value, historical_values) diff --git a/src/hpc_eff/utils/controller.py b/src/hpc_eff/utils/controller.py index 656c0f1..8b7f7e1 100644 --- a/src/hpc_eff/utils/controller.py +++ b/src/hpc_eff/utils/controller.py @@ -171,10 +171,8 @@ def run_evaluation(conn, config, static_context: dict, debug_log): # Fetch CO2 values and grading (only if the price regulator needs them for logging) try: - historical_values, current_value, median_value, grade = co2_value({ - 'User-Agent': config['API']['USER_AGENT'], - 'X-Api-Key': config['API']['API_KEY'] - }) + # Pass config object to support both nowtricity and wattnet APIs + historical_values, current_value, median_value, grade = co2_value(config) debug_log(f"Last 24 hours CO2 values (g CO2eq/kWh): {historical_values}") debug_log(f"Current CO2 value (g CO2eq/kWh): {current_value}") debug_log(f"Median value from 24 hours values (g CO2eq/kWh): {median_value}") From 34f1ae0daf868c2b58d40e54af2f6870535b794c Mon Sep 17 00:00:00 2001 From: pailozka Date: Mon, 17 Aug 2026 15:54:26 +0200 Subject: [PATCH 2/2] fix(co2_value): raise instead of padding on incomplete Wattnet data --- src/hpc_eff/utils/co2_value.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/hpc_eff/utils/co2_value.py b/src/hpc_eff/utils/co2_value.py index 1a546f4..8d9c288 100644 --- a/src/hpc_eff/utils/co2_value.py +++ b/src/hpc_eff/utils/co2_value.py @@ -126,13 +126,7 @@ def fetch_wattnet_24h(config): result = hourly_values[::-1][:24] if len(result) < 24: - # Not enough data (API may not return full 24h if data is missing) - # Pad with the last known value or repeat the available data - if result: - while len(result) < 24: - result.append(result[-1]) - else: - raise ValueError("No CO2 data available from Wattnet API") + raise ValueError(f"Wattnet API returned only {len(result)}/24 hourly values") return result, current_hourly