diff --git a/resources/migrations/20260419010000-create-elprice.down.sql b/resources/migrations/20260419010000-create-elprice.down.sql new file mode 100644 index 0000000..fd48709 --- /dev/null +++ b/resources/migrations/20260419010000-create-elprice.down.sql @@ -0,0 +1 @@ +DROP TABLE elprice; diff --git a/resources/migrations/20260419010000-create-elprice.up.sql b/resources/migrations/20260419010000-create-elprice.up.sql new file mode 100644 index 0000000..aa4ebce --- /dev/null +++ b/resources/migrations/20260419010000-create-elprice.up.sql @@ -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) +); diff --git a/resources/queries.sql b/resources/queries.sql index 9ee6371..2637335 100644 --- a/resources/queries.sql +++ b/resources/queries.sql @@ -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; diff --git a/src/clj/pmagnus/elprice/web/controllers/elprice.clj b/src/clj/pmagnus/elprice/web/controllers/elprice.clj new file mode 100644 index 0000000..28d99ba --- /dev/null +++ b/src/clj/pmagnus/elprice/web/controllers/elprice.clj @@ -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}))))) diff --git a/src/clj/pmagnus/elprice/web/routes/api.clj b/src/clj/pmagnus/elprice/web/routes/api.clj index d49b548..8d0e308 100644 --- a/src/clj/pmagnus/elprice/web/routes/api.clj +++ b/src/clj/pmagnus/elprice/web/routes/api.clj @@ -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)