Fetch and display day-ahead electricity spot prices from Energi Data Service
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 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,8 @@
|
|||||||
io.github.kit-clj/kit-postgres {:mvn/version "1.0.7"}
|
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-conman {:mvn/version "1.10.5"}
|
||||||
io.github.kit-clj/kit-sql-migratus {:mvn/version "1.0.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"}}
|
:aliases {:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.11"}}
|
||||||
:ns-default build}
|
:ns-default build}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS day_ahead_prices;
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
+16
-2
@@ -1,2 +1,16 @@
|
|||||||
-- place your sql queries here
|
-- :name upsert-price! :! :n
|
||||||
-- see https://www.hugsql.org/ for documentation
|
-- :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;
|
||||||
|
|||||||
@@ -66,4 +66,5 @@
|
|||||||
:db {:datasource #ig/ref :db.sql/connection}
|
:db {:datasource #ig/ref :db.sql/connection}
|
||||||
:migrate-on-init? true}
|
:migrate-on-init? true}
|
||||||
:reitit.routes/ui {:base-path "",
|
:reitit.routes/ui {:base-path "",
|
||||||
:env #ig/ref :system/env}}
|
:env #ig/ref :system/env
|
||||||
|
:query-fn #ig/ref :db.sql/query-fn}}
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -3,32 +3,85 @@
|
|||||||
[pmagnus.elprice.web.middleware.exception :as exception]
|
[pmagnus.elprice.web.middleware.exception :as exception]
|
||||||
[pmagnus.elprice.web.middleware.formats :as formats]
|
[pmagnus.elprice.web.middleware.formats :as formats]
|
||||||
[pmagnus.elprice.web.routes.utils :as utils]
|
[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]
|
[integrant.core :as ig]
|
||||||
[reitit.ring.middleware.muuntaja :as muuntaja]
|
[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"}
|
(page {:lang "en"}
|
||||||
[:h1 {:class "text-2xl font-bold text-gray-900"}
|
[:h1 {:class "text-2xl font-bold text-gray-900"}
|
||||||
"Welcome to elprice"]
|
"Electricity Prices"]
|
||||||
[:p {:class "mt-2 text-gray-600"}
|
[:p {:class "mt-1 text-sm text-gray-500"}
|
||||||
"Built with Kit, HTMX, Hiccup & Tailwind"]
|
"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
|
[:button {:class "mt-4 rounded-lg bg-blue-600 px-4 py-2 text-white font-medium
|
||||||
active:bg-blue-700 transition-colors"
|
active:bg-blue-700 transition-colors"
|
||||||
:hx-post "/clicked"
|
:hx-post "/prices/fetch"
|
||||||
:hx-swap "outerHTML"}
|
:hx-target "#price-tables"
|
||||||
"Click me!"]))
|
: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
|
(fragment
|
||||||
[:div {:class "mt-4 rounded-lg bg-green-100 p-4 text-green-800"}
|
(price-tables query-fn)))
|
||||||
"Congratulations! You just clicked the button!"]))
|
|
||||||
|
|
||||||
;; Routes
|
;; Routes
|
||||||
(defn ui-routes [_opts]
|
(defn ui-routes [{:keys [query-fn]}]
|
||||||
[["/" {:get home}]
|
[["/" {:get (partial home query-fn)}]
|
||||||
["/clicked" {:post clicked}]])
|
["/prices/fetch" {:post (partial fetch-prices query-fn)}]])
|
||||||
|
|
||||||
(def route-data
|
(def route-data
|
||||||
{:muuntaja formats/instance
|
{:muuntaja formats/instance
|
||||||
|
|||||||
Reference in New Issue
Block a user