# Palmetto EI API — `/api/v0/bem/calculate` > Building energy model (BEM) endpoint that estimates energy consumption, costs, and emissions for residential buildings at hourly, daily, monthly, or yearly granularity. Covers electricity and fossil fuels (natural gas, propane, fuel oil). > > Given a location (necessary and sufficient), and optionally any combination of: > 1. Known building characteristics (floor area, vintage, HVAC type, etc.) > 2. Consumption or cost actuals (kWh readings or dollar bills for arbitrary periods, including monthly or annual totals) > 3. A PV system definition (panel arrays with capacity/tilt/azimuth, inverter specs, and/or loss assumptions) > 4. PV production actuals (measured kWh) > 5. Battery storage specification (capacity, power, dispatch strategy) > 6. Electricity and/or fossil fuel cost and emission rate information > > ...it simulates an ensemble of plausible buildings and returns a calibrated energy profile with end-use and DER disaggregation, costs, and CO2 emissions. > > The API supports both **baseline** and **hypothetical** scenarios. For hypothetical analysis, you describe the existing building in `baseline` attributes and the upgraded/what-if conditions in `hypothetical` attributes (e.g., baseline has cooling enabled, hypothetical disables it). This separation allows actuals to calibrate against the baseline (the building as it exists today), producing a tighter estimate of the hypothetical/upgraded scenario. In an API response, these inputs are included along with any other localized assumptions the model has made, automatically filtering out hypothetical attributes that match the baseline. > > Weather is **TMY (Typical Meteorological Year)** by default. Actual weather — historical records and forecasts (up to 14 days out) — can be opted into separately for calibration and for response intervals. This allows, for example, calibrating against real bills using the real weather that produced them while still projecting savings against a typical year, or producing near-term energy estimates based on actual weather forecasts. Using actual weather incurs additional per-request charges and the total request timespan cannot exceed 8,760 hours. > > Electricity costs default to the EIA utility-average residential rate for the location's serving utility (e.g., ~$0.166/kWh for Duke Energy in Florida). Callers can override with a custom `$/kWh` flat rate, or supply a full inline tariff structure supporting tiered pricing, time-of-use periods, seasonal rates, demand charges, fixed charges, surcharges, taxes, net metering policies, and export credits. - [OpenAPI spec](https://ei.palmetto.com/openapi.json) ## Get Started ### Authentication All requests require an `X-API-Key` header: ``` POST https://ei.palmetto.com/api/v0/bem/calculate X-API-Key: Content-Type: application/json ``` ### Minimal request The only required fields are `location` and `parameters`: ```json { "location": { "latitude": 28.819114, "longitude": -81.529367 }, "parameters": { "from_datetime": "2026-01-01", "to_datetime": "2027-01-01", "group_by": "month", "interval_format": "wide", "variables": ["consumption.electricity", "costs.electricity"] } } ``` - **`location`**: Either `{ "address": "..." }` or `{ "latitude": ..., "longitude": ... }`. Prefer lat/lon when available — it avoids a geocoding dependency, reducing latency and removing a potential point of failure. Lat/lon is ultimately required when internally leveraging our geospatial intelligence layer, which uses the coordinates for data lookups and to statistically estimate remaining unknown building characteristics. - **`parameters.from_datetime` / `to_datetime`**: ISO 8601 date strings defining the period. No timezone — assumes local standard time. - **`parameters.group_by`**: `"hour"` | `"day"` | `"month"` | `"year"` (default `"year"`). - **`parameters.interval_format`** (default `"long"`): `"long"` returns one row per variable per period. `"wide"` returns one row per period with all variables as columns. Prefer `"wide"` for tabular analysis. - **`parameters.variables`**: List of variables to return. Common choices: `"consumption.electricity"`, `"costs.electricity"`, `"costs"`, `"emissions"`, `"production.electricity"`, `"weather.air_temperature"`. Use `"all_non_zero"` to get everything that has a value, but be aware this returns many columns — prefer listing only what you need. ### Response structure ```jsonc { "version": "...", "detail": "success", "meta": { "parameters": { /* echo of your request parameters */ }, "units": { "consumption.electricity": "kWh", "costs.electricity": "$", ... }, "warnings": [], "sources": { /* data provenance: building_type_source, floor_area_source, etc. */ } }, "data": { "location": { "latitude": 28.8, "longitude": -81.5, "state": "FL", ... }, "consumption": { "baseline": { "attributes": [ /* baseline building characteristics */ ] }, "hypothetical": { "attributes": [ /* hypothetical building characteristics, if included in the request */ ] } }, "production": { "baseline": { "attributes": [ /* baseline PV system configuration */ ] }, "hypothetical": { "attributes": [ /* hypothetical PV system configuration, if included in the request */ ] } }, "storage": { "baseline": { "attributes": [ /* baseline battery configuration */ ] }, "hypothetical": { "attributes": [ /* hypothetical battery configuration, if included in the request */ ] } }, "costs": { /* rate details applied */ }, "intervals": [ { "from_datetime": "2026-01-01", "to_datetime": "2026-02-01", "consumption.electricity": 1850.3, "costs.electricity": 307.15 }, ... ] } } ``` **`interval_format: "wide"` does NOT return variables as top-level arrays.** The dot-notation variable names (e.g., `"consumption.electricity"`) are column keys within each interval object, not nested paths in the response. `data.intervals` is the only place results live. ```python # ✅ correct kwh = [row["consumption.electricity"] for row in data["data"]["intervals"]] # ❌ wrong — no such key exists at the top level kwh = data["data"]["consumption.electricity"] ``` ```js // ✅ correct const kwh = data.data.intervals.map(row => row["consumption.electricity"]); // ❌ wrong — no such key exists at the top level const kwh = data.data["consumption.electricity"]; ``` Key things to check in the response: - `data.location` — the geocoded lat/lon actually used. - `data.consumption.baseline.attributes` — the building characteristics the model received and/or selected (floor area, vintage, HVAC type, etc.). Useful for sanity-checking. When using `"average"` aggregation, only attributes equal across all buildings in the ensemble will be returned. - `meta.warnings` — the API will warn you about data quality issues. ### Calibration parameters These go under `consumption.calibration` and control how the building ensemble is constructed and resolved: ```json { "consumption": { "calibration": { "sample_size": 35, "ensemble_aggregation_method": "nearest", "sample_seed": null } } } ``` - **`sample_size`** (default 35, range 1–100): Number of buildings simulated in the ensemble. The sweet spot is **25–50** — below 25 you lose diversity and add variability, above 50 latency increases (~0.5s at 10, ~1.2s at 50, ~2s at 100) with negligible accuracy gains. - **`ensemble_aggregation_method`** (default `"nearest"`): - `"nearest"` — Elects a single building from the ensemble. Without actuals, selects the building closest to the median total consumption. With actuals, selects the building with the lowest calibration error (MSE). **Caveat**: changing any baseline input (rate source, actuals, attributes, etc.) can cause a *different* building to be elected, producing significant swings in the consumption profile unrelated to the input change. Hypothetical attributes don't affect baseline sampling or building election. Since a single building is elected, the full array of building attributes is returned, including those not provided in the request. - `"average"` — Returns a weighted average across all buildings in the ensemble (weights derived from calibration error when actuals are provided; uniform weights otherwise). This is **more stable** — input changes produce smooth, proportional output changes. One tradeoff is that hourly load shapes are less realistic (they're a blend of multiple buildings), though monthly totals are well-behaved. Another is that building attributes are probabilistic rather than deterministic — those that vary across the ensemble are not currently returned, but distributional information may be returned in the future. - **`sample_seed`**: By default, the seed is derived deterministically from the lat/lon. This means the same location always produces the same ensemble, while small changes in coordinates will perturb the seed and yield different but statistically equivalent results. You can override this with an explicit integer to get a different but reproducible ensemble. **Recommendation**: For the use cases detailed in this doc, mostly at monthly granularity, use `"average"` aggregation — it isolates the effect of your input changes from building-selection noise and provides more stable, defensible results. ## Typical Use Case #1: Estimating Monthly Consumption Given what you know about a building, estimate its monthly electricity consumption and costs over a 12-month period. ### Inputs You calibrate the model through `consumption.actuals` and/or `consumption.attributes.baseline`, both optional. Actuals are intervals with a `from_datetime`, `to_datetime`, `value`, and `variable` — either `"consumption.electricity"` (kWh) or `"costs.electricity"` (dollars). They can cover any period: a single month, a full year, or anything in between. You can provide as many as you need; the more you provide, the tighter the calibration. Without any actuals, the API relies exclusively on parcel-level and neighborhood data combined with a probabilistic model of the US building stock by geography. Building attributes (e.g., `floor_area` in square meters) narrow the distribution. See the OpenAPI spec for the full list of supported attributes. **kWh actuals** calibrate the ensemble directly. In this example, the API distributes 27,000 kWh across months using weather-driven load shapes from the ensemble: ```json { "consumption": { "actuals": [{ "from_datetime": "2026-01-01", "to_datetime": "2027-01-01", "value": 27000, "variable": "consumption.electricity" }] } } ``` **Dollar actuals** require the API to convert $ to kWh before calibrating, using the configured electricity rate (see [Electricity rate](#electricity-rate) below). This conversion depends on the rate — a tiered tariff with fixed charges implies different kWh for the same dollar amount than the EIA flat rate. The output cost for the calibrated month may not exactly match the input bill, particularly with complex tariffs. When possible, prefer providing kWh actuals directly to avoid this indirection. ```json { "consumption": { "actuals": [{ "from_datetime": "2026-07-01", "to_datetime": "2026-08-01", "value": 498, "variable": "costs.electricity" }] } } ``` ### Electricity rate Rate configuration goes in `costs.utility_rates.electricity` and determines how dollar costs are calculated — and how dollar bill inputs are converted to kWh. #### EIA flat rate (default) The default electricity rate is the EIA utility-average residential rate for the location's serving utility — a single flat $/kWh, no tiers, no fixed charges. The `costs` object may be omitted entirely: ```json { "costs": {} } ``` #### Custom flat rate To override the default with a custom flat rate, supply `costs.utility_rates.electricity` as a `UtilityFlatRate`: ```json { "costs": { "utility_rates": { "electricity": { "units": "$/kWh", "value": 0.29305 } } } } ``` #### Inline tariff (BYO) Supply `costs.utility_rates.electricity` as a `Tariff` to model tiered pricing, time-of-use periods, seasonal rates, demand charges, fixed charges, surcharges, taxes, net metering, and export credits. The `Tariff` is interpreted exactly as provided — no external lookup. See the OpenAPI spec for the full structure; a minimal flat-import example: ```jsonc { "costs": { "utility_rates": { "electricity": { "type": "tariff", "name": "Acme Residential R1", "rates": [ { "rate_type": "imports", "name": "Energy charge", "charge_period": "monthly", "bands": [{ "amount": 0.17 }] }, { "rate_type": "fixed", "name": "Service charge", "charge_period": "monthly", "bands": [{ "amount": 12.50 }] } ] } } } } ``` The response echoes the tariff back under `data.costs.utility_rates.electricity`, so callers can stash and re-use it across requests. When passing a `Tariff`, `costs.utility_rates.pv_buyback` cannot also be set — export credits go inside the tariff (`rate_type: "exports"` / `"net_exports"` / `"net"`). For tariffs that model mid-year rate changes or seasonal step pricing crossing an effective-date boundary, set `costs.use_tariff_history: true` to honor the `from_datetime` / `to_datetime` fields on individual `Rate` entries. Default is `false`, which treats rates as time-invariant and ignores those date fields. (`costs` also accepts `utility_rates.fossil_fuel`, `utility_rates.pv_ppa`, `emission_rates`, and `fossil_fuel_type` overrides; see the OpenAPI spec for the full surface.) ### Putting it together A complete request estimating monthly consumption from a July bill, with stable aggregation: ```jsonc { "location": { "latitude": 28.819114, "longitude": -81.529367 }, "parameters": { "from_datetime": "2025-03-01", "to_datetime": "2026-03-01", "group_by": "month", "interval_format": "wide", "variables": ["consumption.electricity", "costs.electricity"] }, "consumption": { "attributes": { "baseline": [ { "name": "floor_area", "value": 492.39 }, { "name": "num_occupants", "value": 4 }, { "name": "vintage", "value": "2000s" } ] }, "actuals": [{ "from_datetime": "2025-07-01", "to_datetime": "2025-08-01", "value": 498, "variable": "costs.electricity" }], "calibration": { "sample_size": 35, "ensemble_aggregation_method": "average" } } } ``` ## Typical Use Case #2: HVAC Load Sizing Calculate peak design heating and cooling loads in BTU/hr for right-sizing HVAC equipment. Use `POST /api/v0/bem/calculate` with `group_by: "hour"`, `interval_format: "wide"`, a full calendar year date range, and these variables: ```jsonc // fragment — merge into the top-level request body "variables": ["thermal_demand.cooling.delivered", "thermal_demand.heating.delivered"] ``` To avoid large payloads, do not use `"all_non_zero"`. For stable results, use `ensemble_aggregation_method: "average"` — weather is identical across ensemble members, so averaging produces stable thermal load shapes without meaningful loss of accuracy. ### Required baseline attribute defaults Always include these to ensure cooling is modelled for all buildings (merge user-provided overrides on top): ```jsonc // fragment — merge into consumption.attributes.baseline [ { "name": "hvac_cooling", "value": true }, { "name": "hvac_cooling_partial_space_conditioning", "value": 1.0 }, { "name": "hvac_cooling_setpoint", "value": 23.9 }, { "name": "hvac_heating_setpoint", "value": 21.1 } ] ``` The setpoints (23.9°C / 75°F cooling, 21.1°C / 70°F heating) match standard Manual J indoor design conditions. ### Building attributes The model infers all attributes not provided from national housing stock data. Supplying known values improves accuracy. The full list is in the OpenAPI spec — the most impactful for thermal loads: | Category | Attributes | |---|---| | Geometry | `floor_area` (m², divide ft² by 10.7639), `num_stories`, `orientation`, `building_type` | | Infiltration | `infiltration_ach50` (ACH50) — primary driver of ventilation load | | Windows | `window_to_wall_ratio`, `window_panes`, `window_low_e`, `window_storm` | | Walls | `wall_type`, `wall_insulation` (R-value) | | Roof/attic | `roof_or_ceiling_insulation` (R-value), `attic_type`, `roof_material` | | Foundation | `foundation_type`, `floor_or_slab_insulation` (R-value) | | Ducts | `hvac_heating_ducts`, `hvac_cooling_ducts`, `hvac_ducts_leakage`, `hvac_ducts_insulation`, `hvac_ducts_location` | | Occupancy | `num_occupants` | HVAC system attributes (`hvac_heating_efficiency`, `hvac_cooling_efficiency`, `hvac_heating_capacity`, etc.) are not relevant — load sizing answers *what does this building require*, independent of what equipment is installed. ### Request example ```json { "location": { "latitude": 28.819114, "longitude": -81.529367 }, "parameters": { "from_datetime": "2023-01-01T00:00:00", "to_datetime": "2024-01-01T00:00:00", "group_by": "hour", "interval_format": "wide", "variables": ["thermal_demand.cooling.delivered", "thermal_demand.heating.delivered"] }, "consumption": { "attributes": { "baseline": [ { "name": "hvac_cooling", "value": true }, { "name": "hvac_cooling_setpoint", "value": 23.9 }, { "name": "hvac_heating_setpoint", "value": 21.1 }, { "name": "hvac_cooling_partial_space_conditioning", "value": 1.0 }, { "name": "floor_area", "value": 185.8 }, { "name": "infiltration_ach50", "value": 7.0 } ] }, "calibration": { "ensemble_aggregation_method": "average" } } } ``` ### Peak load calculation `thermal_demand.*` values are in kBtu per hour-long interval. Multiply by 1,000 for BTU/hr. To determine peak load, find the max across all 8,760 intervals: ```python intervals = response["data"]["intervals"] peak_heating = max(r["thermal_demand.heating.delivered"] for r in intervals) * 1000 # BTU/hr peak_cooling = max(r["thermal_demand.cooling.delivered"] for r in intervals) * 1000 ``` ### Safety factors **Safety factors** are user-defined multipliers applied to the modeled peak load to account for real-world variation — uncertainty in building attributes, occupant behavior, and weather extremes not captured by the simulation. We recommend starting with 1.2 for heating and 1.1 for cooling — calibrated empirically to produce results consistent with Manual J software. Apply safety factors **after** finding the peak. ```python design_heating = peak_heating * heating_factor # e.g. 1.2 design_cooling = peak_cooling * cooling_factor # e.g. 1.1 ``` ### Gotchas **TMY understates design-day peaks.** The API uses Typical Meteorological Year weather — a statistically average year, not ASHRAE 99%/1% design-day extremes. The TMY-derived peak will be lower than a strict Manual J result; the safety factor partially compensates. **Use a full calendar year.** A partial date range will silently miss whichever season falls outside it. ## Typical Use Case #3: Solar Savings Calculations Estimate monthly pre- and post-solar bills and produce a multi-year financial projection for a rooftop PV system. The calculation is **four sequential requests**. Each step feeds into the next: 1. **Tariff** — define the inline tariff once and stash it for reuse (`sample_size: 1`, skip ensemble) 2. **Consumption** — model monthly kWh and pre-solar bills; save the 12 monthly kWh values 3. **Production** — simulate hourly PV output (`group_by: "hour"`); save all 8,760 intervals 4. **Savings** — pin Step 2 kWh + Step 3 hourly production as actuals, price against the post-solar tariff Use `ensemble_aggregation_method: "average"` in Steps 2–4. Savings = Step 2 `costs.electricity` − Step 4 `costs.electricity` per month. ### Steps 1–2 — Tariff and consumption ```jsonc // Step 1: define and stash the inline tariff. Run once; reuse the payload in Steps 2 and 4. { "location": { "latitude": 37.75, "longitude": -122.45 }, "parameters": { "from_datetime": "2025-01-01T00:00:00", "to_datetime": "2026-01-01T00:00:00", "group_by": "month", "interval_format": "wide", "variables": ["costs.electricity"] }, "consumption": { "calibration": { "sample_size": 1, "ensemble_aggregation_method": "nearest" } }, "costs": { "utility_rates": { "electricity": { "type": "tariff", "name": "Acme TOU R1", "rates": [ { "rate_type": "imports", "name": "Energy", "charge_period": "monthly", "bands": [{ "amount": 0.22 }] }, { "rate_type": "fixed", "name": "Service", "charge_period": "monthly", "bands": [{ "amount": 12.50 }] } ] } } } } // The response echoes the tariff at data.costs.utility_rates.electricity — stash it verbatim for Steps 2 and 4. ``` ```jsonc // Step 2: pre-solar consumption (save 12 monthly consumption.electricity values) { "location": { "latitude": 37.75, "longitude": -122.45 }, "parameters": { "from_datetime": "2025-01-01T00:00:00", "to_datetime": "2026-01-01T00:00:00", "group_by": "month", "interval_format": "wide", "variables": ["consumption.electricity", "costs.electricity"] }, "consumption": { "calibration": { "sample_size": 35, "ensemble_aggregation_method": "average" }, "actuals": [ // optional — any kWh or $ actuals for known months { "from_datetime": "2025-07-01", "to_datetime": "2025-08-01", "value": 720, "variable": "consumption.electricity" } ], "attributes": { "baseline": [{ "name": "floor_area", "value": 185.8 }, { "name": "num_occupants", "value": 3 }] } }, "costs": { "utility_rates": { "electricity": /* tariff payload from Step 1 */ } } } ``` ### Steps 3–4 — Production and savings ```jsonc // Step 3: hourly PV simulation (save all 8,760 intervals as production actuals for Step 4) { "location": { "latitude": 37.75, "longitude": -122.45 }, "parameters": { "from_datetime": "2025-01-01T00:00:00", "to_datetime": "2026-01-01T00:00:00", "group_by": "hour", "interval_format": "wide", "variables": ["production.electricity"] }, "consumption": { "calibration": { "sample_size": 1, "ensemble_aggregation_method": "nearest" } }, "production": { "attributes": { "baseline": [{ "name": "panel_arrays", "value": [{ "capacity": 8.0, "tilt": 20, "azimuth": 180 }] }] } } } // PV arrays go in production.attributes.baseline, not hypothetical ``` ```jsonc // Step 4: post-solar bill (savings = Step 2 cost − Step 4 cost per month) { "location": { "latitude": 37.75, "longitude": -122.45 }, "parameters": { "from_datetime": "2025-01-01T00:00:00", "to_datetime": "2026-01-01T00:00:00", "group_by": "month", "interval_format": "wide", "variables": ["costs.electricity", "costs.electricity.imports", "costs.electricity.exports"] }, "consumption": { "actuals": [ // 12 monthly consumption.electricity entries from Step 2 { "from_datetime": "2025-01-01", "to_datetime": "2025-02-01", "value": 850.2, "variable": "consumption.electricity" } // ... ], "attributes": { "baseline": [ /* same as Step 2 */ ] }, "calibration": { "sample_size": 35, "ensemble_aggregation_method": "average" } }, "production": { "actuals": [ // all 8,760 hourly production.electricity intervals from Step 3 ] }, "costs": { "utility_rates": { "electricity": /* tariff payload from Step 1, possibly with post-solar variant (TOU, net metering) */ } } } ``` ### Building attributes Any attributes from the OpenAPI spec can be supplied. All are optional — unspecified values are inferred. The critical rule is **consistency**: use the exact same attributes across request baselines. Changing a building attribute between steps conflates the building's baseline consumption profile with the solar savings estimate. ### 25-year financial projection ``` yearSavings[y] = annualSavings × (1 − degradation/100)^(y−1) × (1 + rateInflation/100)^(y−1) payments[y] = min(12, max(0, numPayments − (y−1)×12)) × monthlyPayment × (1 + pmtEscalation/100)^(y−1) cumulative[0] = −upfrontCost cumulative[y] = cumulative[y−1] + yearSavings[y] − payments[y] paybackYear = linear interpolation of when cumulative crosses zero ``` ### Gotchas **Steps are sequential** — each depends on outputs from the previous step. **Step 3 returns 8,760 intervals.** All must be passed back in Step 4. Strip response fields down to `from_datetime`, `to_datetime`, `value`, and `variable` before re-submitting to keep the payload manageable. **All four steps must share the same date range.** Use `group_by: "hour"` only in Step 3 — monthly granularity in Steps 1, 2, and 4.