Approach
Early-stage district thermal analyses typically rely on archetype energy models. Each building type gets a single representative model: one for offices, another for residential, and so on. Heating and cooling load profiles are extracted from these models and scaled by floor area.
The main problem with this approach is that it flattens the differences between buildings. Every office is treated like every other office, and every apartment building like every other apartment building. Yet those differences in age, systems, envelope, and operations are exactly what determine how well a district network will perform.
The alternatives - creating a detailed energy model for each building or collecting sub-metered energy data - lead to a more robust analysis, but they take several months (or years) and are not economically viable for large districts.
Carbon Signal sits between the two. The platform uses machine learning to infer the baseline characteristics of each building based on its monthly utility data, preserving the exact differences between buildings that are important for a district thermal network analysis. Once these baseline characteristics are known, the platform automatically creates an energy model from which load profiles can be extracted.
The following is an example of how we used the Carbon Signal API to extract load profiles from buildings in three districts in Manhattan, using only publicly available energy benchmarking data as an input. These load profiles created the foundation for a thermal network analysis that Carbon Signal completed with the Urban Land Institution (ULI).
Get The Data
New York’s Local Law 84 requires large buildings to report their energy use annually, and the city publishes the results. Two datasets matter: the annual benchmarking table, which carries address, coordinates, gross floor area, and primary property type; and the monthly consumption table, which carries twelve months of electricity, natural gas, district steam, and fuel oil per property. Most large North American cities publish an equivalent.
We developed a pipeline that prioritizes using monthly utility data where available, as that will produce the highest quality calibration. Annualized data is used when we don’t have access to monthly values, while “archetype” models are only used as a last resort if there is no other information available about the building.
import requests
BASE_URL = "https://api.carbonsignal.com"
API_KEY = "your-api-key"
session = requests.Session()
session.headers.update(
{
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
)
building_payload = {
"name": f"{address} ({bbl})",
"notes": None,
"location": {
"type": "coordinates",
"lat": lat,
"lon": lon,
"country": "US",
"city": city,
"state": state,
"zip": zip_code,
"address": address,
},
"area": gross_floor_area_ft2,
"occupancy_type": "MF_HOUSING",
"reported_energy": [
{
"year": 2023,
"measured_utilities": {
"electricity": [...12 monthly kBtu values...],
"gas": [...],
"steam": [...],
"chilled_water": [0.0] * 12,
},
}
],
"calibration_year": 2023,
"heating_system": {"type": None, "cop_target": None},
"cooling_system": {"type": None, "cop_target": None},
"discount_rate_percentage": 6.0,
"secondary_occupancy_type": None,
"secondary_occupancy_percentage": None,
"lease_end_date": 2050,
"constrain_analysis_to_lease_term": False,
}
For each building, we set the heating_system and cooling_system to unknown. We do not know what equipment is in these buildings, and the calibration infers it from the consumption signature more reliably than we could guess.
The Analysis
POST /v1.0/buildings/bulk returns quickly, and what comes back is building records: identifiers, names, and a status field. The calibration itself runs afterward. The API automatically creates tasks for the baseline and intervention simulations and sends them to a queue for processing.
For an integration, this means treating creation as a job submission rather than a write. A pipeline that creates several hundred buildings and immediately requests their calibrated data gets empty responses back. This is another
response = session.post(
f"{BASE_URL}/v1.0/buildings/bulk",
params={"team_id": team_id},
json={"buildings": building_payloads},
)
response.raise_for_status()
created = response.json()
pending = {building["id"] for building in created}
# Poll until calibration finishes, then poll result endpoints until their
# "not ready" message clears.
while pending:
still_pending = set()
failed = []
for building_id in pending:
building = session.get(f"{BASE_URL}/v1.0/buildings/{building_id}").json()
status = building["status"]
if status in {"QUEUED", "RUNNING", "NOT_STARTED"}:
still_pending.add(building_id)
elif status == "FAILED":
failed.append(building_id)
if failed:
raise RuntimeError(f"Calibration failed for:{failed}")
pending = still_pending
time.sleep(poll_interval)
Status is a single field on GET /v1.0/buildings/{building_id}, moving through NOT_STARTED, QUEUED, and RUNNING before COMPLETED or FAILED. A COMPLETED building has a finished calibration, but the result endpoints can still return a {"message": "... not ready."} response until the baseline and intervention simulations are complete.
Site Energy
The calibrated outputs arrive through three endpoints. The baseline energy endpoint returns twelve monthly values per utility in kBtu/ft². The end-uses endpoint returns the annual end-use breakdown, disaggregated by utility. Both report every quantity as a distribution rather than a single number. The characterization endpoint returns details about the building inferred from the calibration process, including the heating and cooling equipment we deliberately left unspecified at creation.
// GET /v1.0/buildings/{building_id}/baseline (trimmed to two months)
"steam": [{"estimate": 11.4, "p25": 9.8, "p75": 13.1}, // January, kBtu/ft²
{"estimate": 9.7, "p25": 8.4, "p75": 11.2}], // February
"electricity": [{"estimate": 2.9, "p25": 2.7, "p75": 3.2}, ...]
// GET /v1.0/buildings/{building_id}/end-uses (trimmed)
"energy_by_utility": {
"heating": {"steam": {"estimate": 0.82, ...}, "electricity": null},
"cooling": {"electricity": {"estimate": 0.31, ...}, "steam": null}
}
A null means that utility does not contribute to that end use, which is itself a calibration result: this building heats with steam and cools with electricity. Multiplying each utility’s monthly consumption (kBtu/ft2) by the fraction that goes to heating and cooling, then summing across all utilities and scaling by floor area, gives monthly heating and cooling site energy in absolute terms: the fuel and electricity going into the equipment, not yet the thermal load that equipment delivers.
baseline = session.get(f"{BASE_URL}/v1.0/buildings/{building_id}/baseline").json()
end_uses = session.get(f"{BASE_URL}/v1.0/buildings/{building_id}/end-uses").json()
if "message" in baseline or "message" in end_uses:
raise RuntimeError("Building results are not ready yet")
UTILITIES = ["electricity", "gas", "steam", "chilled_water"]
# Keep site energy per utility rather than a single total: the conversion further down
# needs to know which fuel each kBtu arrived as, since each is delivered at a
# different efficiency.
def monthly_site_energy(end_use: str) -> dict[str, list[float]]:
per_utility = {utility: [0.0] * 12 for utility in UTILITIES}
for utility in UTILITIES:
share = end_uses["energy_by_utility"][end_use][utility]
if share is None: # this utility does not serve this end use in this building
continue
for month_index in range(12):
monthly_eui = baseline[utility][month_index]["estimate"]
per_utility[utility][month_index] = monthly_eui * share["estimate"] * area_ft2
return per_utility
heating_site_kbtu = monthly_site_energy("heating")
cooling_site_kbtu = monthly_site_energy("cooling")Thermal Demand
Site energy is not thermal demand. A boiler burning 100 kBtu of gas at 80% efficiency delivers 80 kBtu of heat; a chiller drawing 100 kBtu of electricity at a COP of 4 delivers 400 kBtu of cooling. What a shared loop has to carry is the delivered thermal quantity, so the site energy profiles need one more conversion. The conversion factors to do it - HEATCOP and COOLCOP - are already in the calibration.
These values come back with an inferred_system and a counts array, which refers to the number of ensemble members that fall into different performance tiers. The values in the counts array are indexed to a specific definition of each performance tier published in /v1.0/config.
// GET /v1.0/buildings/{building_id}/characterization (trimmed)
"HEATCOP": {"inferred_system": "GAS", "counts": [12, 61, 27], "total_count": 100}
// GET /v1.0/config -> model_parameters.HEATCOP.characterization.GAS
"buckets": [{"label": "Less than 75% efficiency"},
{"label": "75% to 80%"},
{"label": "Greater than 80% efficiency"}]
Since the performance tiers refer to a range, you’ll need to choose a representative value for each tier. Note that the values for HEATCOP for GAS refer to efficiency values less than 1, while those for ELECTRICITY refer to efficiency values greater than 1.
config = session.get(f"{BASE_URL}/v1.0/config").json()
characterization = session.get(
f"{BASE_URL}/v1.0/buildings/{building_id}/characterization"
).json()
# Representative value per bucket, in the order the config lists them.
MIDPOINTS = {
("HEATCOP", "ELECTRICITY"): (1.0, 2.5, 4.0), # resistance / 2-3 / >3
("HEATCOP", "GAS"): (0.70, 0.775, 0.875), # <75% / 75-80% / >80%
("COOLCOP", "ELECTRICITY"): (2.5, 4.0, 5.5), # <3.5 / 3.5-4.5 / >4.5
}
# District and unknown systems carry no posterior; fall back to a stated assumption.
DEFAULTS = {"HEATCOP": {"DISTRICT": 0.85, "NO_SYSTEM": 1.0},
"COOLCOP": {"DISTRICT": 1.0, "NO_SYSTEM": 1.0}}
def calibrated_efficiency(dimension: str) -> float:
"""Count-weighted mean of the calibrated efficiency posterior."""
result = characterization[dimension]
system = result["inferred_system"]
buckets = config["model_parameters"][dimension]["characterization"][system]
if buckets is None:
return DEFAULTS[dimension][system]
midpoints = MIDPOINTS[(dimension, system)]
counts = result["counts"]
assert len(counts) == len(buckets["buckets"]) == len(midpoints)
return sum(c * m for c, m in zip(counts, midpoints)) / sum(counts)
heating_efficiency = calibrated_efficiency("HEATCOP") # e.g. 0.79 for a gas boiler
cooling_cop = calibrated_efficiency("COOLCOP") # e.g. 4.1 for an electric chiller
After applying this conversation factors to the site energy use profiles, you’ll have thermal loads to use in a district analysis. District chilled water and district steam are already delivered thermal energy and pass through unchanged.
thermal_heating_kbtu = [
sum(heating_site_kbtu[u][m] for u in UTILITIES) * heating_efficiency
for m in range(12)
]
thermal_cooling_kbtu = [
cooling_site_kbtu["electricity"][m] * cooling_cop
+ cooling_site_kbtu["chilled_water"][m]
for m in range(12)
]Interpreting Results
Every statistic arrives with a 25th and 75th percentile estimate alongside the median value. In practice we run the entire downstream analysis three times, once on each quantile, and carry the bounds through to the final metrics. Having the full range of outcomes helps overcome errors in any single assumption, and allows analysts to choose the appropriate confidence interval for different questions.
Unlocking possibilities
Once you have the thermal load profiles, you can use them as inputs for a variety of different analysis tasks. For our district network analysis with ULI, we used the thermal load profiles for:
- Demand Overlap Coefficient (DOC): Computed across a group of buildings and for each pair of buildings in a district. Districts systems with a DOC > 0.2 tend to show lower total annualized costs than standalone systems. Calibrated profiles let you evaluate real districts, not just archetypes.
- Building cluster selection. Pairwise diversity across the portfolio identifies which specific buildings in a district system are worth connecting first, rather than treating the district as one undifferentiated mass. This is the analysis archetype profiles cannot support at all: if two buildings of the same type and size share a normalized shape, their pairwise diversity is close to zero.
Reminders
Three things are worth designing around from the start: 1) Creation is asynchronous, so a status-polling stage belongs in your architecture rather than a sleep statement in a function. 2) The bulk create endpoint relies on building name, so names must be unique or a re-run will update buildings you did not intend to touch. And 3) because calibration is the expensive step, caching results and versioning the parameters that produced them will save you far more time than optimizing anything else.




