Liking cljdoc? Tell your friends :D

clj-midas

Clojars Project md-docs build-provenance

A Clojure client library for the California Energy Commission's MIDAS API, providing access to electricity rate data, GHG emissions signals, Flex Alerts, utility holidays, and reference lookup tables. Built on a non-official OpenAPI spec derived from the CEC's public documentation.

Features

  • Spec-driven HTTP client built on Martian with the bundled OpenAPI spec as the single source of truth
  • Two-layer data model: raw API responses (PascalCase, strings) and coerced Clojure entities (namespaced keywords, BigDecimals, java.time types)
  • Auto-refreshing authentication: 10-minute bearer tokens are transparently refreshed before expiry
  • ZonedDateTime end-to-end — every coerced timestamp is a ZonedDateTime in the client's configured zone (default America/Los_Angeles, MIDAS's native zone); DST-aware by construction
  • Metadata preservation: every coerced entity carries the original API data as :midas/raw metadata
  • Malli schemas for both raw and coerced data layers
  • RIN parsing: decompose a Rate Identification Number into its component fields, with optional annotation from lookup tables
  • Signal type helpers: flex-alert?, flex-alert-active?, ghg? for quick classification

Installation

Add to your deps.edn:

{:deps {energy.grid-coordination/clj-midas {:mvn/version "0.5.1"}}}

Quick Start

