diff --git a/seed/migrations/0237_auto_20241106_1339.py b/seed/migrations/0237_auto_20241106_1339.py new file mode 100644 index 0000000000..f835cd64e5 --- /dev/null +++ b/seed/migrations/0237_auto_20241106_1339.py @@ -0,0 +1,91 @@ +# Generated by Django 3.2.25 on 2024-11-06 21:39 + +import quantityfield.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("seed", "0236_auto_20241030_1434"), + ] + + operations = [ + migrations.AlterField( + model_name="batterysystem", + name="efficiency", + field=models.FloatField(), + ), + migrations.AlterField( + model_name="meter", + name="connection_type", + field=models.IntegerField( + choices=[ + (1, "Imported"), + (2, "Exported"), + (3, "Receiving Service"), + (4, "Returning To Service"), + (5, "Total From Users"), + (6, "Total To Users"), + ], + default=1, + ), + ), + migrations.AlterField( + model_name="service", + name="emission_factor", + field=models.FloatField(null=True), + ), + migrations.RemoveField( + model_name="batterysystem", + name="capacity", + ), + migrations.RemoveField( + model_name="dessystem", + name="capacity", + ), + migrations.AddField( + model_name="batterysystem", + name="energy_capacity", + field=quantityfield.fields.QuantityField(base_units="kWh", default=1, unit_choices=["kWh"]), + preserve_default=False, + ), + migrations.AddField( + model_name="batterysystem", + name="power_capacity", + field=quantityfield.fields.QuantityField(base_units="kW", default=1, unit_choices=["kW"]), + preserve_default=False, + ), + migrations.AddField( + model_name="dessystem", + name="cooling_capacity", + field=quantityfield.fields.QuantityField(base_units="Ton", null=True, unit_choices=["Ton"]), + ), + migrations.AddField( + model_name="dessystem", + name="heating_capacity", + field=quantityfield.fields.QuantityField(base_units="MMBtu", null=True, unit_choices=["MMBtu"]), + ), + migrations.AddField( + model_name="evsesystem", + name="voltage", + field=quantityfield.fields.QuantityField(base_units="V", default=1, unit_choices=["V"]), + preserve_default=False, + ), + migrations.AlterField( + model_name="batterysystem", + name="voltage", + field=quantityfield.fields.QuantityField(base_units="V", unit_choices=["V"]), + ), + migrations.AlterField( + model_name="evsesystem", + name="power", + field=quantityfield.fields.QuantityField(base_units="kW", unit_choices=["kW"]), + ), + migrations.AddConstraint( + model_name="dessystem", + constraint=models.CheckConstraint( + check=models.Q(("heating_capacity__isnull", False), ("cooling_capacity__isnull", False), _connector="OR"), + name="heating_or_cooling_capacity_required", + ), + ), + ] diff --git a/seed/models/meters.py b/seed/models/meters.py index 1003a447ba..934449e936 100644 --- a/seed/models/meters.py +++ b/seed/models/meters.py @@ -15,24 +15,24 @@ class Meter(models.Model): ## CONNECTION TYPES # These connection types do not require services. May be on a property or system - FROM_OUTSIDE = 1 # tracks what is received via an unknown source - TO_OUTSIDE = 2 # tracks what is expelled via an unknown source + IMPORTED = 1 # tracks what is received via an unknown source + EXPORTED = 2 # tracks what is expelled via an unknown source # These connection types require services. May be on a property or system - FROM_SERVICE_TO_PATRON = 3 # tracks what is received via my service - FROM_PATRON_TO_SERVICE = 4 # tracks what is expelled via my service + RECEIVING_SERVICE = 3 # tracks what is received via my service + RETURNING_TO_SERVICE = 4 # tracks what is expelled via my service # These connection types require services and may only be on Systems - TOTAL_FROM_PATRON = 5 # tracks everything that this system expelled via my service - TOTAL_TO_PATRON = 6 # tracks everything that this system received via my service + TOTAL_FROM_USERS = 5 # tracks everything that this system expelled via my service + TOTAL_TO_USERS = 6 # tracks everything that this system received via my service CONNECTION_TYPES = ( - (FROM_OUTSIDE, "From Outside"), - (TO_OUTSIDE, "To Outside"), - (FROM_SERVICE_TO_PATRON, "From Service To Patron"), - (FROM_PATRON_TO_SERVICE, "From Patron To Service"), - (TOTAL_FROM_PATRON, "Total From Patron"), - (TOTAL_TO_PATRON, "Total To Patron"), + (IMPORTED, "Imported"), + (EXPORTED, "Exported"), + (RECEIVING_SERVICE, "Receiving Service"), + (RETURNING_TO_SERVICE, "Returning To Service"), + (TOTAL_FROM_USERS, "Total From Users"), + (TOTAL_TO_USERS, "Total To Users"), ) COAL_ANTHRACITE = 1 @@ -165,7 +165,7 @@ class Meter(models.Model): type = models.IntegerField(choices=ENERGY_TYPES, default=None, null=True) service = models.ForeignKey("Service", on_delete=models.SET_NULL, related_name="meters", null=True, blank=True) - connection_type = models.IntegerField(choices=CONNECTION_TYPES, default=FROM_OUTSIDE, null=False) + connection_type = models.IntegerField(choices=CONNECTION_TYPES, default=IMPORTED, null=False) def copy_readings(self, source_meter, overlaps_possible=True): """ @@ -218,7 +218,7 @@ def presave_meter(sender, instance, **kwargs): if property is not None and system is not None: raise IntegrityError(f"Meter {instance.id} has both a property and a system. It must only have one.") - outside_connection = connection_type in [Meter.FROM_OUTSIDE, Meter.TO_OUTSIDE] + outside_connection = connection_type in [Meter.IMPORTED, Meter.EXPORTED] if outside_connection: # outside connections don't have services if instance.service is not None: @@ -228,7 +228,7 @@ def presave_meter(sender, instance, **kwargs): if service is None: raise IntegrityError(f"Meter {instance.id} has connection_type '{connection_string}', but is not connected to a service") - total_connections = connection_type in [Meter.TOTAL_FROM_PATRON, Meter.TOTAL_TO_PATRON] + total_connections = connection_type in [Meter.TOTAL_FROM_USERS, Meter.TOTAL_TO_USERS] if total_connections: # Only systems have connection type "total" if system is None: @@ -245,7 +245,7 @@ def presave_meter(sender, instance, **kwargs): if Meter.objects.filter(service=service, connection_type=connection_type).exclude(pk=instance.pk).exists(): raise IntegrityError(f"Service {service.id} already has a meter with connection type '{connection_string}'") - elif property: # Meter.FROM_PATRON_TO_SERVICE and Meter.FROM_SERVICE_TO_PATRON + elif property: # Meter.RETURNING_TO_SERVICE and Meter.RECEIVING_SERVICE # service must be within the meter's property's group property_groups = InventoryGroupMapping.objects.filter(property=property).values_list("group_id", flat=True) if service is not None and service.system.group.id not in property_groups: diff --git a/seed/models/systems.py b/seed/models/systems.py index 1d4964fbdc..6cdf0ffae4 100644 --- a/seed/models/systems.py +++ b/seed/models/systems.py @@ -4,10 +4,16 @@ """ from django.db import models +from django.db.models import Q from model_utils.managers import InheritanceManager +from quantityfield.fields import QuantityField +from quantityfield.units import ureg from seed.models import InventoryGroup +ureg.define("MMBtu = 1e6 * Btu") +ureg.define("Ton = 12000 * Btu / hour") + class System(models.Model): name = models.CharField(max_length=255) @@ -32,9 +38,17 @@ class DESSystem(System): (CHP, "CHP"), ) type = models.IntegerField(choices=DES_TYPES, null=False) - capacity = models.IntegerField(null=False) + heating_capacity = QuantityField("MMBtu", null=True) + cooling_capacity = QuantityField("Ton", null=True) count = models.IntegerField(default=1, null=False) + class Meta: + constraints = [ + models.CheckConstraint( + check=Q(heating_capacity__isnull=False) | Q(cooling_capacity__isnull=False), name="heating_or_cooling_capacity_required" + ) + ] + class EVSESystem(System): LEVEL1 = 0 @@ -47,20 +61,22 @@ class EVSESystem(System): (LEVEL3, "Level3-DC Fast"), ) type = models.IntegerField(choices=EVSE_TYPES, null=False) - power = models.IntegerField(null=False) + power = QuantityField("kW", null=False) + voltage = QuantityField("V", null=False) count = models.IntegerField(default=1, null=False) class BatterySystem(System): - efficiency = models.IntegerField(null=False) - capacity = models.IntegerField(null=False) - voltage = models.IntegerField(null=False) + efficiency = models.FloatField(null=False) + power_capacity = QuantityField("kW", null=False) + energy_capacity = QuantityField("kWh", null=False) + voltage = QuantityField("V", null=False) class Service(models.Model): system = models.ForeignKey(System, on_delete=models.CASCADE, related_name="services") name = models.CharField(max_length=255) - emission_factor = models.IntegerField(null=True) + emission_factor = models.FloatField(null=True) objects = InheritanceManager() diff --git a/seed/serializers/meters.py b/seed/serializers/meters.py index cc8434a774..e323d6a4bf 100644 --- a/seed/serializers/meters.py +++ b/seed/serializers/meters.py @@ -47,15 +47,12 @@ def validate_scenario_id(self, scenario_id): def to_representation(self, obj): result = super().to_representation(obj) - if obj.source == Meter.GREENBUTTON: result["source_id"] = usage_point_id(obj.source_id) - result["scenario_name"] = obj.scenario.name if obj.scenario else None - if obj.alias is None or obj.alias == "": result["alias"] = f"{obj.get_type_display()} - {obj.get_source_display()} - {result['source_id']}" - + self.get_property_display_name(obj, result) self.set_config(obj, result) return result @@ -63,12 +60,12 @@ def to_representation(self, obj): def set_config(self, obj, result): # generate config for meter modal connection_lookup = { - 1: {"direction": "inflow", "use": "outside", "connection": "outside"}, - 2: {"direction": "outflow", "use": "outside", "connection": "outside"}, - 3: {"direction": "inflow", "use": "using", "connection": "service"}, - 4: {"direction": "outflow", "use": "using", "connection": "service"}, - 5: {"direction": "inflow", "use": "offering", "connection": "service"}, - 6: {"direction": "outflow", "use": "offering", "connection": "service"}, + 1: {"direction": "imported", "use": "outside", "connection": "outside"}, + 2: {"direction": "exported", "use": "outside", "connection": "outside"}, + 3: {"direction": "imported", "use": "using", "connection": "service"}, + 4: {"direction": "exported", "use": "using", "connection": "service"}, + 5: {"direction": "imported", "use": "offering", "connection": "service"}, + 6: {"direction": "exported", "use": "offering", "connection": "service"}, } group_id, system_id = None, None @@ -80,3 +77,9 @@ def set_config(self, obj, result): config = {"group_id": group_id, "system_id": system_id, "service_id": obj.service_id, **connection_lookup[obj.connection_type]} result["config"] = config + + def get_property_display_name(self, obj, result): + if obj.property: + state = obj.property.views.first().state + property_display_field = state.organization.property_display_field + result["property_display_field"] = getattr(state, property_display_field, "Unknown") diff --git a/seed/serializers/pint.py b/seed/serializers/pint.py index b6347836e3..54287d4cbe 100644 --- a/seed/serializers/pint.py +++ b/seed/serializers/pint.py @@ -76,7 +76,8 @@ def collapse_unit(org, x): if isinstance(x, ureg.Quantity): dimensionality = get_dimensionality(x) - pint_spec = pint_specs[dimensionality] + # default to quantity's units if not found + pint_spec = pint_specs.get(dimensionality, x.units) converted_value = x.to(pint_spec).magnitude return round(converted_value, org.display_decimal_places) elif isinstance(x, list): diff --git a/seed/serializers/systems.py b/seed/serializers/systems.py index 33b6a27a39..f15d663d82 100644 --- a/seed/serializers/systems.py +++ b/seed/serializers/systems.py @@ -11,6 +11,7 @@ from seed.data_importer.utils import usage_point_id from seed.models import BatterySystem, DESSystem, EVSESystem, Service, System from seed.serializers.base import ChoiceField +from seed.serializers.pint import collapse_unit class ServiceSerializer(serializers.ModelSerializer): @@ -70,63 +71,62 @@ class Meta: fields = ["id", "name", "services", "type", "group_id"] def validate(self, data): - SystemClass = self.Meta.model id = self.instance.id if self.instance else None - if SystemClass.objects.filter(name=data.get("name"), group=data.get("group_id")).exclude(id=id).exists(): + if System.objects.filter(name=data.get("name"), group=data.get("group_id")).exclude(id=id).exists(): raise serializers.ValidationError("System name must be unique within group") return data class DESSystemSerializer(SystemSerializer): des_type = ChoiceField(source="type", choices=DESSystem.DES_TYPES) - capacity = serializers.IntegerField() - count = serializers.IntegerField() class Meta: model = DESSystem - fields = [*SystemSerializer.Meta.fields, "des_type", "capacity", "count"] + fields = [*SystemSerializer.Meta.fields, "cooling_capacity", "count", "des_type", "heating_capacity"] def to_representation(self, obj): + org = obj.group.organization + mode = "Cooling" if obj.cooling_capacity else "Heating" return { "type": "DES", "des_type": obj.get_type_display(), - "capacity": obj.capacity, + "cooling_capacity": collapse_unit(org, obj.cooling_capacity), "count": obj.count, + "heating_capacity": collapse_unit(org, obj.heating_capacity), + "mode": mode, } class EVSESystemSerializer(SystemSerializer): evse_type = ChoiceField(source="type", choices=EVSESystem.EVSE_TYPES) - power = serializers.IntegerField() - count = serializers.IntegerField() class Meta: model = EVSESystem - fields = [*SystemSerializer.Meta.fields, "evse_type", "power", "count"] + fields = [*SystemSerializer.Meta.fields, "count", "evse_type", "power", "voltage"] def to_representation(self, obj): + org = obj.group.organization return { "type": "EVSE", "evse_type": obj.get_type_display(), - "power": obj.power, - "count": obj.count, + "power": collapse_unit(org, obj.power), + "voltage": collapse_unit(org, obj.voltage), + "count": collapse_unit(org, obj.count), } class BatterySystemSerializer(SystemSerializer): - efficiency = serializers.IntegerField() - capacity = serializers.IntegerField() - voltage = serializers.IntegerField() - class Meta: model = BatterySystem - fields = [*SystemSerializer.Meta.fields, "efficiency", "capacity", "voltage"] + fields = [*SystemSerializer.Meta.fields, "energy_capacity", "power_capacity", "efficiency", "voltage"] def to_representation(self, obj): + org = obj.group.organization return { "type": "Battery", "efficiency": obj.efficiency, - "capacity": obj.capacity, - "voltage": obj.voltage, + "energy_capacity": collapse_unit(org, obj.energy_capacity), + "power_capacity": collapse_unit(org, obj.power_capacity), + "voltage": collapse_unit(org, obj.voltage), } diff --git a/seed/static/seed/js/controllers/inventory_detail_meters_controller.js b/seed/static/seed/js/controllers/inventory_detail_meters_controller.js index 747afc21f2..628ca16122 100644 --- a/seed/static/seed/js/controllers/inventory_detail_meters_controller.js +++ b/seed/static/seed/js/controllers/inventory_detail_meters_controller.js @@ -71,9 +71,9 @@ angular.module('SEED.controller.inventory_detail_meters', []).controller('invent // dont show edit if disabled? const buttons = ( '