Add Eloverblik data pipeline: fetch metering points, readings, and charges to PostgreSQL
- Add eloverblik controller with API client for metering points, time series, and charges - Add migrations for metering_points, meter_readings, and charges tables - Add HugSQL queries (upsert-metering-point!, insert-meter-reading!, insert-charge!) - Add POST /eloverblik/sync route to trigger sync from browser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS metering_points;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE metering_points (
|
||||||
|
metering_point_id VARCHAR(18) PRIMARY KEY,
|
||||||
|
type_of_mp VARCHAR(50),
|
||||||
|
balance_supplier VARCHAR(100),
|
||||||
|
street_name VARCHAR(200),
|
||||||
|
building_number VARCHAR(20),
|
||||||
|
postcode VARCHAR(10),
|
||||||
|
city_name VARCHAR(100),
|
||||||
|
has_relation BOOLEAN,
|
||||||
|
settlement_method VARCHAR(50),
|
||||||
|
reading_occurrence VARCHAR(50),
|
||||||
|
consumer_start_date DATE,
|
||||||
|
first_consumer_name VARCHAR(200),
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS meter_readings;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE meter_readings (
|
||||||
|
metering_point_id VARCHAR(18) NOT NULL REFERENCES metering_points(metering_point_id),
|
||||||
|
time_dk TIMESTAMP NOT NULL,
|
||||||
|
quantity_kwh NUMERIC(10,3),
|
||||||
|
quality VARCHAR(50),
|
||||||
|
PRIMARY KEY (metering_point_id, time_dk)
|
||||||
|
);
|
||||||
|
--;;
|
||||||
|
CREATE INDEX idx_meter_readings_time_dk ON meter_readings(time_dk);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS charges;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE charges (
|
||||||
|
id SERIAL,
|
||||||
|
metering_point_id VARCHAR(18) NOT NULL REFERENCES metering_points(metering_point_id),
|
||||||
|
charge_type VARCHAR(20) NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description VARCHAR(500),
|
||||||
|
owner VARCHAR(100) NOT NULL,
|
||||||
|
valid_from_date DATE NOT NULL,
|
||||||
|
valid_to_date DATE,
|
||||||
|
period_type VARCHAR(50),
|
||||||
|
price NUMERIC(12,6),
|
||||||
|
quantity INTEGER,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (metering_point_id, charge_type, name, owner, valid_from_date, position)
|
||||||
|
);
|
||||||
@@ -11,3 +11,39 @@ FROM day_ahead_prices
|
|||||||
WHERE time_dk::date = :date
|
WHERE time_dk::date = :date
|
||||||
AND price_area = :price-area
|
AND price_area = :price-area
|
||||||
ORDER BY time_dk;
|
ORDER BY time_dk;
|
||||||
|
|
||||||
|
-- :name upsert-metering-point! :! :n
|
||||||
|
-- :doc Upsert a metering point, refreshing data on re-sync
|
||||||
|
INSERT INTO metering_points (metering_point_id, type_of_mp, balance_supplier, street_name,
|
||||||
|
building_number, postcode, city_name, has_relation, settlement_method,
|
||||||
|
reading_occurrence, consumer_start_date, first_consumer_name)
|
||||||
|
VALUES (:metering-point-id, :type-of-mp, :balance-supplier, :street-name,
|
||||||
|
:building-number, :postcode, :city-name, :has-relation, :settlement-method,
|
||||||
|
:reading-occurrence, :consumer-start-date, :first-consumer-name)
|
||||||
|
ON CONFLICT (metering_point_id) DO UPDATE SET
|
||||||
|
type_of_mp = EXCLUDED.type_of_mp,
|
||||||
|
balance_supplier = EXCLUDED.balance_supplier,
|
||||||
|
street_name = EXCLUDED.street_name,
|
||||||
|
building_number = EXCLUDED.building_number,
|
||||||
|
postcode = EXCLUDED.postcode,
|
||||||
|
city_name = EXCLUDED.city_name,
|
||||||
|
has_relation = EXCLUDED.has_relation,
|
||||||
|
settlement_method = EXCLUDED.settlement_method,
|
||||||
|
reading_occurrence = EXCLUDED.reading_occurrence,
|
||||||
|
consumer_start_date = EXCLUDED.consumer_start_date,
|
||||||
|
first_consumer_name = EXCLUDED.first_consumer_name,
|
||||||
|
fetched_at = now();
|
||||||
|
|
||||||
|
-- :name insert-meter-reading! :! :n
|
||||||
|
-- :doc Insert a meter reading, skip if already exists
|
||||||
|
INSERT INTO meter_readings (metering_point_id, time_dk, quantity_kwh, quality)
|
||||||
|
VALUES (:metering-point-id, :time-dk, :quantity-kwh, :quality)
|
||||||
|
ON CONFLICT (metering_point_id, time_dk) DO NOTHING;
|
||||||
|
|
||||||
|
-- :name insert-charge! :! :n
|
||||||
|
-- :doc Insert a charge record, skip if already exists
|
||||||
|
INSERT INTO charges (metering_point_id, charge_type, name, description, owner,
|
||||||
|
valid_from_date, valid_to_date, period_type, price, quantity, position)
|
||||||
|
VALUES (:metering-point-id, :charge-type, :name, :description, :owner,
|
||||||
|
:valid-from-date, :valid-to-date, :period-type, :price, :quantity, :position)
|
||||||
|
ON CONFLICT (metering_point_id, charge_type, name, owner, valid_from_date, position) DO NOTHING;
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
(ns pmagnus.elprice.web.controllers.eloverblik
|
||||||
|
(:require
|
||||||
|
[clojure.data.json :as json]
|
||||||
|
[clojure.string :as str]
|
||||||
|
[clojure.tools.logging :as log])
|
||||||
|
(:import
|
||||||
|
[java.net URI]
|
||||||
|
[java.net.http HttpClient HttpRequest HttpRequest$BodyPublishers HttpResponse$BodyHandlers]
|
||||||
|
[java.time LocalDate LocalDateTime ZoneId]
|
||||||
|
[java.time.format DateTimeFormatter]
|
||||||
|
[java.sql Timestamp Date]))
|
||||||
|
|
||||||
|
(def ^:private base-url "https://api.eloverblik.dk/customerapi")
|
||||||
|
(def ^:private dk-zone (ZoneId/of "Europe/Copenhagen"))
|
||||||
|
(def ^:private date-fmt (DateTimeFormatter/ofPattern "yyyy-MM-dd"))
|
||||||
|
|
||||||
|
;; --- Auth ---
|
||||||
|
|
||||||
|
(defn- read-refresh-token []
|
||||||
|
(str/trim (slurp ".token")))
|
||||||
|
|
||||||
|
(defn- get-access-token
|
||||||
|
"Exchange refresh token for a short-lived access token."
|
||||||
|
[refresh-token]
|
||||||
|
(let [client (HttpClient/newHttpClient)
|
||||||
|
request (-> (HttpRequest/newBuilder)
|
||||||
|
(.uri (URI/create (str base-url "/api/token")))
|
||||||
|
(.header "Authorization" (str "Bearer " refresh-token))
|
||||||
|
(.header "Accept" "application/json")
|
||||||
|
(.GET)
|
||||||
|
(.build))
|
||||||
|
response (.send client request (HttpResponse$BodyHandlers/ofString))]
|
||||||
|
(when (= 200 (.statusCode response))
|
||||||
|
(let [body (json/read-str (.body response) :key-fn keyword)]
|
||||||
|
(:result body)))))
|
||||||
|
|
||||||
|
;; --- HTTP helpers ---
|
||||||
|
|
||||||
|
(defn- api-get
|
||||||
|
"GET with Bearer access token, returns parsed JSON."
|
||||||
|
[access-token path]
|
||||||
|
(let [client (HttpClient/newHttpClient)
|
||||||
|
request (-> (HttpRequest/newBuilder)
|
||||||
|
(.uri (URI/create (str base-url path)))
|
||||||
|
(.header "Authorization" (str "Bearer " access-token))
|
||||||
|
(.header "Accept" "application/json")
|
||||||
|
(.GET)
|
||||||
|
(.build))
|
||||||
|
response (.send client request (HttpResponse$BodyHandlers/ofString))]
|
||||||
|
(log/info "GET" path "->" (.statusCode response))
|
||||||
|
(when (= 200 (.statusCode response))
|
||||||
|
(json/read-str (.body response) :key-fn keyword))))
|
||||||
|
|
||||||
|
(defn- api-post
|
||||||
|
"POST with Bearer access token + JSON body, returns parsed JSON."
|
||||||
|
[access-token path body]
|
||||||
|
(let [client (HttpClient/newHttpClient)
|
||||||
|
json-str (json/write-str body)
|
||||||
|
request (-> (HttpRequest/newBuilder)
|
||||||
|
(.uri (URI/create (str base-url path)))
|
||||||
|
(.header "Authorization" (str "Bearer " access-token))
|
||||||
|
(.header "Content-Type" "application/json")
|
||||||
|
(.header "Accept" "application/json")
|
||||||
|
(.POST (HttpRequest$BodyPublishers/ofString json-str))
|
||||||
|
(.build))
|
||||||
|
response (.send client request (HttpResponse$BodyHandlers/ofString))]
|
||||||
|
(log/info "POST" path "->" (.statusCode response))
|
||||||
|
(when (= 200 (.statusCode response))
|
||||||
|
(json/read-str (.body response) :key-fn keyword))))
|
||||||
|
|
||||||
|
;; --- Metering Points ---
|
||||||
|
|
||||||
|
(defn- fetch-metering-points [access-token]
|
||||||
|
(let [data (api-get access-token "/api/meteringpoints/meteringpoints?includeAll=true")]
|
||||||
|
(:result data)))
|
||||||
|
|
||||||
|
(defn- parse-date [s]
|
||||||
|
(when (and s (not (str/blank? s)))
|
||||||
|
(Date/valueOf (LocalDate/parse (subs s 0 10)))))
|
||||||
|
|
||||||
|
(defn- upsert-mp! [query-fn mp]
|
||||||
|
(query-fn :upsert-metering-point!
|
||||||
|
{:metering-point-id (:meteringPointId mp)
|
||||||
|
:type-of-mp (:typeOfMP mp)
|
||||||
|
:balance-supplier (:balanceSupplierName mp)
|
||||||
|
:street-name (:streetName mp)
|
||||||
|
:building-number (:buildingNumber mp)
|
||||||
|
:postcode (:postcode mp)
|
||||||
|
:city-name (:cityName mp)
|
||||||
|
:has-relation (:hasRelation mp)
|
||||||
|
:settlement-method (:settlementMethod mp)
|
||||||
|
:reading-occurrence (:meterReadingOccurrence mp)
|
||||||
|
:consumer-start-date (parse-date (:consumerStartDate mp))
|
||||||
|
:first-consumer-name (:firstConsumerPartyName mp)}))
|
||||||
|
|
||||||
|
(defn- save-metering-points! [query-fn metering-points]
|
||||||
|
(let [all-mps (mapcat (fn [mp]
|
||||||
|
(cons mp (:childMeteringPoints mp)))
|
||||||
|
metering-points)]
|
||||||
|
(doseq [mp all-mps]
|
||||||
|
(upsert-mp! query-fn mp))
|
||||||
|
(log/info "Saved" (count all-mps) "metering points")))
|
||||||
|
|
||||||
|
;; --- Time Series ---
|
||||||
|
|
||||||
|
(defn- fetch-time-series [access-token metering-point-ids from to]
|
||||||
|
(api-post access-token
|
||||||
|
(str "/api/meterdata/gettimeseries/"
|
||||||
|
(.format from date-fmt) "/"
|
||||||
|
(.format to date-fmt) "/Hour")
|
||||||
|
{:meteringPoints {:meteringPoint metering-point-ids}}))
|
||||||
|
|
||||||
|
(defn- parse-time-series
|
||||||
|
"Parse the nested time series response into flat reading maps."
|
||||||
|
[response metering-point-ids]
|
||||||
|
(let [results (:result response)]
|
||||||
|
(mapcat
|
||||||
|
(fn [result mp-id]
|
||||||
|
(let [doc (:MyEnergyData_MarketDocument result)
|
||||||
|
series (or (:TimeSeries doc) [])]
|
||||||
|
(mapcat
|
||||||
|
(fn [ts]
|
||||||
|
(mapcat
|
||||||
|
(fn [period]
|
||||||
|
(let [start-str (get-in period [:timeInterval :start])
|
||||||
|
start (LocalDateTime/parse
|
||||||
|
start-str
|
||||||
|
(DateTimeFormatter/ofPattern "yyyy-MM-dd'T'HH:mm:ss'Z'"))
|
||||||
|
start-dk (-> start
|
||||||
|
(.atZone (ZoneId/of "UTC"))
|
||||||
|
(.withZoneSameInstant dk-zone)
|
||||||
|
(.toLocalDateTime))]
|
||||||
|
(map (fn [point]
|
||||||
|
(let [pos (dec (Long/parseLong (str (:position point))))
|
||||||
|
time-dk (.plusHours start-dk pos)
|
||||||
|
raw-qty (get point (keyword "out_Quantity.quantity"))
|
||||||
|
quantity (when raw-qty (Double/parseDouble (str raw-qty)))]
|
||||||
|
{:metering-point-id mp-id
|
||||||
|
:time-dk (Timestamp/valueOf time-dk)
|
||||||
|
:quantity-kwh quantity
|
||||||
|
:quality (get point (keyword "out_Quantity.quality"))}))
|
||||||
|
(:Point period))))
|
||||||
|
(:Period ts)))
|
||||||
|
series)))
|
||||||
|
results
|
||||||
|
metering-point-ids)))
|
||||||
|
|
||||||
|
(defn- save-time-series! [query-fn readings]
|
||||||
|
(doseq [r readings]
|
||||||
|
(query-fn :insert-meter-reading! r))
|
||||||
|
(log/info "Saved" (count readings) "meter readings"))
|
||||||
|
|
||||||
|
;; --- Charges ---
|
||||||
|
|
||||||
|
(defn- fetch-charges [access-token metering-point-ids]
|
||||||
|
(api-post access-token
|
||||||
|
"/api/meteringpoints/meteringpoint/getcharges"
|
||||||
|
{:meteringPoints {:meteringPoint metering-point-ids}}))
|
||||||
|
|
||||||
|
(defn- save-charges! [query-fn charges-response]
|
||||||
|
(let [results (:result charges-response)]
|
||||||
|
(doseq [result results]
|
||||||
|
(let [mp-id (:id result)
|
||||||
|
inner (:result result)]
|
||||||
|
;; Subscriptions
|
||||||
|
(doseq [sub (:subscriptions inner)]
|
||||||
|
(query-fn :insert-charge!
|
||||||
|
{:metering-point-id mp-id
|
||||||
|
:charge-type "subscription"
|
||||||
|
:name (:name sub)
|
||||||
|
:description (:description sub)
|
||||||
|
:owner (:owner sub)
|
||||||
|
:valid-from-date (parse-date (:validFromDate sub))
|
||||||
|
:valid-to-date (parse-date (:validToDate sub))
|
||||||
|
:period-type (:periodType sub)
|
||||||
|
:price (:price sub)
|
||||||
|
:quantity (:quantity sub)
|
||||||
|
:position 0}))
|
||||||
|
;; Tariffs (one row per position/price)
|
||||||
|
(doseq [tar (:tariffs inner)]
|
||||||
|
(let [prices (or (:prices tar) [])]
|
||||||
|
(if (seq prices)
|
||||||
|
(doseq [p prices]
|
||||||
|
(query-fn :insert-charge!
|
||||||
|
{:metering-point-id mp-id
|
||||||
|
:charge-type "tariff"
|
||||||
|
:name (:name tar)
|
||||||
|
:description (:description tar)
|
||||||
|
:owner (:owner tar)
|
||||||
|
:valid-from-date (parse-date (:validFromDate tar))
|
||||||
|
:valid-to-date (parse-date (:validToDate tar))
|
||||||
|
:period-type (:periodType tar)
|
||||||
|
:price (:price p)
|
||||||
|
:quantity (:quantity p)
|
||||||
|
:position (some-> (:position p) str Long/parseLong)}))
|
||||||
|
(query-fn :insert-charge!
|
||||||
|
{:metering-point-id mp-id
|
||||||
|
:charge-type "tariff"
|
||||||
|
:name (:name tar)
|
||||||
|
:description (:description tar)
|
||||||
|
:owner (:owner tar)
|
||||||
|
:valid-from-date (parse-date (:validFromDate tar))
|
||||||
|
:valid-to-date (parse-date (:validToDate tar))
|
||||||
|
:period-type (:periodType tar)
|
||||||
|
:price nil
|
||||||
|
:quantity nil
|
||||||
|
:position 0}))))
|
||||||
|
;; Fees
|
||||||
|
(doseq [fee (:fees inner)]
|
||||||
|
(query-fn :insert-charge!
|
||||||
|
{:metering-point-id mp-id
|
||||||
|
:charge-type "fee"
|
||||||
|
:name (:name fee)
|
||||||
|
:description (:description fee)
|
||||||
|
:owner (:owner fee)
|
||||||
|
:valid-from-date (parse-date (:validFromDate fee))
|
||||||
|
:valid-to-date (parse-date (:validToDate fee))
|
||||||
|
:period-type (:periodType fee)
|
||||||
|
:price (:price fee)
|
||||||
|
:quantity (:quantity fee)
|
||||||
|
:position 0}))))))
|
||||||
|
|
||||||
|
;; --- Orchestrator ---
|
||||||
|
|
||||||
|
(defn fetch-and-save-all!
|
||||||
|
"Fetch all Eloverblik data and save to database."
|
||||||
|
[query-fn]
|
||||||
|
(log/info "Starting Eloverblik fetch")
|
||||||
|
(let [refresh-token (read-refresh-token)
|
||||||
|
access-token (get-access-token refresh-token)]
|
||||||
|
(if-not access-token
|
||||||
|
(do (log/error "Failed to get Eloverblik access token")
|
||||||
|
{:error "Failed to get access token"})
|
||||||
|
(let [mps (fetch-metering-points access-token)
|
||||||
|
all-ids (->> mps
|
||||||
|
(mapcat (fn [mp]
|
||||||
|
(cons (:meteringPointId mp)
|
||||||
|
(map :meteringPointId
|
||||||
|
(:childMeteringPoints mp)))))
|
||||||
|
(distinct)
|
||||||
|
(vec))
|
||||||
|
from (LocalDate/of 2026 2 1)
|
||||||
|
to (LocalDate/of 2026 2 2)]
|
||||||
|
(log/info "Found" (count all-ids) "metering points:" all-ids)
|
||||||
|
(save-metering-points! query-fn mps)
|
||||||
|
;; Time series
|
||||||
|
(let [ts-resp (fetch-time-series access-token all-ids from to)
|
||||||
|
readings (parse-time-series ts-resp all-ids)]
|
||||||
|
(save-time-series! query-fn readings))
|
||||||
|
;; Charges
|
||||||
|
(let [ch-resp (fetch-charges access-token all-ids)]
|
||||||
|
(save-charges! query-fn ch-resp))
|
||||||
|
{:metering-points (count all-ids)
|
||||||
|
:from (.format from date-fmt)
|
||||||
|
:to (.format to date-fmt)}))))
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
[pmagnus.elprice.web.htmx :refer [page fragment]]
|
[pmagnus.elprice.web.htmx :refer [page fragment]]
|
||||||
[pmagnus.elprice.web.controllers.prices :as prices]
|
[pmagnus.elprice.web.controllers.prices :as prices]
|
||||||
[pmagnus.elprice.web.controllers.tariffs :as tariffs]
|
[pmagnus.elprice.web.controllers.tariffs :as tariffs]
|
||||||
|
[pmagnus.elprice.web.controllers.eloverblik :as eloverblik]
|
||||||
[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])
|
||||||
@@ -174,12 +175,22 @@
|
|||||||
(fragment
|
(fragment
|
||||||
(price-content :today (prices/get-today-prices query-fn) (has-tomorrow? query-fn))))
|
(price-content :today (prices/get-today-prices query-fn) (has-tomorrow? query-fn))))
|
||||||
|
|
||||||
|
(defn sync-eloverblik [query-fn request]
|
||||||
|
(let [result (eloverblik/fetch-and-save-all! query-fn)]
|
||||||
|
(fragment
|
||||||
|
(if (:error result)
|
||||||
|
[:p {:class "text-red-600 font-medium"} (:error result)]
|
||||||
|
[:p {:class "text-green-700 font-medium"}
|
||||||
|
(str "Synced " (:metering-points result)
|
||||||
|
" metering points (" (:from result) " to " (:to result) ")")]))))
|
||||||
|
|
||||||
;; Routes
|
;; Routes
|
||||||
(defn ui-routes [{:keys [query-fn]}]
|
(defn ui-routes [{:keys [query-fn]}]
|
||||||
[["/" {:get (partial home query-fn)}]
|
[["/" {:get (partial home query-fn)}]
|
||||||
["/prices/today" {:get (partial prices-today query-fn)}]
|
["/prices/today" {:get (partial prices-today query-fn)}]
|
||||||
["/prices/tomorrow" {:get (partial prices-tomorrow query-fn)}]
|
["/prices/tomorrow" {:get (partial prices-tomorrow query-fn)}]
|
||||||
["/prices/fetch" {:post (partial fetch-prices query-fn)}]])
|
["/prices/fetch" {:post (partial fetch-prices query-fn)}]
|
||||||
|
["/eloverblik/sync" {:post (partial sync-eloverblik query-fn)}]])
|
||||||
|
|
||||||
(def route-data
|
(def route-data
|
||||||
{:muuntaja formats/instance
|
{:muuntaja formats/instance
|
||||||
|
|||||||
Reference in New Issue
Block a user