Sondov Engen (@sen)(edited)·permalink
If you draw the horizontal lines after the bars (in the code/dom) they will show on top. (it's a bit hard to read the legend on the bottom one (i guess the one that says Norgespris)
Martin Myrseth (@mmy)·permalink
Good point.
Sondov Engen (@sen)·permalink

Want another tip? Since today is only the right 1/3 of the vis, it's probably a better idea to move the legend to the right. (the dates and colors). I would also align them horizontally instead of vertically, that way you avoid them overlapping your high price lines.

Finally, it's possible to add a little white outline to your text, that makes it a lot easier to read it against a striped background like you have (NYTimes does this a lot)

You might also want to take in some of the margin on the left side to move the price closer to axis label

Adam Falchenberg (@afa)1 point·permalink
Clean viz man!

Description

The below description is supplied in free-text by the user
#!/usr/bin/env python3
"""Norwegian day-ahead power prices -> the `strompriser` novem space.

Step one of the `strompriser` job. It fetches the Nord Pool day-ahead prices
for all five bidding zones (NO1-NO5) from hvakosterstrommen.no, keeps them in
the space as one CSV per day, and refreshes the `strompriser` plot (NO1,
yesterday/today/tomorrow, incl. VAT) from that dataset. Later chain steps
(and other jobs) read the same space to render their own plots.

The space is mounted at /mnt/prices (the job's config/spaces):

    README.md                      the dataset's schema and provenance
    day-ahead/<YYYY>/<YYYY-MM-DD>.csv
        time_start,time_end,zone,nok_per_kwh,eur_per_kwh,exr
        one row per zone per published slot (24/day hourly; 96 when the
        source publishes quarter-hours), prices excl. VAT and grid tariffs

A day file is only written once all five zones are published, so a present
file is always complete and never rewritten. Each run fetches the days after
the newest file through tomorrow (tomorrow appears ~13:00 CET).

    python3 strompriser.py [--prices DIR] [--since YYYY-MM-DD] [--plot NAME]

Fetching and the dataset are stdlib only; refreshing the plot uses the novem
Python SDK (requirements.txt), which reads the run's NOVEM_TOKEN from the
environment.
"""

import argparse
import csv
import datetime as dt
import inspect
import io
import json
import sys
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from zoneinfo import ZoneInfo

ZONES = ["NO1", "NO2", "NO3", "NO4", "NO5"]
FIELDS = ["time_start", "time_end", "zone", "nok_per_kwh", "eur_per_kwh", "exr"]
SOURCE = "https://www.hvakosterstrommen.no/api/v1/prices/{d:%Y}/{d:%m}-{d:%d}_{z}.json"
MVA = 0.25  # Norwegian VAT, applied for the strompriser plot only
OSLO = ZoneInfo("Europe/Oslo")

README = """\
# Strømpriser — Norwegian day-ahead power prices

Nord Pool day-ahead prices for Norway's five bidding zones (NO1 Oslo, NO2
Kristiansand, NO3 Trondheim, NO4 Tromsø, NO5 Bergen), as published by
hvakosterstrommen.no. Maintained by the `strompriser` novem job
(repo `strompriser-aux`), which adds each new day as soon as all five zones
are published (tomorrow's prices appear around 13:00 CET).

## Layout

    day-ahead/<YYYY>/<YYYY-MM-DD>.csv

One file per delivery day (Europe/Oslo), complete and never rewritten:

| column        | meaning                                        |
| ------------- | ---------------------------------------------- |
| `time_start`  | slot start, ISO 8601 with Oslo offset          |
| `time_end`    | slot end                                       |
| `zone`        | `NO1` … `NO5`                                  |
| `nok_per_kwh` | price in NOK/kWh, excl. VAT and grid tariffs   |
| `eur_per_kwh` | price in EUR/kWh                               |
| `exr`         | EUR→NOK exchange rate used by the source       |

Slots are hourly (24 per day; 23/25 on DST days), or quarter-hourly where
the source publishes 15-minute prices. Group by `time_start[:13]` for hours.
"""


def fetch(day: dt.date, zone: str) -> list | None:
    """One zone-day from the source, or None if it isn't published (yet)."""
    req = urllib.request.Request(SOURCE.format(d=day, z=zone),
                                 headers={"User-Agent": "novem-strompriser/2.0"})
    for attempt in range(4):
        try:
            with urllib.request.urlopen(req, timeout=30) as r:
                return json.loads(r.read())
        except urllib.error.HTTPError as e:
            if e.code == 404:
                return None
        except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
            pass
        time.sleep(2 ** attempt)
    raise RuntimeError(f"giving up on {zone} {day}")


def day_path(root: Path, day: dt.date) -> Path:
    return root / "day-ahead" / f"{day:%Y}" / f"{day:%Y-%m-%d}.csv"


def day_csv(by_zone: dict[str, list]) -> str:
    """A day's file content from the source's per-zone JSON."""
    buf = io.StringIO()
    w = csv.writer(buf, lineterminator="\n")
    w.writerow(FIELDS)
    for z in ZONES:
        for e in by_zone[z]:
            w.writerow([e["time_start"], e["time_end"], z,
                        e["NOK_per_kWh"], e["EUR_per_kWh"], e["EXR"]])
    return buf.getvalue()


def read_day(root: Path, day: dt.date) -> list[dict] | None:
    f = day_path(root, day)
    return list(csv.DictReader(f.open())) if f.exists() else None


def newest_day(root: Path) -> dt.date | None:
    files = sorted((root / "day-ahead").glob("*/*.csv"))
    return dt.date.fromisoformat(files[-1].stem) if files else None


def ingest(root: Path, since: dt.date, until: dt.date) -> list[dt.date]:
    """Fetch and store every complete day in [since, until] not yet stored."""
    days = [since + dt.timedelta(n) for n in range((until - since).days + 1)]
    days = [d for d in days if not day_path(root, d).exists()]
    jobs = [(d, z) for d in days for z in ZONES]
    got: dict[dt.date, dict[str, list]] = {}
    with ThreadPoolExecutor(max_workers=5) as pool:
        for (d, z), raw in zip(jobs, pool.map(lambda j: fetch(*j), jobs)):
            if raw:
                got.setdefault(d, {})[z] = raw
    written = []
    for d in days:
        if len(got.get(d, {})) == len(ZONES):
            out = day_path(root, d)
            out.parent.mkdir(parents=True, exist_ok=True)
            out.write_text(day_csv(got[d]))
            written.append(d)
        else:
            print(f"  {d}: not published yet ({len(got.get(d, {}))}/5 zones)")
    return written


def update_plot(root: Path, name: str, today: dt.date) -> None:
    """NO1 hourly prices incl. VAT for yesterday/today/tomorrow, in the
    plot's `hour,<date>,<date>,<date>` shape, plus this script as its
    description."""
    cols: dict[str, dict[int, list[float]]] = {}
    for d in (today - dt.timedelta(1), today, today + dt.timedelta(1)):
        rows = read_day(root, d)
        if not rows:
            continue
        hours: dict[int, list[float]] = {}
        for r in rows:
            if r["zone"] == "NO1":
                hours.setdefault(int(r["time_start"][11:13]), []).append(float(r["nok_per_kwh"]))
        cols[d.isoformat()] = hours
    out = io.StringIO()
    w = csv.writer(out, lineterminator="\n")
    w.writerow(["hour", *cols])
    for h in range(24):
        w.writerow([h, *("" if h not in hs else f"{sum(hs[h]) / len(hs[h]) * (1 + MVA):.4f}"
                         for hs in cols.values())])

    from novem import Plot  # the SDK is only needed for the plot

    plot = Plot(name)
    plot.data = out.getvalue()
    plot.description = f"```python\n{inspect.getsource(sys.modules[__name__])}```\n"
    print(f"updated plot {name}: {', '.join(cols)}")


def main(argv: list[str]) -> None:
    p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    p.add_argument("--prices", type=Path, default=Path("/mnt/prices"),
                   help="dataset root (the mounted space); default /mnt/prices")
    p.add_argument("--since", type=dt.date.fromisoformat,
                   help="first day to fetch when the dataset is empty (default: 30 days ago)")
    p.add_argument("--plot", default="strompriser",
                   help="novem plot to refresh from the dataset ('' to skip)")
    args = p.parse_args(argv)

    today = dt.datetime.now(OSLO).date()
    root = args.prices
    if not (root / "README.md").exists():
        root.mkdir(parents=True, exist_ok=True)
        (root / "README.md").write_text(README)

    newest = newest_day(root)
    since = newest + dt.timedelta(1) if newest else (args.since or today - dt.timedelta(30))
    until = today + dt.timedelta(1)
    if since > until:
        print(f"dataset through {newest}; up to date")
    else:
        print(f"dataset through {newest or '(empty)'}; fetching {since} .. {until}")
        for d in ingest(root, since, until):
            print(f"  wrote {day_path(root, d).relative_to(root)}")

    if args.plot:
        update_plot(root, args.plot, today)


if __name__ == "__main__":
    main(sys.argv[1:])