Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
209a253
chore: ignore local energy-accounting store
jaimeib Sep 10, 2026
9582134
refactor(wattnet): align with Wattnet API and tidy naming
jaimeib Sep 10, 2026
5979dc7
feat(datacenters): add optional TUE column
jaimeib Sep 10, 2026
972a645
feat(energy): per-deployment energy and footprint accounting engine
jaimeib Sep 10, 2026
c9f2ec4
feat(energy): run the accounting sweep as a background thread
jaimeib Sep 10, 2026
bd95edf
feat(energy): expose the energy block on the deployment and stats end…
jaimeib Sep 10, 2026
a32cecb
test(energy): pure-function, sweep-pipeline and read-API checks
jaimeib Sep 10, 2026
520fb9c
docs(energy): architecture and dashboard-integration guides
jaimeib Sep 10, 2026
be007d1
fix(energy): correct IFCA TUE to 2.73 and decouple test from real config
jaimeib Sep 14, 2026
be235b8
feat(energy): serve real-time energy before the sweep has a doc
jaimeib Sep 17, 2026
330e8af
ci: auto-format from pre-commit.com hooks
pre-commit-ci[bot] Sep 17, 2026
a70c7da
fix(energy): avoid log injection via unsanitized deployment/owner ids
jaimeib Sep 17, 2026
b965561
fix(energy): sanitize logged identifiers, not just reformat them
jaimeib Sep 17, 2026
2d3050d
refactor(energy): rename accounting to energy and split the sweep by job
jaimeib Oct 19, 2026
737cb96
fix(mimir): shorter request timeouts and a backoff when Mimir is down
jaimeib Oct 19, 2026
302b293
fix(wattnet): time out requests and keep the last good data on failures
jaimeib Oct 19, 2026
2fffd5f
test(energy): run the suite with pytest and cover the new behaviour
jaimeib Oct 19, 2026
7b53213
docs(energy): update the architecture and API docs
jaimeib Oct 19, 2026
66dd691
fix(energy): only accept plain names as namespace and deployment ids …
jaimeib Oct 19, 2026
aacef2b
chore(energy): mark the store write as a Sonar false positive
jaimeib Oct 19, 2026
9fc4408
chore(energy): keep the Sonar annotation on the line of the write
jaimeib Oct 19, 2026
f13db08
fix(energy): write the store files with open() instead of Path.write_…
jaimeib Oct 19, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,6 @@ GTAGS

# Things specific to this project #
###################################

