Add elprice table + JSON API (GET /api/elprice?date=...)

New `elprice` table with a simpler shape (time_dk, price_area,
price_dkk) backing a JSON endpoint that returns hourly DK1 + DK2
prices for any date. Cache-miss path fetches from the same
DayAheadPrices dataset as day_ahead_prices, upserts, and returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 12:36:01 +02:00
co-authored by Claude Opus 4.7
parent ce1c3822a2
commit 06b575d709
5 changed files with 142 additions and 3 deletions
@@ -0,0 +1 @@
DROP TABLE elprice;
@@ -0,0 +1,6 @@
CREATE TABLE elprice (
time_dk TIMESTAMP NOT NULL,
price_area VARCHAR(3) NOT NULL,
price_dkk NUMERIC(10,2) NOT NULL,
PRIMARY KEY (time_dk, price_area)
);
+13
View File
@@ -65,3 +65,16 @@ ON CONFLICT (time_start, hour) DO NOTHING;
INSERT INTO consumption (time_start, hour, kwh)
VALUES (:time-start, :hour, :kwh)
ON CONFLICT (time_start, hour) DO NOTHING;
-- :name insert-elprice! :! :n
-- :doc Insert an hourly el-price row; keep first value on conflict
INSERT INTO elprice (time_dk, price_area, price_dkk)
VALUES (:time-dk, :price-area, :price-dkk)
ON CONFLICT (time_dk, price_area) DO NOTHING;
-- :name get-elprice-for-date :? :*
-- :doc Hourly prices for a DK local date, both areas
SELECT time_dk, price_area, price_dkk
FROM elprice
WHERE time_dk::date = :date
ORDER BY time_dk, price_area;
@@ -0,0 +1,93 @@
(ns pmagnus.elprice.web.controllers.elprice
(:require
[clojure.data.json :as json]
[clojure.tools.logging :as log])
(:import
(java.net
URI
URLEncoder)
(java.net.http
HttpClient
HttpRequest
HttpResponse$BodyHandlers)
(java.sql
Timestamp)
(java.time
LocalDate
ZoneId)
(java.time.format
DateTimeFormatter)))
(def ^:private dk-zone (ZoneId/of "Europe/Copenhagen"))
(def ^:private date-fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd"))
(def ^:private eur-dkk-rate 7.46)
(defn- build-url
[^LocalDate date]
(let [start (.format date date-fmt)
end (.format (.plusDays date 1) date-fmt)]
(str "https://api.energidataservice.dk/dataset/DayAheadPrices"
"?start=" start
"&end=" end
"&filter=" (URLEncoder/encode (json/write-str {"PriceArea" ["DK1" "DK2"]}) "UTF-8")
"&sort=TimeDK%20asc"
"&limit=200")))
(defn- fetch-json
[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- parse-timestamp
[s]
(when s
(Timestamp/valueOf (.replace ^String s "T" " "))))
(defn- dkk-price
[price-dkk price-eur]
(or price-dkk
(when price-eur (* price-eur eur-dkk-rate))))
(defn- fetch-from-api
[^LocalDate date]
(let [url (build-url date)]
(log/info "Fetching el prices for" (.format date date-fmt) "from" url)
(:records (fetch-json url))))
(defn- save-records!
[query-fn records]
(doseq [rec records]
(when-let [dkk (dkk-price (:DayAheadPriceDKK rec) (:DayAheadPriceEUR rec))]
(query-fn :insert-elprice!
{:time-dk (parse-timestamp (:TimeDK rec))
:price-area (:PriceArea rec)
:price-dkk dkk}))))
(defn get-or-fetch!
"Return hourly el-price rows for the given LocalDate (DK local), fetching
from the upstream API and caching on a miss."
[query-fn ^LocalDate date]
(let [sql-date (java.sql.Date/valueOf date)
rows (query-fn :get-elprice-for-date {:date sql-date})]
(if (seq rows)
rows
(let [records (fetch-from-api date)]
(when (seq records)
(log/info "Saving" (count records) "el-price records")
(save-records! query-fn records))
(query-fn :get-elprice-for-date {:date sql-date})))))
+29 -3
View File
@@ -1,6 +1,7 @@
(ns pmagnus.elprice.web.routes.api
(:require
[integrant.core :as ig]
[pmagnus.elprice.web.controllers.elprice :as elprice]
[pmagnus.elprice.web.controllers.health :as health]
[pmagnus.elprice.web.middleware.exception :as exception]
[pmagnus.elprice.web.middleware.formats :as formats]
@@ -8,7 +9,13 @@
[reitit.ring.coercion :as coercion]
[reitit.ring.middleware.muuntaja :as muuntaja]
[reitit.ring.middleware.parameters :as parameters]
[reitit.swagger :as swagger]))
[reitit.swagger :as swagger]
[ring.util.http-response :as http-response])
(:import
(java.time
LocalDate)
(java.time.format
DateTimeParseException)))
(def route-data
@@ -33,9 +40,24 @@
exception/wrap-exception]})
(defn- elprice-handler
[query-fn {{{:keys [date]} :query} :parameters}]
(try
(let [d (LocalDate/parse date)
rows (elprice/get-or-fetch! query-fn d)]
(http-response/ok
{:date date
:prices (mapv (fn [r] {:time_dk (str (:time_dk r))
:price_area (:price_area r)
:price_dkk (:price_dkk r)})
rows)}))
(catch DateTimeParseException _
(http-response/bad-request {:error "date must be YYYY-MM-DD"}))))
;; Routes
(defn api-routes
[_opts]
[{:keys [query-fn]}]
[["/swagger.json"
{:get {:no-doc true
:swagger {:info {:title "pmagnus.elprice API"}}
@@ -44,7 +66,11 @@
;; note that use of the var is necessary
;; for reitit to reload routes without
;; restarting the system
{:get #'health/healthcheck!}]])
{:get #'health/healthcheck!}]
["/elprice"
{:get {:summary "Hourly DK1 + DK2 electricity prices for a date"
:parameters {:query [:map [:date :string]]}
:handler (partial elprice-handler query-fn)}}]])
(derive :reitit.routes/api :reitit/routes)