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:
@@ -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)
|
||||||
|
);
|
||||||
@@ -65,3 +65,16 @@ ON CONFLICT (time_start, hour) DO NOTHING;
|
|||||||
INSERT INTO consumption (time_start, hour, kwh)
|
INSERT INTO consumption (time_start, hour, kwh)
|
||||||
VALUES (:time-start, :hour, :kwh)
|
VALUES (:time-start, :hour, :kwh)
|
||||||
ON CONFLICT (time_start, hour) DO NOTHING;
|
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})))))
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
(ns pmagnus.elprice.web.routes.api
|
(ns pmagnus.elprice.web.routes.api
|
||||||
(:require
|
(:require
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
|
[pmagnus.elprice.web.controllers.elprice :as elprice]
|
||||||
[pmagnus.elprice.web.controllers.health :as health]
|
[pmagnus.elprice.web.controllers.health :as health]
|
||||||
[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]
|
||||||
@@ -8,7 +9,13 @@
|
|||||||
[reitit.ring.coercion :as coercion]
|
[reitit.ring.coercion :as coercion]
|
||||||
[reitit.ring.middleware.muuntaja :as muuntaja]
|
[reitit.ring.middleware.muuntaja :as muuntaja]
|
||||||
[reitit.ring.middleware.parameters :as parameters]
|
[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
|
(def route-data
|
||||||
@@ -33,9 +40,24 @@
|
|||||||
exception/wrap-exception]})
|
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
|
;; Routes
|
||||||
(defn api-routes
|
(defn api-routes
|
||||||
[_opts]
|
[{:keys [query-fn]}]
|
||||||
[["/swagger.json"
|
[["/swagger.json"
|
||||||
{:get {:no-doc true
|
{:get {:no-doc true
|
||||||
:swagger {:info {:title "pmagnus.elprice API"}}
|
:swagger {:info {:title "pmagnus.elprice API"}}
|
||||||
@@ -44,7 +66,11 @@
|
|||||||
;; note that use of the var is necessary
|
;; note that use of the var is necessary
|
||||||
;; for reitit to reload routes without
|
;; for reitit to reload routes without
|
||||||
;; restarting the system
|
;; 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)
|
(derive :reitit.routes/api :reitit/routes)
|
||||||
|
|||||||
Reference in New Issue
Block a user