# Local energy-accounting store ($ACCOUNTING_PTH), runtime data
.local-accounting/
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ More details can be found in the [API docs](https://api.cloud.ai4eosc.eu/docs).
It will serve the contents of the `ai4-accounting/summaries` folder.
</details>

Energy consumption and footprint (carbon and water) is attached additively to the existing responses: an `energy` key per deployment, per user in `stats/user` and per datacenter in `stats/cluster`. It needs the `MIMIR_USER` / `MIMIR_PASSWORD` variables and the `energy` section of `etc/main.yaml`. Dashboard integration reference: [docs/energy-api.md](./docs/energy-api.md); architecture and internals: [docs/energy-accounting.md](./docs/energy-accounting.md).


<details>
<summary>The API methods can also be accessed by interacting directly with
Expand Down
Empty file added ai4papi/clients/__init__.py
Empty file.
133 changes: 133 additions & 0 deletions ai4papi/clients/mimir.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please move "mimir.py" into a "clients" folder?
This will help keep the code tidy when Saúl merges his litellm client

Minimal client for the Grafana Mimir (Prometheus-compatible) HTTP API.

Used by the energy-accounting feature to read per-allocation power time series.
API reference: https://prometheus.io/docs/prometheus/latest/querying/api/
"""

import datetime
import os
import time

import requests

MIMIR_URL = "https://mimir.k8s.cloud.ai4eosc.eu/prometheus"
# Optional: the energy-accounting feature is disabled if these are not set.
MIMIR_USER = os.environ.get("MIMIR_USER")
MIMIR_PASSWORD = os.environ.get("MIMIR_PASSWORD")
if not (MIMIR_USER and MIMIR_PASSWORD):
print("You should define MIMIR_USER / MIMIR_PASSWORD to enable energy stats")

# Mimir keeps each series under a point cap per query; stay well below it.
MAX_POINTS_PER_QUERY = 11_000
# Measured against the real Mimir: a 24h range query takes under 1 s and a full
# 11,000-point chunk about 1.5 s, so these leave a wide margin while a hung Mimir
# does not hold a request or the sweep thread for a minute.

session = requests.Session()

# After a timeout or connection error Mimir is not asked again for this long (the
# calls fail fast with `MimirError`), so a hung Mimir costs one timeout per minute
# instead of one per query.
BACKOFF_SECONDS = 60
_down_until = 0.0


class MimirError(RuntimeError):
"""Raised when Mimir returns a non-successful response."""


def _auth():
return (MIMIR_USER, MIMIR_PASSWORD) if MIMIR_USER else None


def _get(path: str, params: dict, timeout: int) -> list:
"""GET a Prometheus API route and return `data.result`."""
global _down_until
if time.monotonic() < _down_until:
raise MimirError("Mimir unreachable, retrying shortly")
try:
r = session.get(
f"{MIMIR_URL}{path}", params=params, auth=_auth(), timeout=timeout
)
except (requests.ConnectionError, requests.Timeout):
_down_until = time.monotonic() + BACKOFF_SECONDS
raise
r.raise_for_status()
body = r.json()
if body.get("status") != "success":
raise MimirError(f"Mimir query failed: {body}")
return body.get("data", {}).get("result", [])


def query(promql: str, at: datetime.datetime | None = None, timeout: int = 10):
"""
Instantaneous query (`/api/v1/query`). Returns the raw `data.result` list
(vector: one entry per series, each with `metric` and `value` = [ts, str]).
"""
params = {"query": promql}
if at is not None:
params["time"] = at.timestamp()
return _get("/api/v1/query", params, timeout)


def query_range(
promql: str,
start: datetime.datetime,
end: datetime.datetime,
step_s: int = 30,
timeout: int = 15,
):
"""
Range query (`/api/v1/query_range`). Returns the raw `data.result` list
(matrix: one entry per series, each with `metric` and `values` =
[[ts, str], ...]).
"""
params = {
"query": promql,
"start": start.timestamp(),
"end": end.timestamp(),
"step": f"{step_s}s",
}
return _get("/api/v1/query_range", params, timeout)


def _split_chunks(start, end, step_s, max_points):
"""
Yield non-overlapping (chunk_start, chunk_end) tuples so each range query
stays under `max_points` samples per series (relevant when backfilling a
deployment whose pointer is far behind).
"""
while start < end:
remaining_steps = int((end - start).total_seconds() / step_s)
chunk_steps = min(remaining_steps, max_points)
if chunk_steps <= 0:
break
chunk_end = start + datetime.timedelta(seconds=chunk_steps * step_s)
yield start, min(chunk_end, end)
start = chunk_end + datetime.timedelta(seconds=step_s)


def query_range_chunked(
promql: str,
start: datetime.datetime,
end: datetime.datetime,
step_s: int = 30,
max_points: int = MAX_POINTS_PER_QUERY,
):
"""
Like `query_range` but splits long windows into consecutive chunks and
merges the results, concatenating each series' `values` sorted by timestamp.
"""
merged: dict = {}
for chunk_start, chunk_end in _split_chunks(start, end, step_s, max_points):
for series in query_range(promql, chunk_start, chunk_end, step_s):
key = frozenset(series.get("metric", {}).items())
merged.setdefault(key, {"metric": series.get("metric", {}), "values": []})
merged[key]["values"].extend(series.get("values", []))

result = []
for entry in merged.values():
entry["values"].sort(key=lambda v: float(v[0]))
result.append(entry)
return result
3 changes: 2 additions & 1 deletion ai4papi/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ class AssetConfig(TypedDict):
elif k == "country":
datacenters[name][k] = v
else:
datacenters[name][k] = float(v)
# Numeric columns; allow blank cells (eg. TUE only known for some DCs)
datacenters[name][k] = float(v) if v not in (None, "") else None

# Retrieve git info from PAPI, to show current version in the docs
papi_commit = subprocess.run(
Expand Down
Loading