(require '[midas.client :as client]
         '[midas.entities :as entities])

;; Create an auto-refreshing client (token renews transparently)
(def c (client/create-auto-client "username" "password"))

;; Or manually: acquire token, then create client
(def token-info (client/get-token "username" "password"))
(def c (client/create-client token-info))

;; Fetch rate values for a RIN
(def resp (client/get-rate-values c "USCA-TSTS-TTOU-TEST" "alldata"))
(client/success? resp)
;=> true

;; Coerce to idiomatic Clojure entities
(def rate (entities/rate-info resp))

API Endpoints

The MIDAS API exposes five endpoints. clj-midas wraps all of them:

FunctionEndpointDescription
get-rin-listGET /ValueData?SignalType=List available RINs by signal type
get-rate-valuesGET /ValueData?ID=&QueryType=Fetch rate/price data for a RIN
get-lookup-tableGET /ValueData?LookupTable=Fetch reference data (codes & descriptions)
get-holidaysGET /HolidayFetch all utility holidays
get-historical-listGET /HistoricalListList RINs with archived data
get-historical-dataGET /HistoricalDataFetch archived rate data by date range
registerPOST /RegistrationRegister a new API account

Data Model

The library provides two views of the API data:

Raw Layer

Direct from the JSON — PascalCase keys, string values. Useful for debugging or when you need the exact API representation.

(:body resp)
;=> {:RateID "USCA-TSTS-TTOU-TEST"
;    :RateName "CEC TEST24HTOU"
;    :RateType "Time of use"
;    :ValueInformation [{:ValueName "winter off peak"
;                         :DateStart "2023-05-01"
;                         :TimeStart "07:00:00"
;                         :value 0.1006
;                         :Unit "$/kWh"} ...]}

Coerced Layer

Idiomatic Clojure — namespaced keywords, native types. Tick boundaries (:tick/beginning, :tick/end) and parsed datetimes (:midas.rate/system-time, :midas.rin/last-updated, …) are ZonedDateTime in the client's configured zone — see Time and timezones.

(entities/rate-info resp)
;=> #:midas.rate{:id "USCA-TSTS-TTOU-TEST"
;                :name "CEC TEST24HTOU"
;                :type :midas.rate-type/tou
;                :system-time #zoned-date-time "2026-03-19T03:03:46.379-07:00[America/Los_Angeles]"
;                :values [#:midas.value{:name "winter off peak"
;                                       :date-start #local-date "2023-05-01"
;                                       :time-start #local-time "07:00"
;                                       :price 0.1006M
;                                       :unit :midas.unit/dollar-per-kwh
;                                       :day-start :midas.day/monday
;                                       :tick/beginning #zoned-date-time "2023-05-01T07:00-07:00[America/Los_Angeles]"
;                                       :tick/end       #zoned-date-time "2023-05-01T07:59:59-07:00[America/Los_Angeles]"} ...]}

Entities

RateInfo

KeyTypeDescription
:midas.rate/idStringRate Identification Number (RIN)
:midas.rate/nameStringRate name
:midas.rate/typeKeyword or StringRate type (:midas.rate-type/tou, /cpp, /rtp, /ghg, /flex-alert, or passthrough string)
:midas.rate/system-timeZonedDateTimeServer timestamp (parsed from SystemTime_UTC, displayed in the client's configured zone)
:midas.rate/sectorString or nilCustomer sector
:midas.rate/end-useString or nilEnd use category
:midas.rate/api-urlString or nilAPI URL (literal "None" becomes nil)
:midas.rate/rate-plan-urlString or nilRate schedule URL
:midas.rate/signup-closeZonedDateTime or nilSignup deadline (parsed from a Z-suffixed wire field)
:midas.rate/valuesvectorVector of ValueData maps

ValueData

KeyTypeDescription
:midas.value/nameStringInterval description (e.g. "winter off peak")
:midas.value/date-startLocalDateInterval start date (zone-naive boundary descriptor)
:midas.value/date-endLocalDateInterval end date (zone-naive boundary descriptor)
:midas.value/day-startKeyword or nilDay type (:midas.day/monday ... :midas.day/holiday)
:midas.value/day-endKeyword or nilDay type
:midas.value/time-startLocalTimeInterval start time (zone-naive boundary descriptor)
:midas.value/time-endLocalTimeInterval end time (zone-naive boundary descriptor)
:midas.value/priceBigDecimalPrice or emissions value
:midas.value/unitKeyword or StringUnit (:midas.unit/dollar-per-kwh, /kg-co2-per-kwh, /event, etc.)
:tick/beginningZonedDateTimeInterval start as a moment-in-time (composed from date-start + time-start in the configured zone). Present when both date-start and time-start are non-nil.
:tick/endZonedDateTimeInterval end as a moment-in-time. Present when both date-end and time-end are non-nil.

RinListEntry

KeyTypeDescription
:midas.rin/idStringRate Identification Number
:midas.rin/signal-typeKeyword or nilSignal type (:midas.signal-type/rates, /ghg, /flex-alert)
:midas.rin/descriptionStringHuman-readable description
:midas.rin/last-updatedZonedDateTime or nilLast data update (bare wire field — interpreted as wall-clock in the configured zone)

Holiday

KeyTypeDescription
:midas.holiday/energy-codeStringTwo-character provider code
:midas.holiday/energy-nameStringProvider name
:midas.holiday/dateLocalDateHoliday date
:midas.holiday/descriptionStringHoliday name

LookupEntry

KeyTypeDescription
:midas.lookup/codeStringUpload code
:midas.lookup/descriptionStringHuman-readable description

ParsedRin

KeyTypeDescription
:midas.rin/countryStringCountry code (e.g. "US")
:midas.rin/stateStringState code (e.g. "CA")
:midas.rin/distributionStringDistribution utility code (e.g. "PG")
:midas.rin/energyStringEnergy provider code (e.g. "PG")
:midas.rin/rateStringRate schedule identifier (e.g. "TOU4")
:midas.rin/locationStringLocation identifier (e.g. "0000")
:midas.rin/distribution-nameString or absentHuman-readable distribution utility name (added by annotate-rin)
:midas.rin/energy-nameString or absentHuman-readable energy provider name (added by annotate-rin)

Type Coercion Summary

Raw (API)Coerced (Clojure)Example
ISO date stringjava.time.LocalDate"2023-05-01"#local-date "2023-05-01"
Time stringjava.time.LocalTime"07:00:00"#local-time "07:00"
ISO datetime with Zjava.time.ZonedDateTime (instant preserved, displayed in client's zone)"2026-03-19T10:03:46.379Z"#zoned-date-time "2026-03-19T03:03:46.379-07:00[America/Los_Angeles]"
Datetime without TZjava.time.ZonedDateTime (wall-clock attached to client's zone)"2023-06-07T15:57:48.023"#zoned-date-time "2023-06-07T15:57:48.023-07:00[America/Los_Angeles]"
date + time → tickjava.time.ZonedDateTime("2023-05-01", "07:00:00")#zoned-date-time "2023-05-01T07:00-07:00[America/Los_Angeles]"
NumberBigDecimal0.10060.1006M
Rate type stringNamespaced keyword"Time of use":midas.rate-type/tou
Unit stringNamespaced keyword"$/kWh":midas.unit/dollar-per-kwh
Day stringNamespaced keyword"Monday":midas.day/monday

Time and timezones

MIDAS is the California Energy Commission's API and uses America/Los_Angeles as its native zone. The wire format mixes UTC fields (those whose names end in _UTC or whose ISO 8601 strings carry a Z suffix) with bare wall-clock fields (everything else). The CEC does not document this; it has been verified empirically — see midas-api-specs/doc/datetime-and-timezone.md.

clj-midas normalises both styles to a single in-memory representation:

Every coerced datetime is a java.time.ZonedDateTime in the client's configured zone.

The default zone is America/Los_Angeles; override with :zone at client construction:

;; default — bare datetimes interpreted as PT, Z fields preserved instant-wise
(def c (client/create-auto-client "user" "pass"))

;; override — accepts a ZoneId or a zone-id string
(def c-utc (client/create-auto-client "user" "pass" {:zone "UTC"}))
(def c-pt  (client/create-auto-client "user" "pass" {:zone (java.time.ZoneId/of "America/Los_Angeles")}))

How parsing works

Wire fieldWire formCoerced as
SystemTime_UTC, SignupCloseDateISO 8601 with Z (UTC)Parsed as OffsetDateTime, re-expressed in the configured zone via .atZoneSameInstant (instant preserved).
LastUpdated, DateOfHoliday, DateStart+TimeStart, DateEnd+TimeEndBare ISO 8601 / time / date (no zone)Parsed as LocalDateTime/LocalTime/LocalDate and attached to the configured zone (treated as wall-clock in that zone).

Why ZonedDateTime (and not Instant or OffsetDateTime)

ZonedDateTime carries an IANA zone (America/Los_Angeles) that knows DST rules. .plusDays(1) on a PT timestamp lands on the next PT midnight regardless of spring-forward/fall-back. OffsetDateTime carries a fixed offset and gets DST math wrong; Instant discards wall-clock semantics entirely. For operating-day arithmetic — TOU period boundaries, billing days, "next 24 hours" — ZonedDateTime is the only correct type.

This library is part of an ecosystem-wide ZonedDateTime end-to-end discipline.

Boundary descriptors vs. moments

:midas.value/date-start, :date-end, :time-start, :time-end are deliberately kept as LocalDate / LocalTime — they describe boundary clock-times of a tariff schedule (e.g. "this rate is in effect from PT-clock-time 07:00 to 21:59:59"), not moments in time. The moment-in-time view is provided as :tick/beginning and :tick/end (ZonedDateTime), which compose date+time+zone.

:midas/zone on responses

Each client/get-* fn annotates its response with :midas/zone — the coercion helpers (entities/rate-info, entities/rin-list, entities/historical-list, entities/historical-data) read it during coercion. If you call those helpers on a synthetic response that lacks :midas/zone, they throw a clear error; for direct coercion you can call the lower-level (entities/->rate-info raw zone) etc. with an explicit ZoneId.

Authentication

MIDAS uses a two-step auth flow:

  1. HTTP Basic authGET /Token returns a bearer token (valid for 10 minutes)
  2. Bearer token → all other endpoints
;; Manual token management
(def token-info (client/get-token "user" "pass"))
;=> {:token "eyJ..." :acquired-at #inst "..." :expires-at #inst "..."}

(client/token-expired? token-info)
;=> false

;; Auto-refreshing client (recommended)
(def c (client/create-auto-client "user" "pass"))
;; Token refreshes transparently — no manual management needed

RIN Parsing

Parse a Rate Identification Number into its component fields:

(entities/parse-rin "USCA-PGPG-TOU4-0000")
;=> {:midas.rin/country "US"
;    :midas.rin/state "CA"
;    :midas.rin/distribution "PG"
;    :midas.rin/energy "PG"
;    :midas.rin/rate "TOU4"
;    :midas.rin/location "0000"}

;; Returns nil for invalid RINs
(entities/parse-rin "not-a-rin")
;=> nil

Add human-readable labels from MIDAS lookup tables:

;; Fetch lookup tables once
(def dist-table (entities/lookup-table (client/get-lookup-table c "Distribution")))
(def energy-table (entities/lookup-table (client/get-lookup-table c "Energy")))
(def lookups {"Distribution" dist-table, "Energy" energy-table})

(entities/annotate-rin (entities/parse-rin "USCA-SDEA-TTOU-0000") lookups)
;=> {:midas.rin/country "US"
;    :midas.rin/state "CA"
;    :midas.rin/distribution "SD"
;    :midas.rin/distribution-name "San Diego Gas and Electric"
;    :midas.rin/energy "EA"
;    :midas.rin/energy-name "Clean Energy Alliance"
;    :midas.rin/rate "TTOU"
;    :midas.rin/location "0000"}

Signal Type Helpers

;; Detect signal type from rate data
(entities/ghg? rate)           ;=> true/false (checks rate-type + unit)
(entities/flex-alert? rate)    ;=> true/false
(entities/flex-alert-active? rate) ;=> true if any value > 0

Metadata

Every coerced entity preserves the original API data as metadata:

(def rate (entities/rate-info resp))

;; Access the original API response
(-> rate meta :midas/raw)
;=> {:RateID "USCA-TSTS-TTOU-TEST" :RateName "CEC TEST24HTOU" ...}

;; Works at every level
(-> rate :midas.rate/values first meta :midas/raw)
;=> {:ValueName "winter off peak" :DateStart "2023-05-01" ...}

Schemas

Malli schemas are published in dedicated namespaces.

midas.entities.schema — Coerced entities (the public contract)

(require '[midas.entities.schema :as schema]
         '[malli.core :as m])

(m/validate schema/RateInfo rate)    ;=> true
(m/validate schema/ValueData value)  ;=> true

;; Available: RateInfo, ValueData, RinListEntry, ParsedRin, Holiday,
;;            LookupEntry, RateType, SignalType, DayType, UnitType

midas.entities.schema.raw — Raw API shapes

(require '[midas.entities.schema.raw :as raw])

(raw/validate-raw-rate-info (:body resp))    ;=> nil (valid)
(raw/validate-raw-value-data value)          ;=> nil or Malli explanation

;; Available: RateInfo, ValueData, RinListEntry, HolidayEntry, LookupEntry

API Reference

midas.client

FunctionDescription
get-tokenAuthenticate with HTTP Basic, returns token-info map
token-expired?Check if a token-info is expired (with 30s buffer)
create-clientCreate client with a token string or token-info map
create-auto-clientCreate client with auto-refreshing token
token-infoGet current token-info from a client
success?True if HTTP response is 2xx
bodyExtract parsed body from response
get-rin-listList RINs by signal type (0=All, 1=Rates, 2=GHG, 3=Flex Alert)
get-rate-valuesFetch rate data for a RIN ("alldata" or "realtime")
get-lookup-tableFetch a reference table (Distribution, Energy, Unit, etc.)
get-holidaysFetch all utility holidays
get-historical-listList RINs with archived data for a provider pair
get-historical-dataFetch archived rate data for a RIN and date range
registerRegister a new MIDAS account (auto base64 encodes fields)
routesList available Martian route names

midas.entities

FunctionDescription
->rate-infoCoerce a raw RateInfo map (takes raw, zone)
->value-dataCoerce a raw ValueData map (takes raw, zone)
->rin-list-entryCoerce a raw RIN list entry (takes raw, zone)
->holidayCoerce a raw HolidayEntry
->lookup-entryCoerce a raw LookupEntry
rate-infoExtract + coerce rate info from HTTP response
rin-listExtract + coerce RIN list from HTTP response
holidaysExtract + coerce holidays from HTTP response
historical-listExtract + coerce + deduplicate historical RIN list
historical-dataExtract + coerce historical rate data
lookup-tableExtract + coerce lookup table entries
ghg?True if rate-info is a GHG signal
parse-rinParse a RIN string into its component fields
annotate-rinAdd human-readable labels from lookup tables to a parsed RIN
flex-alert?True if rate-info is a Flex Alert
flex-alert-active?True if a Flex Alert is currently active

REPL Session Example

(require '[midas.client :as client]
         '[midas.entities :as entities]
         '[midas.entities.schema :as schema]
         '[malli.core :as m])

;; Create auto-refreshing client
(def c (client/create-auto-client
         (System/getenv "MIDAS_USERNAME")
         (System/getenv "MIDAS_PASSWORD")))

;; List all rate RINs
(def rin-resp (client/get-rin-list c 1))
(def rins (entities/rin-list rin-resp))
(count rins)
;=> 67266

;; Fetch the test TOU rate
(def rate-resp (client/get-rate-values c "USCA-TSTS-TTOU-TEST" "alldata"))
(def rate (entities/rate-info rate-resp))

(:midas.rate/type rate)
;=> :midas.rate-type/tou

(count (:midas.rate/values rate))
;=> 768

;; Inspect a value interval
(first (:midas.rate/values rate))
;=> #:midas.value{:name "winter off peak"
;                  :date-start #local-date "2023-05-01"
;                  :time-start #local-time "07:00"
;                  :time-end #local-time "07:59:59"
;                  :price 0.1006M
;                  :unit :midas.unit/dollar-per-kwh
;                  :day-start :midas.day/monday
;                  :day-end :midas.day/monday}

;; Validate against schema
(m/validate schema/RateInfo rate)
;=> true

;; Check Flex Alert status
(def flex-resp (client/get-rate-values c "USCA-FLEX-FXRT-0000" "realtime"))
(def flex (entities/rate-info flex-resp))
(entities/flex-alert? flex)
;=> true
(entities/flex-alert-active? flex)
;=> false  (no active alert)

;; Lookup tables
(def dists (entities/lookup-table (client/get-lookup-table c "Distribution")))
(first dists)
;=> #:midas.lookup{:code "BN" :description "Banning"}

;; Holidays
(first (entities/holidays (client/get-holidays c)))
;=> #:midas.holiday{:energy-code "SD"
;                    :energy-name "San Diego Gas and Electric"
;                    :date #local-date "2023-02-20"
;                    :description "President's Day"}

;; Access raw API data via metadata
(-> rate meta :midas/raw :RateType)
;=> "Time of use"

Development

Start nREPL

clojure -M:nrepl
# nREPL port written to .nrepl-port

Requires MIDAS_USERNAME and MIDAS_PASSWORD environment variables.

Dev helpers

The dev/user.clj namespace provides REPL convenience functions:

(start!)   ; create auto-refreshing client from env vars

Run tests

# Unit tests only (fast, no network)
clojure -M:test -m kaocha.runner --focus :unit

# Integration tests (hits live API, needs credentials)
clojure -M:test -m kaocha.runner --focus :integration

# All tests
clojure -M:test -m kaocha.runner

Contributing

Issues, Discussions, and pull requests are welcome — see CONTRIBUTING.md for the workflow (and the dev commands: tests, integration tests, lint, nREPL). In short:

  • Questions, API/design discussion, MIDAS behavior gapsDiscussions
  • Confirmed bugs, coercion/schema fixes, doc errorsIssues
  • Patches → pull requests; please open a Discussion or Issue first for non-trivial changes (new endpoints, new schema fields, new coercion behavior, time-handling changes)

License

MIT License -- Copyright (c) 2026 Clark Communications Corporation

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close