A Clojure client library for the GridX Pricing API, providing access to marginal cost pricing data for California utilities (PG&E and SCE). Built on a non-official OpenAPI spec derived from GridX's public developer docs.
- Multi-utility support: PG&E and SCE via parallel
gridx.pge.clientandgridx.sce.clientnamespaces - Spec-driven HTTP client built on Martian with bundled OpenAPI specs as the single source of truth
- Two-layer data model: raw API responses (camelCase, strings) and coerced Clojure entities (namespaced keywords, BigDecimals, ZonedDateTimes)
- Per-instance
:zone— every coerced timestamp is aZonedDateTimein the configuredZoneId, with DST-aware behavior across spring-forward / fall-back days - tick intervals for time periods, enabling Allen's interval algebra out of the box
- Metadata preservation: every coerced entity carries the original API data as
:gridx/rawmetadata - Malli schemas for both raw and coerced data layers
Add to your deps.edn:
{:deps {energy.grid-coordination/clj-gridx {:mvn/version "0.4.1"}}}(require '[gridx.pge.client :as pge]
'[gridx.pricing :as pricing])
;; Create a PG&E client (defaults to stage API + America/Los_Angeles zone)
(def c (pge/create-client))
;; Or target production / override the zone
(def c (pge/create-client {:url pge/production-url
:zone "America/Los_Angeles"}))
;; Fetch pricing data — utility/market/program are filled in automatically
(def resp (pge/get-pricing c
{:startdate "20260308"
:enddate "20260308"
:ratename "EELEC"
:representativeCircuitId "013532223"}))
(pricing/success? resp) ;=> true
(pricing/curves resp) ;=> vector of coerced Curve maps (ZonedDateTimes in client zone)(require '[gridx.sce.client :as sce]
'[gridx.pricing :as pricing])
;; Create an SCE client (defaults to stage API + America/Los_Angeles zone)
(def c (sce/create-client))
;; Fetch pricing data
(def resp (sce/get-pricing c
{:startdate "20250701"
:enddate "20250701"
:ratename "TOU-EV-9S"
:representativeCircuitId "System"}))
(pricing/success? resp) ;=> true
(pricing/curves resp) ;=> vector of coerced Curve maps (ZonedDateTimes in client zone)The utility-specific namespaces wrap the shared gridx.client namespace, which can also be used directly:
(require '[gridx.client :as client])
(def c (client/create-client {:url "https://pge-pe-api.gridx.com/stage/v1"
:spec-path "gridx-pricing-spec/pge/openapi.yaml"
:zone "America/Los_Angeles"}))
(client/get-pricing c {:utility "PGE" :market "DAM" :program "CalFUSE" ...}):zone is required by the shared create-client (the per-utility wrappers default it to America/Los_Angeles for you). It accepts a java.time.ZoneId or a zone-id string, and it flows from the client onto each response (as :gridx/zone) where the coercion layer reads it.
The coercion layer (gridx.pricing) is shared — both utilities produce the same Clojure entity shape. The differences are in the API parameters and price component vocabulary:
| Aspect | PG&E | SCE |
|---|---|---|
| Client namespace | gridx.pge.client |
gridx.sce.client |
| Circuit parameter | :representativeCircuitId (9-digit feeder ID; see circuit lookup) |
:representativeCircuitId (substation name, e.g. "System") |
| Rate names | AG-A1, B6, EELEC, EV2AS, ... | TOU-GS-1, TOU-EV-9S, TOU-PRIME, ... |
| Components per interval | 3 (cld, mec, mgcc) | 8 (abank, bbank, circuitpricecurve, mec, nbc, ppf, ramp, transmissionpricecurve) |
| Price types | generation, distribution | generation, distribution, nonbypassablecharge, transmission |
| CCA support | Yes (optional :cca param) |
No |
| Data available from | 2024-06-01 | 2025-07-01 |
PG&E's representativeCircuitId is a 9-digit distribution feeder identifier. PG&E presents customers with a dropdown of these opaque numbers with no indication of what or where they are. The gridx.pge.circuits namespace maps all 98 known circuit IDs to their substation locations.
(require '[gridx.pge.circuits :as circuits])
;; Find circuit IDs by substation name (case-insensitive)
(circuits/find-circuits "mountain view")
;=> (["082031112" {:region "South Bay and Central Coast"
; :division "De Anza"
; :substation "MOUNTAIN VIEW"
; :feeder "1112"
; :in-gridx-enum? true}])
;; Look up a specific circuit
(circuits/circuit-location "013532223")
;=> {:region "Bay Area", :division "Diablo", :substation "LAKEWOOD", ...}
;; Browse by region
(keys (circuits/circuits-by-region))
;=> ("Bay Area" "Central Valley" "North Coast" ...)
;; Only circuits confirmed in the GridX API (59 of 98)
(count (circuits/gridx-circuits)) ;=> 59Data derived from the PG&E 2022 Grid Needs Assessment (CPUC filing 496629893, Appendix D) and the Priicer community cross-reference.
The library provides two views of the API data:
Direct from the JSON — camelCase keys, string values. Useful for debugging or when you need the exact API representation.
(pricing/raw-curves resp)
;=> [{:priceHeader {:priceCurveName "PGE-CalFUSE-EELEC-SECONDARY"
; :marketName "CAISO-DAM"
; :startTime "2026-03-08T00:00:00-0800"
; ...}
; :priceDetails [{:startIntervalTimeStamp "2026-03-08T00:00:00-0800"
; :intervalPrice "0.032176"
; :priceStatus "Final"
; :priceComponents [{:component "cld"
; :intervalPrice "0.000351"
; :priceType "distribution"} ...]}
; ...]}]Idiomatic Clojure — namespaced keywords, native types, tick intervals. The same shape for both PG&E and SCE. All timestamps are ZonedDateTime values in the client's configured :zone.
(first (pricing/curves resp))
;=> #:gridx.curve{:name "PGE-CalFUSE-EELEC-SECONDARY"
; :market :gridx.market/caiso-dam
; :interval-minutes 60
; :currency :USD
; :unit :kWh
; :start #time/zoned-date-time "2026-03-08T00:00-08:00[America/Los_Angeles]"
; :end #time/zoned-date-time "2026-03-08T23:59:59-07:00[America/Los_Angeles]"
; :period #:tick{:beginning #time/zoned-date-time "2026-03-08T00:00-08:00[America/Los_Angeles]"
; :end #time/zoned-date-time "2026-03-08T23:59:59-07:00[America/Los_Angeles]"}
; :record-count 23
; :intervals [...]}| Key | Type | Description |
|---|---|---|
:gridx.curve/name |
String |
Price curve name (e.g. "PGE-CalFUSE-EELEC-SECONDARY") |
:gridx.curve/market |
Keyword |
Market identifier (e.g. :gridx.market/caiso-dam) |
:gridx.curve/interval-minutes |
int |
Interval length: 15 or 60 |
:gridx.curve/currency |
Keyword |
Settlement currency (e.g. :USD) |
:gridx.curve/unit |
Keyword |
Settlement unit (e.g. :kWh) |
:gridx.curve/start |
ZonedDateTime |
Curve start in the configured :zone |
:gridx.curve/end |
ZonedDateTime |
Curve end in the configured :zone |
:tick/beginning |
ZonedDateTime |
Curve start (tick interval key, same value as :gridx.curve/start) |
:tick/end |
ZonedDateTime |
Curve end (tick interval key, same value as :gridx.curve/end) |
:gridx.curve/record-count |
int |
Number of intervals |
:gridx.curve/intervals |
vector |
Vector of Interval maps |
| Key | Type | Description |
|---|---|---|
:tick/beginning |
ZonedDateTime |
Interval start in the configured :zone |
:tick/end |
ZonedDateTime |
Interval end in the configured :zone (start + interval length) |
:gridx.interval/price |
BigDecimal |
Total interval price in currency/unit |
:gridx.interval/status |
Keyword |
:gridx.status/final or :gridx.status/preliminary |
:gridx.interval/components |
vector |
Vector of Component maps |
| Key | Type | Description |
|---|---|---|
:gridx.component/name |
Keyword |
e.g. :gridx.component/cld, :gridx.component/mec, :gridx.component/abank |
:gridx.component/price |
BigDecimal |
Component price |
:gridx.component/type |
Keyword |
e.g. :gridx.price-type/generation, :gridx.price-type/distribution, :gridx.price-type/transmission |
PG&E components: cld (distribution), mec (generation), mgcc (generation)
SCE components: abank (distribution), bbank (distribution), circuitpricecurve (distribution), mec (generation), nbc (nonbypassablecharge), ppf (generation), ramp (generation), transmissionpricecurve (transmission)
| Raw (API) | Coerced (Clojure) | Example (zone = America/Los_Angeles) |
|---|---|---|
| Timestamp string | java.time.ZonedDateTime in client :zone |
"2026-03-08T00:00:00-0800" → #time/zoned-date-time "2026-03-08T00:00-08:00[America/Los_Angeles]" |
| Decimal string | BigDecimal |
"0.032176" → 0.032176M |
| Enum string | Namespaced keyword | "Final" → :gridx.status/final |
Every coerced timestamp is a java.time.ZonedDateTime in the :zone configured on the client. The parser reads the API's offset (e.g. -0800), uses it to fix the underlying instant, and then .atZoneSameInstants into the configured zone — so the wall-clock time the API meant is preserved while the value gains the zone's DST rules for any subsequent arithmetic.
;; Default for the per-utility wrappers:
(pge/create-client) ;; :zone defaults to America/Los_Angeles
(sce/create-client {:zone "America/Los_Angeles"}) ;; explicit, same default
;; The shared client requires :zone:
(client/create-client {:url "..." :spec-path "..." :zone "UTC"})The zone flows from the client onto each response (as :gridx/zone) where pricing/curves reads it. You can also pass a zone explicitly to bypass that flow — useful for re-coercing a hand-built response or for tests:
(pricing/curves resp) ;; uses (:gridx/zone resp)
(pricing/curves resp "America/Los_Angeles") ;; explicit overrideBecause the coerced values are ZonedDateTime in a real zone, DST transitions are handled correctly:
- On spring-forward day, the wall clock skips from 02:00 PST to 03:00 PDT. An interval at 01:00 PST keeps the
-08:00offset; the next interval at 03:00 PDT comes back with-07:00. Adding a 1-hourDurationto the 01:00 PSTZonedDateTimeproduces a value that is the same instant as 03:00 PDT — i.e. it crosses the gap correctly. - On fall-back day, the wall clock repeats the 01:00 hour. The API serializes each repeated hour with its own offset (
-07:00for the first pass,-08:00for the second), and the parser preserves that distinction.
Verified against the live PG&E stage API for hourly intervals:
| Transition day | Direction | Hourly intervals returned | Offsets present |
|---|---|---|---|
| 2026-03-08 | Spring-forward (PST → PDT) | 23 | -08:00, -07:00 |
| 2025-11-02 | Fall-back (PDT → PST) | 25 | -07:00, -08:00 |
This library does not synthesize or drop intervals; it coerces what the API returns. (See pge-live-dst-spring-forward-test and pge-live-dst-fall-back-test in the integration test suite for the running assertions.)
If you create a client with a :zone whose offset doesn't match what the API serializes for a given timestamp, the parser throws ExceptionInfo early with the wire string, wire offset, configured zone, and the offset that zone would produce — so a misconfigured :zone (e.g. Asia/Tokyo configured against a Pacific-serving API) surfaces immediately rather than silently re-zoning the wall clock.
Both Curve and Interval entities carry :tick/beginning and :tick/end directly, making them tick intervals usable with Allen's interval algebra:
(require '[tick.core :as t])
(let [intervals (:gridx.curve/intervals (first curves))
i1 (nth intervals 0)
i2 (nth intervals 1)
i3 (nth intervals 2)]
(t/relation i1 i2) ;=> :meets
(t/relation i1 i3) ;=> :precedes
;; Access interval boundaries directly
(:tick/beginning i1) ;=> #time/zoned-date-time "2026-03-08T00:00-08:00[America/Los_Angeles]"
(:tick/end i1)) ;=> #time/zoned-date-time "2026-03-08T01:00-08:00[America/Los_Angeles]"Note on curve tick/end: The GridX API reports curve end time as
23:59:59(inclusive convention), while tick intervals are half-open[start, end). This means the curve's:tick/endis 1 second before the last interval's computed end time. The library preserves the API's value faithfully and does not adjust for this difference.
Every coerced entity preserves the original API data as metadata, accessible via :gridx/raw:
;; Get a coerced interval
(def interval (-> curves first :gridx.curve/intervals first))
;; Access the original API data
(-> interval meta :gridx/raw)
;=> {:startIntervalTimeStamp "2026-03-08T00:00:00-0800"
; :intervalPrice "0.032176"
; :priceStatus "Final"
; :priceComponents [{:component "cld"
; :intervalPrice "0.000351"
; :priceType "distribution"} ...]}This works at every level — curves, intervals, and components all carry their raw data.
Malli schemas are published in dedicated namespaces so consumers can use them for validation, generative testing, or documentation without pulling in coercion machinery.
(require '[gridx.pricing.schema :as schema]
'[malli.core :as m])
;; Validate a coerced curve
(m/validate schema/Curve (first curves))
;=> true
;; Available schemas: Component, Interval, CurveMost consumers won't need these. They mirror the JSON exactly and are primarily useful for boundary validation.
(require '[gridx.pricing.schema.raw :as schema.raw])
;; Validate a raw API response body
(pricing/validate-raw (:body resp))
;=> nil (success — returns nil on valid, Malli explanation map on failure)
;; Available schemas: PriceComponent, PriceDetail, PriceHeader,
;; PriceCurve, ResponseMeta, PricingResponse| Function | Description |
|---|---|
create-client |
Create a PG&E client. Options: :url (default: stage), :spec-path |
get-pricing |
Fetch PG&E pricing. Fills in utility/market/program. Params: :startdate, :enddate, :ratename, :representativeCircuitId, :cca (optional) |
stage-url |
PG&E stage API base URL |
production-url |
PG&E production API base URL |
| Function | Description |
|---|---|
create-client |
Create an SCE client. Options: :url (default: stage), :spec-path |
get-pricing |
Fetch SCE pricing. Fills in utility/market/program. Params: :startdate, :enddate, :ratename, :representativeCircuitId |
stage-url |
SCE stage API base URL |
production-url |
SCE production API base URL |
| Function | Description |
|---|---|
circuit-locations |
Map of all 98 circuit IDs to location info |
circuit-location |
Look up location for a circuit ID |
find-circuits |
Search by substation name (case-insensitive substring) |
circuits-by-region |
Group circuits by PG&E distribution planning region |
gridx-circuits |
Return only circuits confirmed in the GridX API |
| Function | Description |
|---|---|
create-client |
Create a client with explicit :url and :spec-path (both required) |
get-pricing |
Fetch pricing data with explicit params. Returns raw HTTP response |
routes |
List available API route names |
| Function | Description |
|---|---|
success? |
Check if an API response indicates success |
raw-curves |
Extract raw (uncoerced) curves from response |
curves |
Extract and coerce curves into Clojure entities |
validate-raw |
Validate response body against raw Malli schema |
->gridx-date |
Convert a date to GridX YYYYMMDD format |
->component |
Coerce a raw component map |
->interval |
Coerce a raw price detail map (requires duration) |
->curve |
Coerce a raw price curve map |
Malli schemas for the coerced Clojure entities — the public contract for consumers.
| Schema | Description |
|---|---|
Component |
Price component with BigDecimal price and type keyword |
Interval |
Price interval with tick period, price, status, and components |
Curve |
Complete price curve with header fields and vector of intervals |
Malli schemas mirroring the raw JSON API shape. Primarily for boundary validation.
| Schema | Description |
|---|---|
PriceComponent |
Raw component (component, intervalPrice, priceType) |
PriceDetail |
Raw interval detail with timestamp, price, status, components |
PriceHeader |
Raw curve metadata (name, market, times, record count) |
PriceCurve |
Raw curve (header + details vector) |
ResponseMeta |
HTTP response metadata (code, URLs, body) |
PricingResponse |
Top-level API response (meta + data vector) |
A complete REPL session demonstrating the full workflow:
;; Setup
(require '[gridx.pge.client :as pge]
'[gridx.sce.client :as sce]
'[gridx.pricing :as pricing]
'[gridx.pricing.schema :as schema]
'[tick.core :as t]
'[tick.alpha.interval :as t.i]
'[malli.core :as m])
;; -- PG&E --
(def pc (pge/create-client))
(def pge-resp (pge/get-pricing pc
{:startdate (pricing/->gridx-date (t/today))
:enddate (pricing/->gridx-date (t/today))
:ratename "EELEC"
:representativeCircuitId "013532223"}))
(pricing/success? pge-resp) ;=> true
(def pge-curves (pricing/curves pge-resp))
(m/validate schema/Curve (first pge-curves)) ;=> true
;; -- SCE --
(def sc (sce/create-client))
(def sce-resp (sce/get-pricing sc
{:startdate "20250701"
:enddate "20250701"
:ratename "TOU-EV-9S"
:representativeCircuitId "System"}))
(pricing/success? sce-resp) ;=> true
(def sce-curves (pricing/curves sce-resp))
;; SCE has 8 components per interval
(-> sce-curves first :gridx.curve/intervals first :gridx.interval/components count)
;=> 8
;; Find negative price hours (solar oversupply!)
(->> (:gridx.curve/intervals (first pge-curves))
(filter #(neg? (:gridx.interval/price %)))
(mapv (fn [i]
{:begin (:tick/beginning i)
:price (:gridx.interval/price i)})))
;; Interval algebra — entities are tick intervals directly
(let [intervals (:gridx.curve/intervals (first pge-curves))]
(t/relation (nth intervals 0) (nth intervals 1)))
;=> :meets
;; Access raw API data from any coerced entity
(-> (first pge-curves) meta :gridx/raw :priceHeader :priceCurveName)
;=> "PGE-CalFUSE-EELEC-SECONDARY"clojure -M:nrepl
# nREPL server started on port XXXXX on host localhost
# Port is written to .nrepl-port for automatic discoveryThe dev/user.clj namespace provides REPL convenience functions:
(start!) ; init both clients
(start-pge!) ; init PG&E client only
(start-sce!) ; init SCE client only
(fetch-pge-pricing "EELEC" "013532223" "20260308" "20260308") ; PG&E quick fetch
(fetch-sce-pricing "TOU-EV-9S" "System" "20250701" "20250701") ; SCE quick fetch# Unit tests (offline, uses bundled sample JSON)
clojure -M:test -m kaocha.runner
# Integration tests (requires network access to pe-api.gridx.com)
clojure -M:test-integrationUnit tests validate schema conformance and coercion logic against sample response files. Integration tests hit the live stage API and verify response structure, component names/counts, type coercion, and metadata preservation for both PG&E and SCE — without asserting specific price values.
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, GridX behavior gaps → Discussions
- Confirmed bugs, coercion/schema fixes, doc errors → Issues
- Patches → pull requests; please open a Discussion or Issue first for non-trivial changes (new utility support, new endpoints, new schema fields, new coercion behavior)
MIT License — Copyright (c) 2026 Clark Communications Corporation