From 1b600ad8d458eb6d1ab99d5d7c321b3a55e53bc5 Mon Sep 17 00:00:00 2001 From: Per Magnus Petersen Date: Sat, 14 Feb 2026 12:23:40 +0100 Subject: [PATCH] Fetch and display day-ahead electricity spot prices from Energi Data Service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a prices controller that fetches DK1/DK2 hourly spot prices via the Energi Data Service API, stores them in PostgreSQL, and displays today's and tomorrow's DK1 prices on the home page in øre/kWh. An HTMX "Refresh Prices" button triggers the fetch-and-display cycle. Co-Authored-By: Claude Opus 4.6 --- deps.edn | 3 +- ...214120000-create-day-ahead-prices.down.sql | 1 + ...60214120000-create-day-ahead-prices.up.sql | 8 ++ resources/queries.sql | 18 +++- resources/system.edn | 3 +- .../elprice/web/controllers/prices.clj | 91 +++++++++++++++++++ src/clj/pmagnus/elprice/web/routes/ui.clj | 83 ++++++++++++++--- 7 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 resources/migrations/20260214120000-create-day-ahead-prices.down.sql create mode 100644 resources/migrations/20260214120000-create-day-ahead-prices.up.sql create mode 100644 src/clj/pmagnus/elprice/web/controllers/prices.clj diff --git a/deps.edn b/deps.edn index 439858f..a6d2f39 100644 --- a/deps.edn +++ b/deps.edn @@ -25,7 +25,8 @@ io.github.kit-clj/kit-postgres {:mvn/version "1.0.7"} io.github.kit-clj/kit-sql-conman {:mvn/version "1.10.5"} io.github.kit-clj/kit-sql-migratus {:mvn/version "1.0.5"} - hiccup/hiccup {:mvn/version "2.0.0"}} + hiccup/hiccup {:mvn/version "2.0.0"} + org.clojure/data.json {:mvn/version "2.5.1"}} :aliases {:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.11"}} :ns-default build} diff --git a/resources/migrations/20260214120000-create-day-ahead-prices.down.sql b/resources/migrations/20260214120000-create-day-ahead-prices.down.sql new file mode 100644 index 0000000..98a16bf --- /dev/null +++ b/resources/migrations/20260214120000-create-day-ahead-prices.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS day_ahead_prices; diff --git a/resources/migrations/20260214120000-create-day-ahead-prices.up.sql b/resources/migrations/20260214120000-create-day-ahead-prices.up.sql new file mode 100644 index 0000000..bf05ae1 --- /dev/null +++ b/resources/migrations/20260214120000-create-day-ahead-prices.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE day_ahead_prices ( + time_utc TIMESTAMPTZ NOT NULL, + time_dk TIMESTAMP NOT NULL, + price_area VARCHAR(3) NOT NULL, + price_dkk NUMERIC(10,2), + price_eur NUMERIC(10,6), + PRIMARY KEY (time_utc, price_area) +); diff --git a/resources/queries.sql b/resources/queries.sql index 8f64c5f..3ee1a19 100644 --- a/resources/queries.sql +++ b/resources/queries.sql @@ -1,2 +1,16 @@ --- place your sql queries here --- see https://www.hugsql.org/ for documentation +-- :name upsert-price! :! :n +-- :doc Upsert a day-ahead price record +INSERT INTO day_ahead_prices (time_utc, time_dk, price_area, price_dkk, price_eur) +VALUES (:time-utc, :time-dk, :price-area, :price-dkk, :price-eur) +ON CONFLICT (time_utc, price_area) DO UPDATE +SET time_dk = EXCLUDED.time_dk, + price_dkk = EXCLUDED.price_dkk, + price_eur = EXCLUDED.price_eur; + +-- :name get-prices-for-date :? :* +-- :doc Get all prices for a given date (time_dk local date) and price area +SELECT time_utc, time_dk, price_area, price_dkk, price_eur +FROM day_ahead_prices +WHERE time_dk::date = :date + AND price_area = :price-area +ORDER BY time_dk; diff --git a/resources/system.edn b/resources/system.edn index 47b8ec2..9ba0563 100644 --- a/resources/system.edn +++ b/resources/system.edn @@ -66,4 +66,5 @@ :db {:datasource #ig/ref :db.sql/connection} :migrate-on-init? true} :reitit.routes/ui {:base-path "", - :env #ig/ref :system/env}} + :env #ig/ref :system/env + :query-fn #ig/ref :db.sql/query-fn}} diff --git a/src/clj/pmagnus/elprice/web/controllers/prices.clj b/src/clj/pmagnus/elprice/web/controllers/prices.clj new file mode 100644 index 0000000..008d089 --- /dev/null +++ b/src/clj/pmagnus/elprice/web/controllers/prices.clj @@ -0,0 +1,91 @@ +(ns pmagnus.elprice.web.controllers.prices + (:require + [clojure.data.json :as json] + [clojure.tools.logging :as log]) + (:import + [java.net URI] + [java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers] + [java.time LocalDate ZoneId] + [java.time.format DateTimeFormatter] + [java.sql Timestamp])) + +(def ^:private dk-zone (ZoneId/of "Europe/Copenhagen")) + +(def ^:private date-fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd")) + +(defn- build-url + "Build the Energi Data Service API URL for day-ahead prices." + [start-date end-date] + (str "https://api.energidataservice.dk/dataset/DayAheadPrices" + "?start=" start-date + "&end=" end-date + "&filter=" (json/write-str {"PriceArea" ["DK1" "DK2"]}) + "&sort=TimeDK%20asc" + "&limit=200")) + +(defn- fetch-json + "Fetch JSON from a URL using JDK HttpClient." + [url] + (let [client (HttpClient/newHttpClient) + request (-> (HttpRequest/newBuilder) + (.uri (URI/create url)) + (.header "Accept" "application/json") + (.GET) + (.build)) + response (.send client request (HttpResponse$BodyHandlers/ofString))] + (when (= 200 (.statusCode response)) + (json/read-str (.body response) :key-fn keyword)))) + +(defn fetch-prices-from-api + "Fetch today's and tomorrow's prices from Energi Data Service." + [] + (let [today (LocalDate/now dk-zone) + tomorrow (.plusDays today 1) + ;; Fetch from start of today to end of tomorrow + start (.format today date-fmt) + end (.format (.plusDays tomorrow 1) date-fmt) + url (build-url start end)] + (log/info "Fetching day-ahead prices from" url) + (when-let [data (fetch-json url)] + (:records data)))) + +(defn- parse-timestamp + "Parse an ISO timestamp string to java.sql.Timestamp." + [s] + (when s + (Timestamp/valueOf + (.replace ^String s "T" " ")))) + +(defn save-records! + "Upsert price records into the database." + [query-fn records] + (doseq [rec records] + (query-fn :upsert-price! + {:time-utc (parse-timestamp (:TimeUTC rec)) + :time-dk (parse-timestamp (:TimeDK rec)) + :price-area (:PriceArea rec) + :price-dkk (:DayAheadPriceDKK rec) + :price-eur (:DayAheadPriceEUR rec)}))) + +(defn fetch-and-save! + "Fetch prices from API and save to database." + [query-fn] + (when-let [records (fetch-prices-from-api)] + (log/info "Saving" (count records) "price records") + (save-records! query-fn records) + (count records))) + +(defn get-prices-for-date + "Get prices for a given LocalDate and price area from the database." + [query-fn date price-area] + (query-fn :get-prices-for-date + {:date (java.sql.Date/valueOf date) + :price-area price-area})) + +(defn get-today-prices + [query-fn] + (get-prices-for-date query-fn (LocalDate/now dk-zone) "DK1")) + +(defn get-tomorrow-prices + [query-fn] + (get-prices-for-date query-fn (.plusDays (LocalDate/now dk-zone) 1) "DK1")) diff --git a/src/clj/pmagnus/elprice/web/routes/ui.clj b/src/clj/pmagnus/elprice/web/routes/ui.clj index 7eaa3d1..a3dfedc 100644 --- a/src/clj/pmagnus/elprice/web/routes/ui.clj +++ b/src/clj/pmagnus/elprice/web/routes/ui.clj @@ -3,32 +3,85 @@ [pmagnus.elprice.web.middleware.exception :as exception] [pmagnus.elprice.web.middleware.formats :as formats] [pmagnus.elprice.web.routes.utils :as utils] - [pmagnus.elprice.web.htmx :refer [page fragment] :as htmx] + [pmagnus.elprice.web.htmx :refer [page fragment]] + [pmagnus.elprice.web.controllers.prices :as prices] [integrant.core :as ig] [reitit.ring.middleware.muuntaja :as muuntaja] - [reitit.ring.middleware.parameters :as parameters])) + [reitit.ring.middleware.parameters :as parameters]) + (:import + [java.time LocalDateTime] + [java.time.format DateTimeFormatter])) -(defn home [request] +(def ^:private hour-fmt (DateTimeFormatter/ofPattern "HH:00")) + +(defn- format-hour [time-dk] + (let [ldt (cond + (instance? LocalDateTime time-dk) time-dk + (instance? java.sql.Timestamp time-dk) + (.toLocalDateTime ^java.sql.Timestamp time-dk) + :else (LocalDateTime/parse (str time-dk)))] + (.format ldt hour-fmt))) + +(defn- dkk-mwh->ore-kwh + "Convert DKK/MWh to øre/kWh (divide by 10)." + [price-dkk] + (when price-dkk + (/ (double price-dkk) 10.0))) + +(defn- price-table [title prices] + [:div {:class "mt-6"} + [:h2 {:class "text-lg font-semibold text-gray-900"} title] + (if (seq prices) + [:table {:class "mt-2 w-full text-sm"} + [:thead + [:tr {:class "border-b border-gray-200 text-left text-gray-500"} + [:th {:class "py-2 pr-4"} "Time"] + [:th {:class "py-2 text-right"} "øre/kWh"]]] + [:tbody + (for [p prices] + [:tr {:class "border-b border-gray-100"} + [:td {:class "py-2 pr-4 text-gray-700"} + (format-hour (:time_dk p))] + [:td {:class "py-2 text-right font-mono text-gray-900"} + (if-let [ore (dkk-mwh->ore-kwh (:price_dkk p))] + (format "%.1f" ore) + "-")]])]] + [:p {:class "mt-2 text-sm text-gray-400"} "Not available yet"])]) + +(defn- price-tables [query-fn] + (let [today (prices/get-today-prices query-fn) + tomorrow (prices/get-tomorrow-prices query-fn)] + [:div {:id "price-tables"} + (price-table "Today — DK1" today) + (price-table "Tomorrow — DK1" tomorrow)])) + +(defn home [query-fn request] (page {:lang "en"} [:h1 {:class "text-2xl font-bold text-gray-900"} - "Welcome to elprice"] - [:p {:class "mt-2 text-gray-600"} - "Built with Kit, HTMX, Hiccup & Tailwind"] + "Electricity Prices"] + [:p {:class "mt-1 text-sm text-gray-500"} + "Day-ahead spot prices for DK1 in øre/kWh"] [:button {:class "mt-4 rounded-lg bg-blue-600 px-4 py-2 text-white font-medium active:bg-blue-700 transition-colors" - :hx-post "/clicked" - :hx-swap "outerHTML"} - "Click me!"])) + :hx-post "/prices/fetch" + :hx-target "#price-tables" + :hx-swap "outerHTML" + :hx-indicator "#fetch-spinner"} + "Refresh Prices"] + [:span {:id "fetch-spinner" + :class "ml-2 htmx-indicator text-sm text-gray-400"} + "Loading..."] + (price-tables query-fn))) -(defn clicked [request] +(defn fetch-prices [query-fn request] + (prices/fetch-and-save! query-fn) (fragment - [:div {:class "mt-4 rounded-lg bg-green-100 p-4 text-green-800"} - "Congratulations! You just clicked the button!"])) + (price-tables query-fn))) ;; Routes -(defn ui-routes [_opts] - [["/" {:get home}] - ["/clicked" {:post clicked}]]) +(defn ui-routes [{:keys [query-fn]}] + [["/" {:get (partial home query-fn)}] + ["/prices/fetch" {:post (partial fetch-prices query-fn)}]]) (def route-data {:muuntaja formats/instance