Here is where you can see stats about your org. You will need to expand the configurations section below to indicate which columns
+ your data is stored in.
+
+
+
+
+
+
Select the columns where your organization stores the relevant data to generate statistics.
+ Note that quantities will be converted to kWh and ft2 where appropriate.
Here is where you can see stats about your org. You will need to expand the configurations section below to indicate which columns
- your data is stored in.
-
+
Here is where you can see stats about your org. You will need to expand the configurations section below to indicate which columns your data is stored in.
-
-
Select the columns where your organization stores the relevant data to generate statistics.
- Note that quantities will be converted to kWh and ft2 where appropriate.
+
+ Select the columns where your organization stores the relevant data to generate statistics. Note that quantities will be converted to calculate EUIs in kBtu/ft2 where
+ appropriate. Enter EITHER Annual Electricity and Natural Gas usage quantities OR Electricity and Gas EUIs. EUIs will be used if given; otherwise they will be calculated from usage
+ quantities.
+
-
+
-
Statistics
+
+ {$:: 'Calculate Statistics' | translate $}
+
+
+
Overall Statistics - Gross Floor Area
+
+
+
Cycle
+
Number of Properties
+
Total Gross Floor Area (ft2)
+
+
+
{$ item.cycle $}
+
{$ item.num_records $}
+
{$ item.GFA | number: 0 $}
+
+
+
+
Overall Statistics - Electricity
+
+
+
Cycle
+
Number of Properties
+
Gross Floor Area Represented (ft2)
+
Electricity Use (kWh)
+
+
+
{$ item.cycle $}
+
{$ item.num_records $}
+
{$ item.GFA | number: 0 $}
+
{$ item.Electricity_Use | number: 0 $}
+
+
-
charts and tables here
+
Overall Statistics - Natural Gas
+
+
+
Cycle
+
Number of Properties
+
Gross Floor Area Represented (ft2)
+
Natural Gas Use (therms)
+
+
+
{$ item.cycle $}
+
{$ item.num_records $}
+
{$ item.GFA | number: 0 $}
+
{$ item.Natural_Gas_Use | number: 0 $}
+
+
+
EUI Quantiles - Electricity
+
+
+
Cycle
+
5%
+
25%
+
50%
+
75%
+
95%
+
+
+
{$ key $}
+
{$ item['0.05'] | number: 2 $}
+
{$ item['0.25'] | number: 2 $}
+
{$ item['0.5'] | number: 2 $}
+
{$ item['0.75'] | number: 2 $}
+
{$ item['0.95'] | number: 2 $}
+
+
+
+
EUI Quantiles - Natural Gas
+
+
+
Cycle
+
5%
+
25%
+
50%
+
75%
+
95%
+
+
+
{$ key $}
+
{$ item["0.05"] | number: 2 $}
+
{$ item["0.25"] | number: 2 $}
+
{$ item["0.5"] | number: 2 $}
+
{$ item["0.75"] | number: 2 $}
+
{$ item["0.95"] | number: 2 $}
+
+
+
+
+
diff --git a/seed/static/seed/scss/style.scss b/seed/static/seed/scss/style.scss
index 77d9f3eb05..b85c05a91a 100755
--- a/seed/static/seed/scss/style.scss
+++ b/seed/static/seed/scss/style.scss
@@ -5897,6 +5897,6 @@ tags-input .tags .tag-item {
.stat_instructions_container {
border: 1px solid #ccc;
- padding:20px;
+ padding: 20px;
margin-top: 10px;
}
diff --git a/seed/templates/seed/_vendor_scripts.html b/seed/templates/seed/_vendor_scripts.html
index 04e4b364fa..9811af3f3d 100644
--- a/seed/templates/seed/_vendor_scripts.html
+++ b/seed/templates/seed/_vendor_scripts.html
@@ -35,6 +35,7 @@
+
diff --git a/seed/views/v3/statistics.py b/seed/views/v3/statistics.py
index bb9b778a9b..4ad283fc9e 100644
--- a/seed/views/v3/statistics.py
+++ b/seed/views/v3/statistics.py
@@ -3,10 +3,14 @@
See also https://github.com/SEED-platform/seed/blob/main/LICENSE.md
"""
+import re
from copy import deepcopy
import django.core.exceptions
-from django.db import IntegrityError
+import numpy as np
+import pandas as pd
+from django.db import IntegrityError, models
+from django.db.models.fields.json import KeyTransform
from django.http import JsonResponse
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status, viewsets
@@ -14,14 +18,14 @@
from seed.decorators import ajax_request_class, require_organization_id_class
from seed.lib.superperms.orgs.decorators import has_perm_class
-from seed.lib.superperms.orgs.models import Organization
-from seed.models import StatusLabel as Label
+from seed.lib.superperms.orgs.models import AccessLevelInstance, Organization
from seed.models.columns import Column
+from seed.models.cycles import Cycle
+from seed.models.properties import PropertyView
from seed.models.statistics_setups import StatisticsSetup
from seed.serializers.statistics_setups import StatisticsSetupSerializer
from seed.utils.api import OrgMixin, api_endpoint_class
from seed.utils.api_schema import AutoSchemaHelper, swagger_auto_schema_org_query_param
-from seed.utils.encrypt import decrypt, encrypt
def _validate_data(data, org_id):
@@ -43,6 +47,78 @@ def _validate_data(data, org_id):
return error, msgs
+def _remove_nan(the_obj):
+ """Convert all NaN values in a nested dictionary to None"""
+ if isinstance(the_obj, dict):
+ for key, value in the_obj.items():
+ if isinstance(value, dict):
+ _remove_nan(value)
+ elif pd.isna(value):
+ the_obj[key] = None
+ else:
+ for item in the_obj:
+ if isinstance(item, dict):
+ _remove_nan(item)
+ elif pd.isna(item):
+ the_obj[item] = None
+ return the_obj
+
+
+def _convert_to_numeric(df, exclude_cols=[]):
+ """
+ Convert all columns in a DataFrame to numeric types.
+ For columns with text patterns like '2042.0 foot ** 2',
+ extract the numeric value before conversion.
+
+ Parameters:
+ -----------
+ df : pandas.DataFrame
+ DataFrame with mixed type columns
+
+ Returns:
+ --------
+ pandas.DataFrame
+ DataFrame with all columns converted to numeric types
+ """
+ # Create a copy to avoid modifying the original
+ df_numeric = df.copy()
+
+ for col in df_numeric.columns:
+ # Skip columns in exclude_cols
+ if col in exclude_cols:
+ continue
+ # Check if column is already numeric
+ if pd.api.types.is_numeric_dtype(df_numeric[col]):
+ continue
+
+ # If it's an object type, try to extract numeric values
+ if df_numeric[col].dtype == "object":
+ # Function to extract numeric part from strings
+ def extract_numeric(val):
+ if pd.isna(val):
+ return np.nan
+ if isinstance(val, (int, float)):
+ return val
+
+ # Convert to string if not already
+ val_str = str(val)
+
+ # Extract the first number (including decimals)
+ # This pattern matches numbers like 123, 123.45, .45
+ match = re.search(r"([-+]?\d*\.?\d+)", val_str)
+ if match:
+ return match.group(1)
+ return val
+
+ # Apply extraction and then convert to numeric
+ df_numeric[col] = df_numeric[col].apply(extract_numeric)
+
+ # Convert to numeric, with errors='coerce' to convert non-numerics to NaN
+ df_numeric[col] = pd.to_numeric(df_numeric[col], errors="coerce")
+
+ return df_numeric
+
+
class StastisticsSetupViewSet(viewsets.ViewSet, OrgMixin):
serializer_class = StatisticsSetupSerializer
model = StatisticsSetup
@@ -71,9 +147,7 @@ def retrieve(self, request, pk=0):
return JsonResponse(
{
"status": "success",
- "statistic": StatisticsSetupSerializer(
- StatisticsSetup.objects.filter(organization=organization).first()
- ).data,
+ "statistic": StatisticsSetupSerializer(StatisticsSetup.objects.filter(organization=organization).first()).data,
},
status=status.HTTP_200_OK,
)
@@ -101,9 +175,7 @@ def destroy(self, request, pk):
try:
StatisticsSetup.objects.get(id=pk, organization=organization_id).delete()
except StatisticsSetup.DoesNotExist:
- return JsonResponse(
- {"status": "error", "message": f"Statistics with id {pk} does not exist"}, status=status.HTTP_404_NOT_FOUND
- )
+ return JsonResponse({"status": "error", "message": f"Statistics with id {pk} does not exist"}, status=status.HTTP_404_NOT_FOUND)
return JsonResponse({"status": "success", "message": f"Successfully deleted Statistics ID {pk}"}, status=status.HTTP_200_OK)
@@ -114,9 +186,13 @@ def destroy(self, request, pk):
"gfa_column": "integer",
"gfa_units": "string",
"electricity_column": "integer",
- "electricity_units" : "string",
+ "electricity_units": "string",
"natural_gas_column": "integer",
- "natural_gas_units": "string"
+ "natural_gas_units": "string",
+ "electricity_eui_column": "integer",
+ "electricity_eui_units": "string",
+ "natural_gas_eui_column": "integer",
+ "natural_gas_eui_units": "string",
},
),
)
@@ -165,7 +241,11 @@ def create(self, request):
"electricity_column": "integer",
"electricity_units": "string",
"natural_gas_column": "integer",
- "natural_gas_units": "string"
+ "natural_gas_units": "string",
+ "electricity_eui_column": "integer",
+ "electricity_eui_units": "string",
+ "natural_gas_eui_column": "integer",
+ "natural_gas_eui_units": "string",
},
),
)
@@ -180,9 +260,7 @@ def update(self, request, pk):
try:
statistic = StatisticsSetup.objects.get(id=pk, organization=org_id)
except StatisticsSetup.DoesNotExist:
- return JsonResponse(
- {"status": "error", "message": f"Statistics with id {pk} does not exist"}, status=status.HTTP_404_NOT_FOUND
- )
+ return JsonResponse({"status": "error", "message": f"Statistics with id {pk} does not exist"}, status=status.HTTP_404_NOT_FOUND)
data = deepcopy(request.data)
error, msgs = _validate_data(data, org_id)
@@ -229,3 +307,181 @@ def update(self, request, pk):
},
status=status.HTTP_400_BAD_REQUEST,
)
+
+ @require_organization_id_class
+ @api_endpoint_class
+ @ajax_request_class
+ @has_perm_class("requires_owner")
+ @action(detail=True, methods=["POST"])
+ def calculate(self, request, pk):
+ org_id = self.get_organization(request)
+ ali = AccessLevelInstance.objects.get(pk=request.access_level_instance_id)
+ # get statistic setup for this org
+ try:
+ statistic = StatisticsSetup.objects.get(id=pk, organization=org_id)
+ except StatisticsSetup.DoesNotExist:
+ return JsonResponse({"status": "error", "message": f"Statistics with id {pk} does not exist"}, status=status.HTTP_404_NOT_FOUND)
+
+ # get all cycles in this org
+ cycles = Cycle.objects.filter(organization=org_id)
+
+ # column ids we are interested in, only those non-null
+
+ cols = {}
+ extra_data_cols = {}
+ if statistic.gfa_column:
+ if statistic.gfa_column.is_extra_data:
+ extra_data_cols["gfa"] = statistic.gfa_column.column_name
+ else:
+ cols["gfa"] = "state__" + statistic.gfa_column.column_name
+ else:
+ # if we don't have GFA column, we can't calculate anything
+ return JsonResponse(
+ {
+ "status": "error",
+ "message": "GFA column is required to calculate statistics",
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ # prefer EUI column over usage column
+ if statistic.electricity_eui_column:
+ if statistic.electricity_eui_column.is_extra_data:
+ extra_data_cols["elec_eui"] = statistic.electricity_eui_column.column_name
+ else:
+ cols["elec_eui"] = "state__" + statistic.electricity_eui_column.column_name
+ elif statistic.electricity_column:
+ if statistic.electricity_column.is_extra_data:
+ extra_data_cols["electricity"] = statistic.electricity_column.column_name
+ else:
+ cols["electricity"] = "state__" + statistic.electricity_column.column_name
+ if statistic.natural_gas_eui_column:
+ if statistic.natural_gas_eui_column.is_extra_data:
+ extra_data_cols["gas_eui"] = statistic.natural_gas_eui_column.column_name
+ else:
+ cols["gas_eui"] = "state__" + statistic.natural_gas_eui_column.column_name
+ elif statistic.natural_gas_column:
+ if statistic.natural_gas_column.is_extra_data:
+ extra_data_cols["natural_gas"] = statistic.natural_gas_column.column_name
+ else:
+ cols["natural_gas"] = "state__" + statistic.natural_gas_column.column_name
+
+ # make sure we have at least electricity or gas here to calculate?
+ if len(cols.keys()) + len(extra_data_cols.keys()) < 2:
+ return JsonResponse(
+ {
+ "status": "error",
+ "message": "At least one of electricity or natural gas column is required to calculate statistics",
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # reverse the cols dictionary to have the keys be the values
+ # this is to make it easier to map the results back to the correct columns in df
+ cols_reverse = {v: k for k, v in cols.items()}
+
+ annotations: dict[str, models.Func] = {}
+ for key, val in extra_data_cols.items():
+ annotations[key] = KeyTransform(val, "state__extra_data")
+
+ results = {}
+ df_full = pd.DataFrame()
+ # get all properties that fall in all cycles for this org
+ for cycle in cycles:
+ # get Property-Views for this Cycle
+ property_views = (
+ PropertyView.objects.select_related("state")
+ .filter(
+ property__organization_id=org_id,
+ cycle_id=cycle.id,
+ property__access_level_instance__lft__gte=ali.lft,
+ property__access_level_instance__rgt__lte=ali.rgt,
+ )
+ .annotate(**annotations)
+ .values("property_id", "state_id", *(list(cols.values())), *(list(extra_data_cols.keys())))
+ )
+
+ # dataframe column names will be:
+ # property_id, state_id, electricity, natural_gas, cycle_id, gfa,
+ # elec_eui, gas_eui
+
+ df_temp = pd.DataFrame.from_records(list(property_views))
+ df_temp["cycle"] = cycle.name # or use ID and get name in frontend?
+ df_full = pd.concat([df_full, df_temp])
+
+ # remap the column names to the original column names
+ df_full = df_full.rename(columns=cols_reverse)
+ df_full = _convert_to_numeric(df_full, ["cycle"])
+
+ # calculate the statistics
+ # check if we have EUIs or Usage columns
+ # electricity
+ if "electricity" in df_full.columns:
+ # we have usage, compute EUI
+ df_full["elec_eui"] = (df_full["electricity"] / df_full["gfa"]).replace([np.inf, -np.inf], np.nan)
+ # gas
+ if "natural_gas" in df_full.columns:
+ df_full["gas_eui"] = (df_full["natural_gas"] / df_full["gfa"]).replace([np.inf, -np.inf], np.nan)
+
+ # reverse calculate electricity and/or natural gas from EUIs
+ # electricity
+ if "elec_eui" in df_full.columns:
+ df_full["electricity"] = df_full["elec_eui"] * df_full["gfa"]
+ # gas
+ if "gas_eui" in df_full.columns:
+ df_full["natural_gas"] = df_full["gas_eui"] * df_full["gfa"]
+
+ # high-level counts:
+ yearly_gfa = (
+ df_full.dropna(subset=["gfa"])
+ .groupby("cycle")
+ .agg(num_records=("property_id", "count"), cycle=("cycle", "first"), GFA=("gfa", "sum"))
+ )
+ yearly_elec = (
+ df_full.dropna(subset=["gfa", "elec_eui"])
+ .groupby("cycle")
+ .agg(num_records=("property_id", "count"), cycle=("cycle", "first"), GFA=("gfa", "sum"), Electricity_Use=("electricity", "sum"))
+ )
+ yearly_elec.style.format("{:,.0f}")
+ # print(yearly_elec) # format numbers with commas
+
+ yearly_gas = (
+ df_full.dropna(subset=["gfa", "gas_eui"])
+ .groupby("cycle")
+ .agg(num_records=("property_id", "count"), cycle=("cycle", "first"), GFA=("gfa", "sum"), Natural_Gas_Use=("natural_gas", "sum"))
+ )
+ yearly_gas.style.format("{:,.0f}")
+ # print(yearly_gas) # format numbers with commas
+
+ # elec quartiles for each cycle (include cycle key)
+ elec_eui_quantiles = df_full.groupby("cycle")["elec_eui"].quantile([0.05, 0.25, 0.50, 0.75, 0.95]).unstack() # noqa: PD010
+
+ # print(f"Elec Quantiles")
+ # print(elec_eui_quantiles)
+
+ # do the same for gas eui
+ gas_eui_quantiles = df_full.groupby("cycle")["gas_eui"].quantile([0.05, 0.25, 0.50, 0.75, 0.95]).unstack() # noqa: PD010
+ # print(f"Gas Quantiles")
+ # print(gas_eui_quantiles)
+
+ df_return = df_full[["property_id", "cycle", "elec_eui", "gas_eui"]]
+
+ # transform df_return to have a key for each cycle and an array of elec_eui
+ chart_data_elec = {}
+ chart_data_gas = {}
+ for year_value in df_return["cycle"].unique():
+ chart_data_elec[year_value] = df_return[df_return["cycle"] == year_value]["elec_eui"].dropna().tolist()
+ chart_data_gas[year_value] = df_return[df_return["cycle"] == year_value]["gas_eui"].dropna().tolist()
+
+ # return the results and convert NaN to None
+ results["elec_quantiles"] = elec_eui_quantiles.to_dict(orient="index")
+ results["gas_quantiles"] = gas_eui_quantiles.to_dict(orient="index")
+ results["chart_data_elec"] = chart_data_elec
+ results["chart_data_gas"] = chart_data_gas
+ results["annual_gfa"] = _remove_nan(yearly_gfa.to_dict(orient="records"))
+ results["annual_elec"] = _remove_nan(yearly_elec.to_dict(orient="records"))
+ results["annual_gas"] = _remove_nan(yearly_gas.to_dict(orient="records"))
+
+ return JsonResponse(
+ {"status": "success", "data": results},
+ status=status.HTTP_200_OK,
+ )
diff --git a/vendors/package-lock.json b/vendors/package-lock.json
index e846f9afbd..15aa9a3556 100644
--- a/vendors/package-lock.json
+++ b/vendors/package-lock.json
@@ -12,6 +12,7 @@
"@fontsource/pt-sans": "^5.1.0",
"@fontsource/pt-sans-narrow": "^5.1.0",
"@fortawesome/fontawesome-free": "^6.6.0",
+ "@sgratzl/chartjs-chart-boxplot": "^3.10.0",
"angular": "^1.8.3",
"angular-animate": "^1.8.3",
"angular-aria": "^1.8.3",
@@ -30,7 +31,7 @@
"angular-ui-sortable": "^0.19.0",
"angular-xeditable": "^0.10.2",
"bootstrap": "^3.4.1",
- "chart.js": "^3.9.1",
+ "chart.js": "3.9.1",
"chartjs-plugin-annotation": "^2.2.1",
"chartjs-plugin-zoom": "=1.2.1",
"d3": "^5.16.0",
@@ -473,6 +474,22 @@
"integrity": "sha512-/Ri4xDDpe12NT6Ex/DRgHzLlobiQXEW/hmG08w1wj/YU7hLemk97c+zHQFp0iZQ9r7YqgLEXZR2sls4HxBf9NA==",
"peer": true
},
+ "node_modules/@sgratzl/boxplots": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@sgratzl/boxplots/-/boxplots-1.3.4.tgz",
+ "integrity": "sha512-wRlHp9sZ2aQ/BBq+6Xg8DBsf4zXs/DQhNpLRlfwyO62IqTANmu7d64yE6jSKMnixM+nbtZqbeGa9spiaU/S++g=="
+ },
+ "node_modules/@sgratzl/chartjs-chart-boxplot": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/@sgratzl/chartjs-chart-boxplot/-/chartjs-chart-boxplot-3.10.0.tgz",
+ "integrity": "sha512-+XzmRr9unHzTAMrNVjQ1U6qKid2fEYpV6byC7WIKyU/nT3zB1g98RDuF+z8cFwBjZm/NdVw0uaQYdjHWwHiPbA==",
+ "dependencies": {
+ "@sgratzl/boxplots": "^1.3.0"
+ },
+ "peerDependencies": {
+ "chart.js": "^3.9.1"
+ }
+ },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
diff --git a/vendors/package.json b/vendors/package.json
index 881c336bda..67034e8426 100644
--- a/vendors/package.json
+++ b/vendors/package.json
@@ -26,9 +26,10 @@
"angular-ui-sortable": "^0.19.0",
"angular-xeditable": "^0.10.2",
"bootstrap": "^3.4.1",
- "chart.js": "^3.9.1",
+ "chart.js": "3.9.1",
"chartjs-plugin-annotation": "^2.2.1",
"chartjs-plugin-zoom": "=1.2.1",
+ "@sgratzl/chartjs-chart-boxplot": "^3.10.0",
"d3": "^5.16.0",
"dimple": "PMSI-AlignAlytics/dimple#2.3.0",
"file-saver": "^2.0.5",
From e123c9c7b5e8ace3065f088e2fd538106ee5763a Mon Sep 17 00:00:00 2001
From: kflemin <2205659+kflemin@users.noreply.github.com>
Date: Thu, 17 Jul 2025 10:30:27 -0600
Subject: [PATCH 6/6] XML report updates for AT export
---
seed/audit_template/audit_template.py | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/seed/audit_template/audit_template.py b/seed/audit_template/audit_template.py
index 4cb6fb4806..5257a5b906 100644
--- a/seed/audit_template/audit_template.py
+++ b/seed/audit_template/audit_template.py
@@ -358,12 +358,12 @@ def build_xml(self, state, report_type, display_field):
em.Reports(
em.Report(
{"ID": report_id},
- em.Scenarios(
- *(
- []
- if not org.audit_template_export_meters
- else _build_metering_scenarios(em, view.property.id, building_id)
- ),
+ *(
+ [em.Scenarios(
+ *_build_metering_scenarios(em, view.property.id, building_id)
+ )]
+ if org.audit_template_export_meters and _build_metering_scenarios(em, view.property.id, building_id)
+ else []
),
em.LinkedPremisesOrSystem(
em.Building(em.LinkedBuildingID({"IDref": building_id})),
@@ -371,7 +371,7 @@ def build_xml(self, state, report_type, display_field):
em.UserDefinedFields(
em.UserDefinedField(
em.FieldName("Audit Template Report Type"),
- em.FieldValue(report_type),
+ em.FieldValue(str(report_type).strip()),
),
),
)