Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
NAME = hpc_eff
VERSION = 0.4
VERSION = 0.5
RELEASE = 1
RPMDIR = $(HOME)/rpmbuild
TARBALL = dist/$(NAME)-$(VERSION).tar.gz
Expand Down
10 changes: 10 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -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 <vondruska@cesnet.cz> 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.
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 17 additions & 4 deletions src/hpc_eff/config.ini.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
150 changes: 135 additions & 15 deletions src/hpc_eff/utils/co2_value.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,144 @@
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:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

questionable if feeding padded data into decisions is the right approach. rather fail loudly:
if len(result) < 24: raise ValueError(f"Wattnet API returned only {len(result)}/24 hourly values")

it is consistent with the fallback logic here:

try:
historical_values, current_value, median_value, grade = co2_value({
'User-Agent': config['API']['USER_AGENT'],
'X-Api-Key': config['API']['API_KEY']
})
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}")
debug_log(f"Current CO2 value grade from 1 (low) to 10 (high): {grade}")
except Exception as e:
historical_values, current_value, median_value, grade = None, None, None, "unknown"
debug_log(f"Error fetching co2 values and rating: {e}")

raise ValueError(f"Wattnet API returned only {len(result)}/24 hourly values")

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).
Expand All @@ -53,18 +149,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)

Expand Down
6 changes: 2 additions & 4 deletions src/hpc_eff/utils/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
Loading