diff --git a/archetypal/schedule.py b/archetypal/schedule.py index 9f9050a7d..b124e9be9 100644 --- a/archetypal/schedule.py +++ b/archetypal/schedule.py @@ -1572,7 +1572,7 @@ def _repr_svg_(self): fig.savefig(f, format="svg") return f.getvalue() - def combine(self, other, weights=None, quantity=None): + def combine(self, other, weights=None, quantity=None, **kwargs): """Combine two schedule objects together. Args: @@ -1614,7 +1614,7 @@ def combine(self, other, weights=None, quantity=None): # the new object's name name = "+".join([self.Name, other.Name]) - new_obj = self.__class__(name, value=new_values) + new_obj = self.__class__(name, value=new_values, **kwargs) return new_obj diff --git a/archetypal/template/building_template.py b/archetypal/template/building_template.py index b79dc1e5c..8cdb4ea86 100644 --- a/archetypal/template/building_template.py +++ b/archetypal/template/building_template.py @@ -20,7 +20,7 @@ from archetypal.template.materials.material_layer import MaterialLayer from archetypal.template.schedule import YearSchedulePart from archetypal.template.structure import MassRatio, StructureInformation -from archetypal.template.umi_base import UmiBase +from archetypal.template.umi_base import UmiBase, umibase_property from archetypal.template.window_setting import WindowSetting from archetypal.template.zonedefinition import ZoneDefinition from archetypal.utils import log, reduce @@ -31,7 +31,6 @@ class BuildingTemplate(UmiBase): .. image:: ../images/template/buildingtemplate.png """ - _CREATED_OBJECTS = [] __slots__ = ( "_partition_ratio", @@ -102,10 +101,6 @@ def __init__( super(BuildingTemplate, self).__init__(Name, **kwargs) self.PartitionRatio = PartitionRatio self.Lifespan = Lifespan - self.Core = Core - self.Perimeter = Perimeter - self.Structure = Structure - self.Windows = Windows self.DefaultWindowToWallRatio = DefaultWindowToWallRatio self._year_from = YearFrom # set privately to allow validation self.YearTo = YearTo @@ -114,56 +109,49 @@ def __init__( self.Authors = Authors if Authors else [] self.AuthorEmails = AuthorEmails if AuthorEmails else [] self.Version = Version + # Set UmiBase Properties after standard properties + self.Core = Core + self.Perimeter = Perimeter + self.Structure = Structure + self.Windows = Windows # Only at the end append self to _CREATED_OBJECTS self._CREATED_OBJECTS.append(self) - @property + @umibase_property(type_of_property=ZoneDefinition) def Perimeter(self): """Get or set the perimeter ZoneDefinition.""" return self._perimeter @Perimeter.setter def Perimeter(self, value): - assert isinstance( - value, ZoneDefinition - ), f"Expected a ZoneDefinition, not {type(value)}" self._perimeter = value - @property + @umibase_property(type_of_property=ZoneDefinition) def Core(self): """Get or set the core ZoneDefinition.""" return self._core @Core.setter def Core(self, value): - assert isinstance( - value, ZoneDefinition - ), f"Expected a ZoneDefinition, not {type(value)}" self._core = value - @property + @umibase_property(type_of_property=StructureInformation) def Structure(self): """Get or set the StructureInformation.""" return self._structure_definition @Structure.setter def Structure(self, value): - assert isinstance( - value, StructureInformation - ), f"Expected a StructureInformation, not {type(value)}" self._structure_definition = value - @property + @umibase_property(type_of_property=WindowSetting) def Windows(self): """Get or set the WindowSetting.""" return self._window_setting @Windows.setter def Windows(self, value): - assert isinstance( - value, WindowSetting - ), f"Expected a WindowSetting, not {type(value)}" self._window_setting = value @property @@ -332,12 +320,40 @@ def from_dict( perim = zone_definitions[data.pop("Perimeter")["$ref"]] structure = structure_definitions[data.pop("Structure")["$ref"]] window_data = data.pop("Windows") + + window = None try: - window = window_settings[window_data["$ref"]] + window_ref = window_data["$ref"] + window = window_settings[window_ref] except KeyError: - window = WindowSetting.from_dict( - window_data, schedules, window_constructions - ) + try: + window = window_settings[window_data["$id"]] + except KeyError: + # If the window id changed because of preserve_ids, look for + # the matching window in window_settings + try: + window = next( + iter( + [ + _window + for _window in window_settings.values() + if _window.id == window_data["$id"] + ] + ) + ) + except StopIteration: + # then look in all objects + for obj in UmiBase.all_objects_of_type(WindowSetting): + if obj.id == window_data["$id"]: + obj_data = obj.to_dict() + if obj_data == window_data: + window = obj + break + # Create the window if it does not yet exist + if window is None: + window = WindowSetting.from_dict( + window_data, schedules, window_constructions + ) return cls( Core=core, @@ -623,7 +639,7 @@ def get_ref(self, ref): iter( [ value - for value in BuildingTemplate.CREATED_OBJECTS + for value in UmiBase.all_objects_of_type(BuildingTemplate) if value.id == ref["$ref"] ] ), diff --git a/archetypal/template/conditioning.py b/archetypal/template/conditioning.py index c2dc553e9..95f04615a 100644 --- a/archetypal/template/conditioning.py +++ b/archetypal/template/conditioning.py @@ -13,7 +13,7 @@ from archetypal.reportdata import ReportData from archetypal.template.schedule import UmiSchedule -from archetypal.template.umi_base import UmiBase +from archetypal.template.umi_base import UmiBase, umibase_property from archetypal.utils import float_round, log @@ -88,7 +88,6 @@ class ZoneConditioning(UmiBase): .. image:: ../images/template/zoninfo-conditioning.png """ - _CREATED_OBJECTS = [] __slots__ = ( "_cooling_setpoint", @@ -259,9 +258,6 @@ def __init__( :class:`archetypal.template.UmiBase` """ super(ZoneConditioning, self).__init__(Name, **kwargs) - self.MechVentSchedule = MechVentSchedule - self.HeatingSchedule = HeatingSchedule - self.CoolingSchedule = CoolingSchedule self.CoolingCoeffOfPerf = CoolingCoeffOfPerf self.CoolingLimitType = CoolingLimitType self.CoolingFuelType = CoolingFuelType @@ -286,6 +282,11 @@ def __init__( self.area = area + # set UmiBase Properties after standard properties + self.MechVentSchedule = MechVentSchedule + self.HeatingSchedule = HeatingSchedule + self.CoolingSchedule = CoolingSchedule + # Only at the end append self to _CREATED_OBJECTS self._CREATED_OBJECTS.append(self) @@ -389,18 +390,13 @@ def IsHeatingOn(self, value): ) self._is_heating_on = value - @property + @umibase_property(type_of_property=UmiSchedule) def HeatingSchedule(self): """Get or set the heating availability schedule.""" return self._heating_schedule @HeatingSchedule.setter def HeatingSchedule(self, value): - if value is not None: - assert isinstance(value, UmiSchedule), ( - f"Input error with value {value}. HeatingSchedule must " - f"be an UmiSchedule, not a {type(value)}" - ) self._heating_schedule = value @property @@ -469,18 +465,13 @@ def IsCoolingOn(self, value): ) self._is_cooling_on = value - @property + @umibase_property(type_of_property=UmiSchedule) def CoolingSchedule(self): """Get or set the cooling availability schedule.""" return self._cooling_schedule @CoolingSchedule.setter def CoolingSchedule(self, value): - if value is not None: - assert isinstance(value, UmiSchedule), ( - f"Input error with value {value}. CoolingSchedule must " - f"be an UmiSchedule, not a {type(value)}" - ) self._cooling_schedule = value @property @@ -571,18 +562,13 @@ def EconomizerType(self, value): elif isinstance(value, EconomizerTypes): self._economizer_type = value - @property + @umibase_property(type_of_property=UmiSchedule) def MechVentSchedule(self): """Get or set the outdoor air requirements over time.""" return self._mech_vent_schedule @MechVentSchedule.setter def MechVentSchedule(self, value): - if value is not None: - assert isinstance(value, UmiSchedule), ( - f"Input error with value {value}. MechVentSchedule must " - f"be an UmiSchedule, not a {type(value)}" - ) self._mech_vent_schedule = value @property @@ -1390,7 +1376,7 @@ def _get_cop(zone, energy_in_list, energy_out_variable_name): return cop - def combine(self, other, weights=None): + def combine(self, other, weights=None, **kwargs): """Combine two ZoneConditioning objects together. Args: @@ -1475,7 +1461,7 @@ def combine(self, other, weights=None): ) # create a new object with the previous attributes new_obj = self.__class__( - **meta, **new_attr, allow_duplicates=self.allow_duplicates + **meta, **new_attr, allow_duplicates=self.allow_duplicates, **kwargs ) new_obj.predecessors.update(self.predecessors + other.predecessors) return new_obj diff --git a/archetypal/template/constructions/base_construction.py b/archetypal/template/constructions/base_construction.py index ab4df846e..5e2f47773 100644 --- a/archetypal/template/constructions/base_construction.py +++ b/archetypal/template/constructions/base_construction.py @@ -14,7 +14,7 @@ from archetypal.template.materials import GasMaterial from archetypal.template.materials.gas_layer import GasLayer from archetypal.template.materials.material_layer import MaterialLayer -from archetypal.template.umi_base import UmiBase +from archetypal.template.umi_base import UmiBase, UmiBaseList class ConstructionBase(UmiBase): @@ -158,6 +158,7 @@ def __init__(self, Name, Layers, **kwargs): constructor. """ super(LayeredConstruction, self).__init__(Name, **kwargs) + self._layers = UmiBaseList(self, "Layers") self.Layers = Layers @property @@ -178,7 +179,7 @@ def Layers(self, value): assert isinstance( value[-1], MaterialLayer ), "The inside layer cannot be a GasLayer" - self._layers = value + self.Layers.relink_list(value) @property def r_value(self): @@ -326,11 +327,16 @@ def __copy__(self): """Create a copy of self.""" return self.__class__(Name=self.Name, Layers=self.Layers) + def __hash__(self): + """Return the hash value of self.""" + return hash(self.id) + def __eq__(self, other): """Assert self is equivalent to other.""" - return isinstance(other, LayeredConstruction) and all( - [self.Layers == other.Layers] - ) + if not isinstance(other, LayeredConstruction): + return NotImplemented + else: + return all([self.Layers == other.Layers]) @property def children(self): diff --git a/archetypal/template/constructions/opaque_construction.py b/archetypal/template/constructions/opaque_construction.py index cbf4691f7..1d8881143 100644 --- a/archetypal/template/constructions/opaque_construction.py +++ b/archetypal/template/constructions/opaque_construction.py @@ -31,8 +31,6 @@ class OpaqueConstruction(LayeredConstruction): * solar_reflectance_index """ - _CREATED_OBJECTS = [] - __slots__ = ("area",) def __init__(self, Name, Layers, **kwargs): @@ -167,7 +165,7 @@ def infer_insulation_layer(self): """Return the material layer index that corresponds to the insulation layer.""" return self.Layers.index(max(self.Layers, key=lambda x: x.r_value)) - def combine(self, other, method="dominant_wall", allow_duplicates=False): + def combine(self, other, method="dominant_wall", allow_duplicates=False, **kwargs): """Combine two OpaqueConstruction together. Args: @@ -217,7 +215,7 @@ def combine(self, other, method="dominant_wall", allow_duplicates=False): ) # layers for the new OpaqueConstruction layers = [MaterialLayer(mat, t) for mat, t in zip(new_m, new_t)] - new_obj = self.__class__(**meta, Layers=layers) + new_obj = self.__class__(**meta, Layers=layers, **kwargs) new_name = ( "Combined Opaque Construction {{{}}} with u_value " "of {:,.3f} W/m2k".format(uuid.uuid1(), new_obj.u_value) diff --git a/archetypal/template/constructions/window_construction.py b/archetypal/template/constructions/window_construction.py index 220d85ed2..4f134cff3 100644 --- a/archetypal/template/constructions/window_construction.py +++ b/archetypal/template/constructions/window_construction.py @@ -63,8 +63,6 @@ class WindowConstruction(LayeredConstruction): .. image:: ../images/template/constructions-window.png """ - _CREATED_OBJECTS = [] - _CATEGORIES = ("single", "double", "triple", "quadruple") __slots__ = ("_category",) @@ -360,7 +358,7 @@ def mapping(self, validate=False): Name=self.Name, ) - def combine(self, other, weights=None): + def combine(self, other, weights=None, **kwargs): """Append other to self. Return self + other as a new object. For now, simply returns self. diff --git a/archetypal/template/dhw.py b/archetypal/template/dhw.py index d6966a482..ad79d7fd4 100644 --- a/archetypal/template/dhw.py +++ b/archetypal/template/dhw.py @@ -10,7 +10,7 @@ from archetypal import settings from archetypal.template.schedule import UmiSchedule -from archetypal.template.umi_base import UmiBase +from archetypal.template.umi_base import UmiBase, umibase_property from archetypal.utils import log, reduce, timeit @@ -19,7 +19,6 @@ class DomesticHotWaterSetting(UmiBase): .. image:: ../images/template/zoneinfo-dhw.png """ - _CREATED_OBJECTS = [] __slots__ = ( "_flow_rate_per_floor_area", @@ -59,8 +58,9 @@ def __init__( self.IsOn = IsOn self.WaterSupplyTemperature = WaterSupplyTemperature self.WaterTemperatureInlet = WaterTemperatureInlet - self.WaterSchedule = WaterSchedule self.area = area + # Set UmiBase Properties after standard properties + self.WaterSchedule = WaterSchedule # Only at the end append self to _CREATED_OBJECTS self._CREATED_OBJECTS.append(self) @@ -87,18 +87,13 @@ def IsOn(self, value): ) self._is_on = value - @property + @umibase_property(type_of_property=UmiSchedule) def WaterSchedule(self): """Get or set the schedule which modulates the FlowRatePerFloorArea.""" return self._water_schedule @WaterSchedule.setter def WaterSchedule(self, value): - if value is not None: - assert isinstance(value, UmiSchedule), ( - f"Input error with value {value}. WaterSchedule must " - f"be an UmiSchedule, not a {type(value)}" - ) self._water_schedule = value @property @@ -408,6 +403,7 @@ def combine(self, other, **kwargs): ), area=self.area + other.area, **meta, + **kwargs, ) new_obj.predecessors.update(self.predecessors + other.predecessors) return new_obj diff --git a/archetypal/template/load.py b/archetypal/template/load.py index fad07a3e7..b541b17af 100644 --- a/archetypal/template/load.py +++ b/archetypal/template/load.py @@ -13,7 +13,7 @@ from archetypal import settings from archetypal.template.schedule import UmiSchedule -from archetypal.template.umi_base import UmiBase +from archetypal.template.umi_base import UmiBase, umibase_property from archetypal.utils import log, reduce, timeit @@ -42,7 +42,6 @@ class ZoneLoad(UmiBase): .. image:: ../images/template/zoneinfo-loads.png """ - _CREATED_OBJECTS = [] __slots__ = ( "_dimming_type", @@ -119,11 +118,8 @@ def __init__( super(ZoneLoad, self).__init__(Name, **kwargs) self.EquipmentPowerDensity = EquipmentPowerDensity - self.EquipmentAvailabilitySchedule = EquipmentAvailabilitySchedule self.LightingPowerDensity = LightingPowerDensity - self.LightsAvailabilitySchedule = LightsAvailabilitySchedule self.PeopleDensity = PeopleDensity - self.OccupancySchedule = OccupancySchedule self.IsEquipmentOn = IsEquipmentOn self.IsLightingOn = IsLightingOn self.IsPeopleOn = IsPeopleOn @@ -131,6 +127,10 @@ def __init__( self.IlluminanceTarget = IlluminanceTarget self.area = area self.volume = volume + # Set UmiBase Properties after standard properties + self.EquipmentAvailabilitySchedule = EquipmentAvailabilitySchedule + self.LightsAvailabilitySchedule = LightsAvailabilitySchedule + self.OccupancySchedule = OccupancySchedule # Only at the end append self to _CREATED_OBJECTS self._CREATED_OBJECTS.append(self) @@ -165,7 +165,7 @@ def DimmingType(self, value): else: raise ValueError(f"Could not set DimmingType with value '{value}'") - @property + @umibase_property(type_of_property=UmiSchedule) def EquipmentAvailabilitySchedule(self): """Get or set the equipment availability schedule.""" return self._equipment_availability_schedule @@ -173,11 +173,6 @@ def EquipmentAvailabilitySchedule(self): @EquipmentAvailabilitySchedule.setter def EquipmentAvailabilitySchedule(self, value): if value is not None: - assert isinstance(value, UmiSchedule), ( - f"Input value error for '{value}'. Value must be of type '" - f"{UmiSchedule}', not {type(value)}" - ) - # set quantity on schedule as well value.quantity = self.EquipmentPowerDensity self._equipment_availability_schedule = value @@ -212,7 +207,7 @@ def LightingPowerDensity(self, value): value, minimum=0, allow_empty=True ) - @property + @umibase_property(type_of_property=UmiSchedule) def LightsAvailabilitySchedule(self) -> UmiSchedule: """Get or set the lights availability schedule.""" return self._lights_availability_schedule @@ -220,15 +215,11 @@ def LightsAvailabilitySchedule(self) -> UmiSchedule: @LightsAvailabilitySchedule.setter def LightsAvailabilitySchedule(self, value): if value is not None: - assert isinstance(value, UmiSchedule), ( - f"Input value error for '{value}'. Value must be of type '" - f"{UmiSchedule}', not {type(value)}" - ) # set quantity on schedule as well value.quantity = self.LightingPowerDensity self._lights_availability_schedule = value - @property + @umibase_property(type_of_property=UmiSchedule) def OccupancySchedule(self) -> UmiSchedule: """Get or set the occupancy schedule.""" return self._occupancy_schedule @@ -236,11 +227,6 @@ def OccupancySchedule(self) -> UmiSchedule: @OccupancySchedule.setter def OccupancySchedule(self, value): if value is not None: - assert isinstance(value, UmiSchedule), ( - f"Input value error for '{value}'. Value must be if type '" - f"{UmiSchedule}', not {type(value)}" - ) - # set quantity on schedule as well value.quantity = self.PeopleDensity self._occupancy_schedule = value @@ -509,7 +495,7 @@ def get_schedule(series): ) return z_load - def combine(self, other, weights=None): + def combine(self, other, weights=None, **kwargs): """Combine two ZoneLoad objects together. Returns a new object. Args: @@ -587,6 +573,7 @@ def combine(self, other, weights=None): IsLightingOn=any([self.IsLightingOn, other.IsLightingOn]), IsPeopleOn=any([self.IsPeopleOn, other.IsPeopleOn]), PeopleDensity=self.float_mean(other, "PeopleDensity", weights), + **kwargs, ) new_obj = self.__class__( diff --git a/archetypal/template/materials/gas_layer.py b/archetypal/template/materials/gas_layer.py index 929810a23..906bf3ae2 100644 --- a/archetypal/template/materials/gas_layer.py +++ b/archetypal/template/materials/gas_layer.py @@ -7,9 +7,10 @@ from validator_collection import validators from archetypal.utils import log +from archetypal.template.umi_base import UmiBaseHelper -class GasLayer(object): +class GasLayer(UmiBaseHelper, object): """Class used to define one gas layer in a window construction assembly. This class has two attributes: @@ -28,6 +29,7 @@ def __init__(self, Material, Thickness, **kwargs): Thickness (float): The thickness of the material in the construction. """ + super(GasLayer, self).__init__(umi_base_property="Material") self.Material = Material self.Thickness = Thickness diff --git a/archetypal/template/materials/gas_material.py b/archetypal/template/materials/gas_material.py index 045b18e97..2f77cdb8b 100644 --- a/archetypal/template/materials/gas_material.py +++ b/archetypal/template/materials/gas_material.py @@ -6,6 +6,7 @@ from sigfig import round from validator_collection import validators +from archetypal.template.umi_base import UmiBase from .material_base import MaterialBase @@ -14,7 +15,6 @@ class GasMaterial(MaterialBase): .. image:: ../images/template/materials-gas.png """ - _CREATED_OBJECTS = [] __slots__ = ("_type", "_conductivity", "_density") @@ -42,6 +42,7 @@ def __init__( # Only at the end append self to _CREATED_OBJECTS self._CREATED_OBJECTS.append(self) + UmiBase._GRAPH.add_node(self) @property def Name(self): diff --git a/archetypal/template/materials/glazing_material.py b/archetypal/template/materials/glazing_material.py index 7bb3f5dc2..df8ade33b 100644 --- a/archetypal/template/materials/glazing_material.py +++ b/archetypal/template/materials/glazing_material.py @@ -17,7 +17,6 @@ class GlazingMaterial(MaterialBase): .. image:: ../images/template/materials-glazing.png """ - _CREATED_OBJECTS = [] __slots__ = ( "_ir_emissivity_back", @@ -107,6 +106,7 @@ def __init__( # Only at the end append self to _CREATED_OBJECTS self._CREATED_OBJECTS.append(self) + UmiBase._GRAPH.add_node(self) @property def Conductivity(self): @@ -227,7 +227,7 @@ def SolarTransmittance(self): def SolarTransmittance(self, value): self._solar_transmittance = validators.float(value, False, 0.0, 1.0) - def combine(self, other, weights=None, allow_duplicates=False): + def combine(self, other, weights=None, allow_duplicates=False, **kwargs): """Combine two GlazingMaterial objects together. Args: @@ -279,7 +279,7 @@ def combine(self, other, weights=None, allow_duplicates=False): [new_attr.pop(key, None) for key in meta.keys()] # meta handles these # keywords. # create a new object from combined attributes - new_obj = self.__class__(**meta, **new_attr) + new_obj = self.__class__(**meta, **new_attr, **kwargs) new_obj.predecessors.update(self.predecessors + other.predecessors) return new_obj diff --git a/archetypal/template/materials/material_layer.py b/archetypal/template/materials/material_layer.py index 5646c0bbf..4c7ad7777 100644 --- a/archetypal/template/materials/material_layer.py +++ b/archetypal/template/materials/material_layer.py @@ -8,9 +8,10 @@ from validator_collection import validators from archetypal.utils import log +from archetypal.template.umi_base import UmiBaseHelper -class MaterialLayer(object): +class MaterialLayer(UmiBaseHelper, object): """Class used to define one layer in a construction assembly. This class has two attributes: @@ -30,6 +31,7 @@ def __init__(self, Material, Thickness, **kwargs): Thickness (float): The thickness of the material in the construction. """ + super(MaterialLayer, self).__init__(umi_base_property="Material") self.Material = Material self.Thickness = Thickness diff --git a/archetypal/template/materials/nomass_material.py b/archetypal/template/materials/nomass_material.py index ea4b9564b..364b41663 100644 --- a/archetypal/template/materials/nomass_material.py +++ b/archetypal/template/materials/nomass_material.py @@ -6,6 +6,7 @@ from sigfig import round from validator_collection import validators +from archetypal.template.umi_base import UmiBase from archetypal.template import GasMaterial from archetypal.template.materials.material_base import MaterialBase from archetypal.utils import log @@ -14,8 +15,6 @@ class NoMassMaterial(MaterialBase): """Use this component to create a custom no mass material.""" - _CREATED_OBJECTS = [] - _ROUGHNESS_TYPES = ( "VeryRough", "Rough", @@ -81,6 +80,7 @@ def __init__( # Only at the end append self to _CREATED_OBJECTS self._CREATED_OBJECTS.append(self) + UmiBase._GRAPH.add_node(self) @property def r_value(self): @@ -153,7 +153,7 @@ def MoistureDiffusionResistance(self): def MoistureDiffusionResistance(self, value): self._moisture_diffusion_resistance = validators.float(value, minimum=0) - def combine(self, other, weights=None, allow_duplicates=False): + def combine(self, other, weights=None, allow_duplicates=False, **kwargs): """Combine two OpaqueMaterial objects. Args: @@ -208,6 +208,7 @@ def combine(self, other, weights=None, allow_duplicates=False): MoistureDiffusionResistance=self.float_mean( other, "MoistureDiffusionResistance", weights ), + **kwargs, ) new_obj.predecessors.update(self.predecessors + other.predecessors) return new_obj diff --git a/archetypal/template/materials/opaque_material.py b/archetypal/template/materials/opaque_material.py index abb243f26..578a78093 100644 --- a/archetypal/template/materials/opaque_material.py +++ b/archetypal/template/materials/opaque_material.py @@ -5,6 +5,7 @@ from eppy.bunch_subclass import EpBunch from validator_collection import validators +from archetypal.template.umi_base import UmiBase from archetypal.template.materials import GasMaterial from archetypal.template.materials.material_base import MaterialBase from archetypal.utils import log, signif @@ -16,8 +17,6 @@ class OpaqueMaterial(MaterialBase): .. image:: ../images/template/materials-opaque.png """ - _CREATED_OBJECTS = [] - _ROUGHNESS_TYPES = ( "VeryRough", "Rough", @@ -125,6 +124,7 @@ def __init__( # Only at the end append self to _CREATED_OBJECTS self._CREATED_OBJECTS.append(self) + UmiBase._GRAPH.add_node(self) @property def Conductivity(self): @@ -243,7 +243,7 @@ def generic(cls, **kwargs): **kwargs, ) - def combine(self, other, weights=None, allow_duplicates=False): + def combine(self, other, weights=None, allow_duplicates=False, **kwargs): """Combine two OpaqueMaterial objects. Args: @@ -300,6 +300,7 @@ def combine(self, other, weights=None, allow_duplicates=False): MoistureDiffusionResistance=self.float_mean( other, "MoistureDiffusionResistance", weights ), + **kwargs, ) new_obj.predecessors.update(self.predecessors + other.predecessors) return new_obj diff --git a/archetypal/template/schedule.py b/archetypal/template/schedule.py index 93da42919..ef674edee 100644 --- a/archetypal/template/schedule.py +++ b/archetypal/template/schedule.py @@ -11,13 +11,12 @@ from validator_collection import validators from archetypal.schedule import Schedule, _ScheduleParser, get_year_for_first_weekday -from archetypal.template.umi_base import UmiBase +from archetypal.template.umi_base import UmiBase, UmiBaseHelper, UmiBaseList from archetypal.utils import log class UmiSchedule(Schedule, UmiBase): """Class that handles Schedules.""" - _CREATED_OBJECTS = [] __slots__ = ("_quantity",) @@ -88,7 +87,7 @@ def from_values(cls, Name, Values, Type="Fraction", **kwargs): Name=Name, Values=Values, Type=Type, **kwargs ) - def combine(self, other, weights=None, quantity=None): + def combine(self, other, weights=None, quantity=None, **kwargs): """Combine two UmiSchedule objects together. Args: @@ -207,7 +206,7 @@ def combine(self, other, weights=None, quantity=None): [self.quantity or float("nan"), other.quantity or float("nan")] ) new_obj = UmiSchedule.from_values( - Values=new_values, Type="Fraction", quantity=quantity, **meta + Values=new_values, Type="Fraction", quantity=quantity, **meta, **kwargs ) new_obj.predecessors.update(self.predecessors + other.predecessors) new_obj.weights = sum(weights) @@ -275,7 +274,7 @@ def get_ref(self, ref): iter( [ value - for value in UmiSchedule.CREATED_OBJECTS + for value in UmiBase.all_objects_of_type(UmiSchedule) if value.id == ref["$ref"] ] ), @@ -339,7 +338,7 @@ def __copy__(self): ) -class YearSchedulePart: +class YearSchedulePart(UmiBaseHelper, object): """Helper Class for YearSchedules defined with FromDay FromMonth ToDay ToMonth.""" __slots__ = ("_from_day", "_from_month", "_to_day", "_to_month", "_schedule") @@ -368,10 +367,12 @@ def __init__( object. kwargs (dict): Other Keyword arguments. """ + super(YearSchedulePart, self).__init__(umi_base_property="Schedule") self.FromDay = FromDay self.FromMonth = FromMonth self.ToDay = ToDay self.ToMonth = ToMonth + # Set UmiBase Properties after standard properties self.Schedule = Schedule @property @@ -517,6 +518,7 @@ def __init__(self, Name, Values, Category="Day", **kwargs): super(DaySchedule, self).__init__( Category=Category, Name=Name, Values=Values, **kwargs ) + UmiBase._GRAPH.add_node(self) @property def all_values(self) -> np.ndarray: @@ -729,7 +731,9 @@ def __init__(self, Name, Days=None, Category="Week", **kwargs): **kwargs: """ super(WeekSchedule, self).__init__(Name, Category=Category, **kwargs) + self._days = UmiBaseList(self, "Days") self.Days = Days + UmiBase._GRAPH.add_node(self) @property def Days(self): @@ -742,7 +746,7 @@ def Days(self, value): assert all( isinstance(x, DaySchedule) for x in value ), f"Input value error '{value}'. Expected list of DaySchedule." - self._days = value + self.Days.relink_list(value) @classmethod def from_epbunch(cls, epbunch, **kwargs): @@ -922,14 +926,24 @@ def __init__(self, Name, Type="Fraction", Parts=None, Category="Year", **kwargs) Parts (list of YearSchedulePart): The YearScheduleParts. **kwargs: """ + super(YearSchedule, self).__init__( + Name=Name, Type=Type, schType="Schedule:Year", Category=Category, **kwargs + ) self.epbunch = kwargs.get("epbunch", None) + self._parts = UmiBaseList(self, "Parts") if Parts is None: self.Parts = self._get_parts(self.epbunch) else: self.Parts = Parts - super(YearSchedule, self).__init__( - Name=Name, Type=Type, schType="Schedule:Year", Category=Category, **kwargs - ) + UmiBase._GRAPH.add_node(self) + + @property + def Parts(self): + return self._parts + + @Parts.setter + def Parts(self, Parts): + self.Parts.relink_list(Parts) def __eq__(self, other): """Assert self is equivalent to other.""" diff --git a/archetypal/template/structure.py b/archetypal/template/structure.py index 7818e703b..6dd0af1e8 100644 --- a/archetypal/template/structure.py +++ b/archetypal/template/structure.py @@ -6,9 +6,10 @@ from archetypal.template.constructions.base_construction import ConstructionBase from archetypal.template.materials.opaque_material import OpaqueMaterial +from archetypal.template.umi_base import UmiBaseList, UmiBaseHelper -class MassRatio(object): +class MassRatio(UmiBaseHelper, object): """Handles the properties of the mass ratio for building template structure.""" __slots__ = ("_high_load_ratio", "_material", "_normal_ratio") @@ -21,9 +22,11 @@ def __init__(self, HighLoadRatio=None, Material=None, NormalRatio=None, **kwargs Material (OpaqueMaterial): NormalRatio (float): """ + super(MassRatio, self).__init__(umi_base_property="Material") self.HighLoadRatio = HighLoadRatio - self.Material = Material self.NormalRatio = NormalRatio + # Set UmiBase Properties after standard properties + self.Material = Material @property def HighLoadRatio(self): @@ -139,8 +142,6 @@ class StructureInformation(ConstructionBase): .. image:: ../images/template/constructions-structure.png """ - _CREATED_OBJECTS = [] - __slots__ = ("_mass_ratios",) def __init__(self, Name, MassRatios, **kwargs): @@ -151,6 +152,7 @@ def __init__(self, Name, MassRatios, **kwargs): **kwargs: keywords passed to the ConstructionBase constructor. """ super(StructureInformation, self).__init__(Name, **kwargs) + self._mass_ratios = UmiBaseList(self, "MassRatios") self.MassRatios = MassRatios # Only at the end append self to _CREATED_OBJECTS @@ -163,8 +165,10 @@ def MassRatios(self): @MassRatios.setter def MassRatios(self, value): - assert isinstance(value, list), "mass_ratio must be of a list of MassRatio" - self._mass_ratios = value + assert isinstance( + value, (list, UmiBaseList) + ), "mass_ratio must be of a list of MassRatio" + self.MassRatios.relink_list(value) @classmethod def from_dict(cls, data, materials, **kwargs): diff --git a/archetypal/template/umi_base.py b/archetypal/template/umi_base.py index 1ef934e02..883818f04 100644 --- a/archetypal/template/umi_base.py +++ b/archetypal/template/umi_base.py @@ -2,7 +2,7 @@ import itertools import math -import re +import networkx as nx from collections.abc import Hashable, MutableSet import numpy as np @@ -45,6 +45,9 @@ def _shorten_name(long_name): class UmiBase(object): """Base class for template objects.""" + _GRAPH = nx.MultiDiGraph() + _CREATED_OBJECTS_BY_CLASS = {} + __slots__ = ( "_id", "_datasource", @@ -54,6 +57,7 @@ class UmiBase(object): "_comments", "_allow_duplicates", "_unit_number", + "_parents", ) _ids = itertools.count(0) # unique id for each class instance @@ -90,6 +94,7 @@ def __init__( self.allow_duplicates = allow_duplicates self.unit_number = next(self._ids) self.predecessors = None + self._parents = nx.MultiDiGraph() @property def Name(self): @@ -107,6 +112,10 @@ def id(self): @id.setter def id(self, value): + if getattr(self, "id", None): + raise AttributeError( + "The id of an `UmiBase` object cannot change once it has been set." + ) if value is None: value = id(self) self._id = validators.string(value, coerce_value=True) @@ -223,7 +232,7 @@ def combine_meta(self, predecessors): ), } - def combine(self, other, allow_duplicates=False): + def combine(self, other, allow_duplicates=False, **kwargs): pass def rename(self, name): @@ -371,9 +380,7 @@ def extend(self, other, allow_duplicates): if other is None: return self self._CREATED_OBJECTS.remove(self) - id = self.id - new_obj = self.combine(other, allow_duplicates=allow_duplicates) - new_obj.id = id + new_obj = self.combine(other, allow_duplicates=allow_duplicates, id=self.id) for key in self.mapping(validate=False): setattr(self, key, getattr(new_obj, key)) return self @@ -406,15 +413,11 @@ def get_unique(self): # We want to return the first similar object (equality) that has this name. obj = next( iter( - sorted( - ( - x - for x in self._CREATED_OBJECTS - if x == self - and x.Name == self.Name - ), - key=lambda x: x.unit_number, - ) + ( + x + for x in self._CREATED_OBJECTS + if x == self and x.Name == self.Name + ), ), self, ) @@ -423,20 +426,155 @@ def get_unique(self): # name. obj = next( iter( - sorted( - ( - x - for x in self._CREATED_OBJECTS - if x == self - ), - key=lambda x: x.unit_number, - ) + (x for x in self._CREATED_OBJECTS if x == self), ), self, ) return obj + @property + def Parents(self): + """ Get the parents of an UmiBase Object""" + parents = set() + for component in self._parents: + """Don't add self to parents!""" + if component != self and component.id != self.id: + parents.add(component) + return parents + + @property + def ParentTemplates(self): + """Get the parent templates of an UmiBase object""" + templates = set() + # TODO: Compare performanc using direct graph path traversal + # for bt in UmiBase.all_objects_of_type("BuildingTemplates"): + # if nx.has_path(UmiBase._GRAPH, bt, self): + # templates.add(bt) + + for parent in self.Parents: + # Recursive call terminates at Parent Template level, or if self.Parents is empty + if parent.__class__.__name__ == "BuildingTemplate": + templates.add(parent) + else: + templates = templates.union(parent.ParentTemplates) + return templates + + def replace_me_with(self, other, force=False): + # Copy the edge metadata since the edge dict will change while iterating + if self.id == other.id and not force: + return + edges = [ + (parent, _self, key, data) + for parent, _self, key, data in self._parents.edges(data=True, keys=True) + ] + + # Iterate over the edges and replace each key + for (parent, _, key, data) in edges: + # fire the attr setter + if data["meta"] is not None: + meta = data["meta"] + attr = meta["attr"] + index = meta["index"] + umibase_list = getattr(parent, attr) # get the base list + umibase_list[index] = other # fire the setter + else: + parent[key] = other + + def link(self, parent, key, meta=None): + """Link this object as child to a parent + Args: + parent (UmiBase): the parent to link + key (str): the property which this child was used for in the parent and which should be unlinked, or _ if a list element + meta (dict or NoneType): if self is an UmiBaseList element, stores meta stores {"attr": , "index": } + """ + self._parents.add_edge(parent, self, key, meta=meta) + UmiBase._GRAPH.add_edge(parent, self, key, meta=meta) + + def unlink(self, parent, key): + """Unlink this object as a child from a parent + Args: + parent (UmiBase): the parent to unlink + key (str): the property which the child was used for in the parent and which should be unlinked. + """ + if self._parents.has_node(parent): + # Fails silently if edge does not exist + self._parents.remove_edges_from([(parent, self, key)]) + UmiBase._GRAPH.remove_edges_from([(parent, self, key)]) + + if len(self._parents[parent]) == 0: + self._parents.remove_node(parent) + + def relink(self, child, key): + """Parents call this to link to a new child and unlink the old child for an attr + Args: + child (UmiBase): The new child + key (str): The cache value to store in th link, which should match a Property getter + """ + current_child = getattr(self, key, None) + if current_child: + # print(f"link {self} --> {key} --> {child} (replacing: {current_child})") + getattr(self, key).unlink(self, key) + if child is not None: + # print(f"link {self} --> {key} --> {child}") + child.link(self, key) + + @classmethod + def all_objects_of_type(cls, class_to_lookup): + """Returns all objects of a given type + + Args: + class_to_lookup (str or class or UmiBase): The class of objects to lookup + """ + try: + if isinstance(class_to_lookup, str): + try: + return UmiBase._CREATED_OBJECTS_BY_CLASS[class_to_lookup] + except KeyError as e: + return UmiBase._CREATED_OBJECTS_BY_CLASS[ + class_to_lookup[:-1] + ] # Works if an s was appended + elif isinstance(class_to_lookup, UmiBase): + return UmiBase._CREATED_OBJECTS_BY_CLASS[ + class_to_lookup.__class__.__name__ + ] + elif isinstance(class_to_lookup, type): + return UmiBase._CREATED_OBJECTS_BY_CLASS[class_to_lookup.__name__] + except KeyError: + raise ValueError( + f"You must provide a string, class, or object which corresponds to an UmiBase class, and nothing was found for {class_to_lookup}" + ) + + @property + def _CREATED_OBJECTS(self): + return UmiBase.all_objects_of_type(self) + + @classmethod + def all_objects(cls): + all_objects = [] + for _, objects in cls.groups(): + all_objects.extend(objects) + return all_objects + + @classmethod + def groups(cls): + for group, objects in cls._CREATED_OBJECTS_BY_CLASS.items(): + yield group, objects + + @classmethod + def _clear_class_memory(cls): + for group, _ in cls.groups(): + cls._CREATED_OBJECTS_BY_CLASS[group] = [] + cls._GRAPH = nx.MultiDiGraph() + + def __init_subclass__(cls): + """Register subclasses of UmiBase in the dictionary of created objects""" + try: + # If somehow the class has been defined already, skip + UmiBase._CREATED_OBJECTS_BY_CLASS[cls.__name__] + except KeyError: + UmiBase._CREATED_OBJECTS_BY_CLASS[cls.__name__] = [] + class UserSet(Hashable, MutableSet): """UserSet class.""" @@ -527,3 +665,152 @@ def create_unique(cls, name): new_name = f"{name}_{str(new_count)}" cls.existing[name] = new_count return new_name + + +def umibase_property(type_of_property): + """Create a new property decorator which will automatically + configure the property to handle type-checking and parent-graph relinking + Needs to be abstracted into a single class rather than a class generator + + Args: + type_of_property (class inherits UmiBase): which class of UmiBase object the property will store + """ + + class UmiBaseProperty(property): + def __init__(self, getter_func, *args, **kwargs): + super().__init__(getter_func, *args, **kwargs) + self.type_of_property = type_of_property + self.attr_name = getter_func.__name__ + + def __get__(self, obj, owner): + try: + return super().__get__(obj, owner) + except AttributeError: + return None + + def __set__(self, obj, value): + self.type_check(value) + self.relink(obj, value) + super().__set__(obj, value) + + def type_check(self, value): + if value is not None: + assert isinstance(value, self.type_of_property), ( + f"Input value error. {self.attr_name} must be of " + f"type {self.type_of_property}, not {type(value)}." + ) + + def relink(self, obj, value): + obj.relink(value, self.attr_name) + + return UmiBaseProperty + + # def _setter(self, fset): + # obj = super().setter(fset) + # obj.type_of_property = self.type_of_property + # return obj + + # def setter(self, type_of_property): + # self.type_of_property = type_of_property + # return self._setter + + +class UmiBaseHelper: + """Base for Helper classes so that the UmiBase object can be + found and operated on via the helper, e.g. for YearScheduleParts + """ + + __slots__ = "_umi_base_property" + + def __init__(self, umi_base_property): + assert isinstance( + umi_base_property, str + ), "'umi_base_property' must be a string" + self._umi_base_property = umi_base_property + + def __getattr__(self, attr): + umi_base = getattr(self, self._umi_base_property) + return getattr(umi_base, attr) + + +class UmiBaseList: + """This class is a hook for lists so that UmiBase fields which store lists + can link and unlink list elements from a parent attr + """ + + def __init__(self, parent, attr, objects=[]): + assert isinstance(objects, list), "UmiBaseList must be initialized with a list" + assert isinstance( + parent, UmiBase + ), "UmiBaseList's parent must be initialized with an UmiBase object" + assert isinstance(attr, str), "UmiBaseList's attr must be a str" + assert attr in dir( + parent + ), f"UmiBaseLest's attr '{attr}' is not a valid attr of parent {parent}" + self._attr = attr + self._parent = parent + self.link_list(objects) + + def __getitem__(self, index): + return self._objects[index] + + def format_graph_key(self, index): + return f"{self._attr}_{index}" + + def format_edge_meta(self, index): + return {"attr": self._attr, "index": index} + + def __setitem__(self, index, value): + should_insert_into_helper_obj = isinstance( + self[index], UmiBaseHelper + ) and isinstance(value, UmiBase) + if self[index]: + self[index].unlink(self._parent, self.format_graph_key(index)) + if should_insert_into_helper_obj: + setattr(self[index], self[index]._umi_base_property, value) + if not should_insert_into_helper_obj or not self[index]: + self._objects[index] = value + value.link( + self._parent, + self.format_graph_key(index), + meta=self.format_edge_meta(index), + ) + + def __eq__(self, other): + """Check if two UmiBaseLists are equal by iterating through the arrays""" + # TODO: make sure this has no other knock on effects + # By allowing two lists which are not the same obj in memory to be equal + if not isinstance(other, UmiBaseList): + return NotImplemented + else: + if len(self) > len(other): + return False + else: + return all([a == b for a, b in zip(self, other)]) + + def unlink_list(self): + for index, obj in enumerate(self._objects): + obj.unlink(self._parent, self.format_graph_key(index)) + self._objects = [] + + def link_list(self, objects): + self._objects = [None for obj in objects] + for i, obj in enumerate(objects): + self[i] = obj # fire the setter + + def relink_list(self, objects): + if len(self._objects) > 0: + self.unlink_list() + + new_list = objects._objects if isinstance(objects, UmiBaseList) else objects + self.link_list(new_list) + + def __len__(self): + return len(self._objects) + + def __getattr__(self, attr): + """ Provid access to underlying list methods""" + return getattr(self._objects, attr) + + # TODO: implement aliases for underlying list methods which mutate the list to handle + # relinking diff --git a/archetypal/template/ventilation.py b/archetypal/template/ventilation.py index 636075fc3..d0c016cee 100644 --- a/archetypal/template/ventilation.py +++ b/archetypal/template/ventilation.py @@ -10,7 +10,7 @@ from validator_collection import checkers, validators from archetypal.template.schedule import UmiSchedule -from archetypal.template.umi_base import UmiBase +from archetypal.template.umi_base import UmiBase, umibase_property from archetypal.utils import log, timeit, top, weighted_mean @@ -62,7 +62,6 @@ class VentilationSetting(UmiBase): .. image:: ../images/template/zoneinfo-ventilation.png """ - _CREATED_OBJECTS = [] __slots__ = ( "_infiltration", @@ -183,7 +182,6 @@ def __init__( self.Infiltration = Infiltration self.IsInfiltrationOn = IsInfiltrationOn self.IsNatVentOn = IsNatVentOn - self.NatVentSchedule = NatVentSchedule self.IsWindOn = IsWindOn self.IsBuoyancyOn = IsBuoyancyOn self.NatVentMaxOutdoorAirTemp = NatVentMaxOutdoorAirTemp @@ -192,17 +190,22 @@ def __init__( self.NatVentZoneTempSetpoint = NatVentZoneTempSetpoint self.ScheduledVentilationAch = ScheduledVentilationAch self.ScheduledVentilationSetpoint = ScheduledVentilationSetpoint - self.ScheduledVentilationSchedule = ScheduledVentilationSchedule + # prevent validation error + self._scheduled_ventilation_schedule = ScheduledVentilationSchedule self.IsScheduledVentilationOn = IsScheduledVentilationOn + self._scheduled_ventilation_schedule = None self.VentilationType = VentilationType self.Afn = Afn self.area = area self.volume = volume + # Set UmiBase Properties after standard properties + self.ScheduledVentilationSchedule = ScheduledVentilationSchedule + self.NatVentSchedule = NatVentSchedule # Only at the end append self to _CREATED_OBJECTS self._CREATED_OBJECTS.append(self) - @property + @umibase_property(type_of_property=UmiSchedule) def NatVentSchedule(self): """Get or set the natural ventilation schedule. @@ -213,14 +216,9 @@ def NatVentSchedule(self): @NatVentSchedule.setter def NatVentSchedule(self, value): - if value is not None: - assert isinstance(value, UmiSchedule), ( - f"Input error with value {value}. NatVentSchedule must " - f"be an UmiSchedule, not a {type(value)}" - ) self._nat_ventilation_schedule = value - @property + @umibase_property(type_of_property=UmiSchedule) def ScheduledVentilationSchedule(self): """Get or set the scheduled ventilation schedule.""" return self._scheduled_ventilation_schedule @@ -228,10 +226,6 @@ def ScheduledVentilationSchedule(self): @ScheduledVentilationSchedule.setter def ScheduledVentilationSchedule(self, value): if value is not None: - assert isinstance(value, UmiSchedule), ( - f"Input error with value {value}. ScheduledVentilationSchedule must " - f"be an UmiSchedule, not a {type(value)}" - ) value.quantity = self.ScheduledVentilationAch self._scheduled_ventilation_schedule = value diff --git a/archetypal/template/window_setting.py b/archetypal/template/window_setting.py index 4e06d52b7..fb3b72282 100644 --- a/archetypal/template/window_setting.py +++ b/archetypal/template/window_setting.py @@ -13,8 +13,8 @@ WindowConstruction, WindowType, ) -from archetypal.template.schedule import UmiSchedule -from archetypal.template.umi_base import UmiBase +from archetypal.template.schedule import UmiSchedule, YearSchedule +from archetypal.template.umi_base import UmiBase, umibase_property from archetypal.utils import log, timeit @@ -36,7 +36,6 @@ class WindowSetting(UmiBase): .. _eppy : https://eppy.readthedocs.io/en/latest/ """ - _CREATED_OBJECTS = [] __slots__ = ( "_operable_area", @@ -113,9 +112,6 @@ def __init__( """ super(WindowSetting, self).__init__(Name, **kwargs) - self.ShadingSystemAvailabilitySchedule = ShadingSystemAvailabilitySchedule - self.Construction = Construction - self.AfnWindowAvailability = AfnWindowAvailability self.AfnDischargeC = AfnDischargeC self.AfnTempSetpoint = AfnTempSetpoint self.IsShadingSystemOn = IsShadingSystemOn @@ -128,7 +124,11 @@ def __init__( self.Type = Type self.ZoneMixingDeltaTemperature = ZoneMixingDeltaTemperature self.ZoneMixingFlowRate = ZoneMixingFlowRate + # Set UmiBas Properties after standard properties self.ZoneMixingAvailabilitySchedule = ZoneMixingAvailabilitySchedule + self.ShadingSystemAvailabilitySchedule = ShadingSystemAvailabilitySchedule + self.AfnWindowAvailability = AfnWindowAvailability + self.Construction = Construction self.area = area @@ -171,18 +171,13 @@ def AfnTempSetpoint(self): def AfnTempSetpoint(self, value): self._afn_temp_setpoint = validators.float(value, minimum=-100, maximum=100) - @property + @umibase_property(type_of_property=UmiSchedule) def AfnWindowAvailability(self): """Get or set the air flow network window availability schedule.""" return self._afn_window_availability @AfnWindowAvailability.setter def AfnWindowAvailability(self, value): - if value is not None: - assert isinstance(value, UmiSchedule), ( - f"Input error with value {value}. AfnWindowAvailability must " - f"be an UmiSchedule, not a {type(value)}" - ) self._afn_window_availability = value @property @@ -227,18 +222,13 @@ def ShadingSystemTransmittance(self, value): value, minimum=0, maximum=1 ) - @property + @umibase_property(type_of_property=UmiSchedule) def ShadingSystemAvailabilitySchedule(self): """Get or set the shading system availability schedule.""" return self._shading_system_availability_schedule @ShadingSystemAvailabilitySchedule.setter def ShadingSystemAvailabilitySchedule(self, value): - if value is not None: - assert isinstance(value, UmiSchedule), ( - f"Input error with value {value}. ZoneMixingAvailabilitySchedule must " - f"be an UmiSchedule, not a {type(value)}" - ) self._shading_system_availability_schedule = value @property @@ -254,18 +244,13 @@ def IsShadingSystemOn(self, value): ) self._is_shading_system_on = value - @property + @umibase_property(type_of_property=UmiSchedule) def ZoneMixingAvailabilitySchedule(self): """Get or set the zone mixing availability schedule.""" return self._zone_mixing_availability_schedule @ZoneMixingAvailabilitySchedule.setter def ZoneMixingAvailabilitySchedule(self, value): - if value is not None: - assert isinstance(value, UmiSchedule), ( - f"Input error with value {value}. ZoneMixingAvailabilitySchedule must " - f"be an UmiSchedule, not a {type(value)}" - ) self._zone_mixing_availability_schedule = value @property @@ -277,18 +262,13 @@ def ZoneMixingDeltaTemperature(self): def ZoneMixingDeltaTemperature(self, value): self._zone_mixing_delta_temperature = validators.float(value, minimum=0) - @property + @umibase_property(type_of_property=WindowConstruction) def Construction(self): """Get or set the window construction.""" return self._construction @Construction.setter def Construction(self, value): - if value is not None: - assert isinstance(value, WindowConstruction), ( - f"Input error with value {value}. Construction must " - f"be an WindowConstruction, not a {type(value)}" - ) self._construction = value @property @@ -695,7 +675,7 @@ def from_zone(cls, zone, **kwargs): # no window found, probably a core zone, return None. return None - def combine(self, other, weights=None, allow_duplicates=False): + def combine(self, other, weights=None, allow_duplicates=False, **kwargs): """Append other to self. Return self + other as a new object. Args: @@ -766,6 +746,7 @@ def combine(self, other, weights=None, allow_duplicates=False): weights, ), Type=max(self.Type, other.Type), + **kwargs, ) new_obj = WindowSetting(**meta, **new_attr) new_obj.predecessors.update(self.predecessors + other.predecessors) diff --git a/archetypal/template/zone_construction_set.py b/archetypal/template/zone_construction_set.py index 4e87a0d49..21ff089d4 100644 --- a/archetypal/template/zone_construction_set.py +++ b/archetypal/template/zone_construction_set.py @@ -6,13 +6,12 @@ from validator_collection import validators from archetypal.template.constructions.opaque_construction import OpaqueConstruction -from archetypal.template.umi_base import UmiBase +from archetypal.template.umi_base import UmiBase, umibase_property from archetypal.utils import log, reduce, timeit class ZoneConstructionSet(UmiBase): """ZoneConstructionSet class.""" - _CREATED_OBJECTS = [] __slots__ = ( "_facade", @@ -68,90 +67,66 @@ def __init__( **kwargs: """ super(ZoneConstructionSet, self).__init__(Name, **kwargs) - self.Slab = Slab self.IsSlabAdiabatic = IsSlabAdiabatic - self.Roof = Roof self.IsRoofAdiabatic = IsRoofAdiabatic - self.Partition = Partition self.IsPartitionAdiabatic = IsPartitionAdiabatic - self.Ground = Ground self.IsGroundAdiabatic = IsGroundAdiabatic - self.Facade = Facade self.IsFacadeAdiabatic = IsFacadeAdiabatic self.area = area self.volume = volume + # Set UmiBase Properties after standard properties + self.Slab = Slab + self.Roof = Roof + self.Partition = Partition + self.Ground = Ground + self.Facade = Facade # Only at the end append self to _CREATED_OBJECTS self._CREATED_OBJECTS.append(self) - @property + @umibase_property(type_of_property=OpaqueConstruction) def Facade(self): """Get or set the Facade OpaqueConstruction.""" return self._facade @Facade.setter def Facade(self, value): - if value is not None: - assert isinstance(value, OpaqueConstruction), ( - f"Input value error for {value}. Facade must be" - f" an OpaqueConstruction, not a {type(value)}" - ) self._facade = value - @property + @umibase_property(type_of_property=OpaqueConstruction) def Ground(self): """Get or set the Ground OpaqueConstruction.""" return self._ground @Ground.setter def Ground(self, value): - if value is not None: - assert isinstance(value, OpaqueConstruction), ( - f"Input value error for {value}. Ground must be" - f" an OpaqueConstruction, not a {type(value)}" - ) self._ground = value - @property + @umibase_property(type_of_property=OpaqueConstruction) def Partition(self): """Get or set the Partition OpaqueConstruction.""" return self._partition @Partition.setter def Partition(self, value): - if value is not None: - assert isinstance(value, OpaqueConstruction), ( - f"Input value error for {value}. Partition must be" - f" an OpaqueConstruction, not a {type(value)}" - ) self._partition = value - @property + @umibase_property(type_of_property=OpaqueConstruction) def Roof(self): """Get or set the Roof OpaqueConstruction.""" return self._roof @Roof.setter def Roof(self, value): - if value is not None: - assert isinstance(value, OpaqueConstruction), ( - f"Input value error for {value}. Roof must be" - f" an OpaqueConstruction, not a {type(value)}" - ) self._roof = value - @property + @umibase_property(type_of_property=OpaqueConstruction) def Slab(self): """Get or set the Slab OpaqueConstruction.""" return self._slab @Slab.setter def Slab(self, value): - if value is not None: - assert isinstance(value, OpaqueConstruction), ( - f"Input value error for {value}. Slab must be" - f" an OpaqueConstruction, not a {type(value)}" - ) self._slab = value @property @@ -464,7 +439,7 @@ def validate(self): iter( filter( lambda x: getattr(x, attr, None) is not None, - ZoneConstructionSet._CREATED_OBJECTS, + UmiBase.all_objects_of_type(ZoneConstructionSet), ) ), None, diff --git a/archetypal/template/zonedefinition.py b/archetypal/template/zonedefinition.py index dd2c9f6a8..440750d4f 100644 --- a/archetypal/template/zonedefinition.py +++ b/archetypal/template/zonedefinition.py @@ -13,7 +13,7 @@ from archetypal.template.constructions.opaque_construction import OpaqueConstruction from archetypal.template.dhw import DomesticHotWaterSetting from archetypal.template.load import ZoneLoad -from archetypal.template.umi_base import UmiBase +from archetypal.template.umi_base import UmiBase, umibase_property from archetypal.template.ventilation import VentilationSetting from archetypal.template.window_setting import WindowSetting from archetypal.template.zone_construction_set import ZoneConstructionSet @@ -25,7 +25,6 @@ class ZoneDefinition(UmiBase): .. image:: ../images/template/zoneinfo-zone.png """ - _CREATED_OBJECTS = [] __slots__ = ( "_internal_mass_exposed_per_floor_area", @@ -98,18 +97,10 @@ def __init__( """ super(ZoneDefinition, self).__init__(Name, **kwargs) - self.Ventilation = Ventilation - self.Loads = Loads - self.Conditioning = Conditioning - self.Constructions = Constructions self.DaylightMeshResolution = DaylightMeshResolution self.DaylightWorkplaneHeight = DaylightWorkplaneHeight - self.DomesticHotWater = DomesticHotWater - self.InternalMassConstruction = InternalMassConstruction self.InternalMassExposedPerFloorArea = InternalMassExposedPerFloorArea - self.Windows = Windows # This is not used in to_dict() - if zone_surfaces is None: zone_surfaces = [] self.zone_surfaces = zone_surfaces @@ -120,78 +111,61 @@ def __init__( self.is_part_of_total_floor_area = is_part_of_total_floor_area self.multiplier = multiplier self.is_core = is_core + # Set UmiBase Properties after standard properties + self.Ventilation = Ventilation + self.Windows = Windows # This is not used in to_dict() + self.Loads = Loads + self.Conditioning = Conditioning + self.Constructions = Constructions + self.DomesticHotWater = DomesticHotWater + self.InternalMassConstruction = InternalMassConstruction # Only at the end append self to _CREATED_OBJECTS self._CREATED_OBJECTS.append(self) - @property + @umibase_property(ZoneConstructionSet) def Constructions(self): """Get or set the ZoneConstructionSet object.""" return self._constructions @Constructions.setter def Constructions(self, value): - if value is not None: - assert isinstance(value, ZoneConstructionSet), ( - f"Input value error. Constructions must be of " - f"type {ZoneConstructionSet}, not {type(value)}." - ) self._constructions = value - @property + @umibase_property(ZoneLoad) def Loads(self): """Get or set the ZoneLoad object.""" return self._loads @Loads.setter def Loads(self, value): - if value is not None: - assert isinstance(value, ZoneLoad), ( - f"Input value error. Loads must be of " - f"type {ZoneLoad}, not {type(value)}." - ) self._loads = value - @property + @umibase_property(type_of_property=ZoneConditioning) def Conditioning(self): """Get or set the ZoneConditioning object.""" return self._conditioning @Conditioning.setter def Conditioning(self, value): - if value is not None: - assert isinstance(value, ZoneConditioning), ( - f"Input value error. Conditioning must be of " - f"type {ZoneConditioning}, not {type(value)}." - ) self._conditioning = value - @property + @umibase_property(type_of_property=VentilationSetting) def Ventilation(self): """Get or set the VentilationSetting object.""" return self._ventilation @Ventilation.setter def Ventilation(self, value): - if value is not None: - assert isinstance(value, VentilationSetting), ( - f"Input value error. Ventilation must be of " - f"type {VentilationSetting}, not {type(value)}." - ) self._ventilation = value - @property + @umibase_property(type_of_property=DomesticHotWaterSetting) def DomesticHotWater(self): """Get or set the DomesticHotWaterSetting object.""" return self._domestic_hot_water @DomesticHotWater.setter def DomesticHotWater(self, value): - if value is not None: - assert isinstance(value, DomesticHotWaterSetting), ( - f"Input value error. DomesticHotWater must be of " - f"type {DomesticHotWaterSetting}, not {type(value)}." - ) self._domestic_hot_water = value @property @@ -212,18 +186,13 @@ def DaylightWorkplaneHeight(self): def DaylightWorkplaneHeight(self, value): self._daylight_workplane_height = validators.float(value, minimum=0) - @property + @umibase_property(type_of_property=OpaqueConstruction) def InternalMassConstruction(self): """Get or set the internal mass construction object.""" return self._internal_mass_construction @InternalMassConstruction.setter def InternalMassConstruction(self, value): - if value is not None: - assert isinstance(value, OpaqueConstruction), ( - f"Input value error. InternalMassConstruction must be of " - f"type {OpaqueConstruction}, not {type(value)}." - ) self._internal_mass_construction = value @property @@ -235,18 +204,13 @@ def InternalMassExposedPerFloorArea(self): def InternalMassExposedPerFloorArea(self, value): self._internal_mass_exposed_per_floor_area = validators.float(value, minimum=0) - @property + @umibase_property(type_of_property=WindowSetting) def Windows(self): """Get or set the WindowSetting object.""" return self._windows @Windows.setter def Windows(self, value): - if value is not None: - assert isinstance(value, WindowSetting), ( - f"Input value error. Windows must be of " - f"type {WindowSetting}, not {type(value)}." - ) self._windows = value @property @@ -576,7 +540,7 @@ def is_core(zone_ep): ) return zone - def combine(self, other, weights=None, allow_duplicates=False): + def combine(self, other, weights=None, allow_duplicates=False, **kwargs): """Combine two ZoneDefinition objects together. Args: @@ -658,6 +622,7 @@ def combine(self, other, weights=None, allow_duplicates=False): other, "InternalMassExposedPerFloorArea", weights ), Loads=ZoneLoad.combine(self.Loads, other.Loads, weights), + **kwargs, ) new_obj = ZoneDefinition(**meta, **new_attr) diff --git a/archetypal/umi_template.py b/archetypal/umi_template.py index 21af240ac..5b8019f91 100644 --- a/archetypal/umi_template.py +++ b/archetypal/umi_template.py @@ -47,6 +47,8 @@ class UmiTemplateLibrary: - See :meth:`from_idf_files` to create a library by converting existing IDF models. """ + _CREATED_LIBS = [] + _LIB_GROUPS = [ "GasMaterials", "GlazingMaterials", @@ -142,6 +144,12 @@ def __init__( self.GasMaterials = GasMaterials or [] self.GlazingMaterials = GlazingMaterials or [] + for obj in self.object_list: + UmiBase._GRAPH.add_node( + obj + ) # Add all objects to graph in case they are complete orphans + UmiTemplateLibrary._CREATED_LIBS.append(self) + def __iter__(self): """Iterate over component groups. Yields tuple of (group, value).""" for group in self._LIB_GROUPS: @@ -152,10 +160,10 @@ def __getitem__(self, item): def __add__(self, other: "UmiTemplateLibrary"): """Combined""" - for key, group in other: - # for each group items - for component in group: - component.id = None # Reset the component's id + # for key, group in other: + # # for each group items + # for component in group: + # component.id = None # Reset the component's id attrs = {} for group, value in self: @@ -271,7 +279,9 @@ def from_idf_files( ] if keep_all_zones: - _zones = set(obj.get_unique() for obj in ZoneDefinition._CREATED_OBJECTS) + _zones = set( + obj.get_unique() for obj in UmiBase.all_objects_of_type(ZoneDefinition) + ) for zone in _zones: umi_template.ZoneDefinitions.append(zone) exceptions = [ZoneDefinition.__name__] @@ -314,28 +324,65 @@ def template_complexity_reduction(idfname, epw, **kwargs): return BuildingTemplate.from_idf(idf, **kwargs) @classmethod - def open(cls, filename): + def open(cls, filename, preserve_ids=False): """Initialize an UmiTemplate object from an UMI Template Library File. Args: filename (str or Path): PathLike object giving the pathname of the UMI Template File. + preserve_ids: (bool): If `True` original object ids will be kept even if an object with the original_id exists. If False, ids will be changed when creating objects if one already exists Returns: UmiTemplateLibrary: The template object. """ name = Path(filename) with open(filename, "r") as f: - t = cls.loads(f.read(), name) + t = cls.loads(f.read(), name=name, preserve_ids=preserve_ids) return t @classmethod - def loads(cls, s, name): - """load string.""" + def loads(cls, s, name, preserve_ids=False): + """load string. + + Args: + name (str): Name for the UmiTemplatLibrary object + preserve_ids: (bool): If `True` original object ids will be kept even if an object with the original_id exists. If False, ids will be changed when creating objects if one already exists + + """ datastore = json.loads(s) # with datastore, create each objects t = cls(name) + id_cache = {} + for lib_group in cls._LIB_GROUPS: + if lib_group == "BuildingTemplates": + continue # Building Templates do not have IDs + elif lib_group == "ZoneDefinitions": + dict_group = "Zones" + elif lib_group == "StructureInformations": + dict_group = "StructureDefinitions" + else: + dict_group = lib_group + id_cache[dict_group] = {} + for store in datastore[dict_group]: + original_id = store["$id"] if store.get("$id") else store["$ref"] + cache_id = original_id + if not preserve_ids and original_id in [ + o.id for o in UmiBase.all_objects() + ]: + new_id = str(id(store)) + if store.get("$id"): + store["$id"] = new_id + else: + # handle windows + store["$id"] = new_id + for bt in datastore["BuildingTemplates"]: + if bt["Windows"].get("$id") == original_id: + bt["Windows"]["$id"] = new_id + store["$ref"] = new_id + cache_id = new_id + id_cache[dict_group][cache_id] = original_id + t.GasMaterials = [ GasMaterial.from_dict(store, allow_duplicates=False) for store in datastore["GasMaterials"] @@ -352,7 +399,7 @@ def loads(cls, s, name): OpaqueConstruction.from_dict( store, materials={ - a.id: a + id_cache[a.__class__.__name__ + "s"][a.id]: a for a in (t.GasMaterials + t.GlazingMaterials + t.OpaqueMaterials) }, allow_duplicates=True, @@ -362,7 +409,10 @@ def loads(cls, s, name): t.WindowConstructions = [ WindowConstruction.from_dict( store, - materials={a.id: a for a in (t.GasMaterials + t.GlazingMaterials)}, + materials={ + id_cache[a.__class__.__name__ + "s"][a.id]: a + for a in (t.GasMaterials + t.GlazingMaterials) + }, allow_duplicates=True, ) for store in datastore["WindowConstructions"] @@ -370,7 +420,9 @@ def loads(cls, s, name): t.StructureInformations = [ StructureInformation.from_dict( store, - materials={a.id: a for a in t.OpaqueMaterials}, + materials={ + id_cache["OpaqueMaterials"][a.id]: a for a in t.OpaqueMaterials + }, allow_duplicates=True, ) for store in datastore["StructureDefinitions"] @@ -382,7 +434,9 @@ def loads(cls, s, name): t.WeekSchedules = [ WeekSchedule.from_dict( store, - day_schedules={a.id: a for a in t.DaySchedules}, + day_schedules={ + id_cache["DaySchedules"][a.id]: a for a in t.DaySchedules + }, allow_duplicates=True, ) for store in datastore["WeekSchedules"] @@ -390,7 +444,9 @@ def loads(cls, s, name): t.YearSchedules = [ YearSchedule.from_dict( store, - week_schedules={a.id: a for a in t.WeekSchedules}, + week_schedules={ + id_cache["WeekSchedules"][a.id]: a for a in t.WeekSchedules + }, allow_duplicates=True, ) for store in datastore["YearSchedules"] @@ -398,7 +454,7 @@ def loads(cls, s, name): t.DomesticHotWaterSettings = [ DomesticHotWaterSetting.from_dict( store, - schedules={a.id: a for a in t.YearSchedules}, + schedules={id_cache["YearSchedules"][a.id]: a for a in t.YearSchedules}, allow_duplicates=True, ) for store in datastore["DomesticHotWaterSettings"] @@ -406,7 +462,7 @@ def loads(cls, s, name): t.VentilationSettings = [ VentilationSetting.from_dict( store, - schedules={a.id: a for a in t.YearSchedules}, + schedules={id_cache["YearSchedules"][a.id]: a for a in t.YearSchedules}, allow_duplicates=True, ) for store in datastore["VentilationSettings"] @@ -414,7 +470,7 @@ def loads(cls, s, name): t.ZoneConditionings = [ ZoneConditioning.from_dict( store, - schedules={a.id: a for a in t.YearSchedules}, + schedules={id_cache["YearSchedules"][a.id]: a for a in t.YearSchedules}, allow_duplicates=True, ) for store in datastore["ZoneConditionings"] @@ -422,7 +478,10 @@ def loads(cls, s, name): t.ZoneConstructionSets = [ ZoneConstructionSet.from_dict( store, - opaque_constructions={a.id: a for a in t.OpaqueConstructions}, + opaque_constructions={ + id_cache["OpaqueConstructions"][a.id]: a + for a in t.OpaqueConstructions + }, allow_duplicates=True, ) for store in datastore["ZoneConstructionSets"] @@ -430,7 +489,7 @@ def loads(cls, s, name): t.ZoneLoads = [ ZoneLoad.from_dict( store, - schedules={a.id: a for a in t.YearSchedules}, + schedules={id_cache["YearSchedules"][a.id]: a for a in t.YearSchedules}, allow_duplicates=True, ) for store in datastore["ZoneLoads"] @@ -438,14 +497,26 @@ def loads(cls, s, name): t.ZoneDefinitions = [ ZoneDefinition.from_dict( store, - zone_conditionings={a.id: a for a in t.ZoneConditionings}, - zone_construction_sets={a.id: a for a in t.ZoneConstructionSets}, + zone_conditionings={ + id_cache["ZoneConditionings"][a.id]: a for a in t.ZoneConditionings + }, + zone_construction_sets={ + id_cache["ZoneConstructionSets"][a.id]: a + for a in t.ZoneConstructionSets + }, domestic_hot_water_settings={ - a.id: a for a in t.DomesticHotWaterSettings + id_cache["DomesticHotWaterSettings"][a.id]: a + for a in t.DomesticHotWaterSettings + }, + opaque_constructions={ + id_cache["OpaqueConstructions"][a.id]: a + for a in t.OpaqueConstructions + }, + zone_loads={id_cache["ZoneLoads"][a.id]: a for a in t.ZoneLoads}, + ventilation_settings={ + id_cache["VentilationSettings"][a.id]: a + for a in t.VentilationSettings }, - opaque_constructions={a.id: a for a in t.OpaqueConstructions}, - zone_loads={a.id: a for a in t.ZoneLoads}, - ventilation_settings={a.id: a for a in t.VentilationSettings}, allow_duplicates=True, ) for store in datastore["Zones"] @@ -454,14 +525,20 @@ def loads(cls, s, name): WindowSetting.from_ref( store["$ref"], datastore["BuildingTemplates"], - schedules={a.id: a for a in t.YearSchedules}, - window_constructions={a.id: a for a in t.WindowConstructions}, + schedules={id_cache["YearSchedules"][a.id]: a for a in t.YearSchedules}, + window_constructions={ + id_cache["WindowConstructions"][a.id]: a + for a in t.WindowConstructions + }, ) if "$ref" in store else WindowSetting.from_dict( store, - schedules={a.id: a for a in t.YearSchedules}, - window_constructions={a.id: a for a in t.WindowConstructions}, + schedules={id_cache["YearSchedules"][a.id]: a for a in t.YearSchedules}, + window_constructions={ + id_cache["WindowConstructions"][a.id]: a + for a in t.WindowConstructions + }, allow_duplicates=True, ) for store in datastore["WindowSettings"] @@ -469,15 +546,31 @@ def loads(cls, s, name): t.BuildingTemplates = [ BuildingTemplate.from_dict( store, - zone_definitions={a.id: a for a in t.ZoneDefinitions}, - structure_definitions={a.id: a for a in t.StructureInformations}, - window_settings={a.id: a for a in t.WindowSettings}, - schedules={a.id: a for a in t.YearSchedules}, - window_constructions={a.id: a for a in t.WindowConstructions}, + zone_definitions={ + id_cache["Zones"][a.id]: a for a in t.ZoneDefinitions + }, + structure_definitions={ + id_cache["StructureDefinitions"][a.id]: a + for a in t.StructureInformations + }, + window_settings={ + id_cache["WindowSettings"][a.id]: a for a in t.WindowSettings + }, + schedules={id_cache["YearSchedules"][a.id]: a for a in t.YearSchedules}, + window_constructions={ + id_cache["WindowConstructions"][a.id]: a + for a in t.WindowConstructions + }, allow_duplicates=True, ) for store in datastore["BuildingTemplates"] ] + + for obj in t.object_list: + UmiBase._GRAPH.add_node( + obj + ) # Add all objects to graph in case they are complete orphans + return t def validate(self, defaults=True): @@ -665,15 +758,18 @@ def unique_components( cleared from self. keep_orphaned (bool): if True, orphaned objects are kept. """ + G = self.to_graph(fast_return=True) if keep_orphaned: - G = self.to_graph(include_orphans=True) - connected_to_building = set() - for bldg in self.BuildingTemplates: - for obj in nx.dfs_preorder_nodes(G, bldg): - connected_to_building.add(obj) - orphans = [ - obj for obj in self.object_list if obj not in connected_to_building - ] + def get_orphans(): + connected_to_building = [] + for bldg in self.BuildingTemplates: + for obj in nx.dfs_preorder_nodes(G, bldg): + connected_to_building.append(obj) + orphans = [ + obj for obj in self.object_list if obj not in connected_to_building + ] + return orphans + orphans = get_orphans() self._clear_components_list(exceptions) # First clear components # Inclusion is a set of object classes that will be unique. @@ -688,18 +784,20 @@ def unique_components( f"{', '.join(set(self._LIB_GROUPS))}" ) inclusion = set(self._LIB_GROUPS) - for key, group in self: - # for each group - for component in group: - # travers each object using generator - for parent, key, obj in parent_key_child_traversal(component): - if obj.__class__.__name__ + "s" in inclusion: - if key: - setattr( - parent, key, obj.get_unique() - ) # set unique object on key - - self.update_components_list(exceptions=exceptions) # Update the components list + + self.update_components_list() + + def replacement(): + for group, components in self: + if group != "BuildingTemplates": + for component in components: + if component.__class__.__name__ + "s" in inclusion: + equivalent_component = component.get_unique() + component.replace_me_with(equivalent_component) # will skip replacement if ids are equal + replacement() + self.update_components_list() + + if keep_orphaned: for obj in orphans: self[obj.__class__.__name__ + "s"].append(obj) @@ -711,67 +809,98 @@ def replace_component(self, this, that) -> None: this (UmiBase): The reference to replace with `that`. that (UmiBase): The object to replace each references with. """ - for bldg in self.BuildingTemplates: - for parent, key, obj in parent_key_child_traversal(bldg): - if obj is this: - setattr(parent, key, that) - + this.replace_me_with(that) self.update_components_list() def update_components_list(self, exceptions=None): """Update the component groups with connected components.""" # clear components list except BuildingTemplate self._clear_components_list(exceptions) + G = self.to_graph(fast_return=True) + for bt in self.BuildingTemplates: + for component in nx.dfs_preorder_nodes(G, bt): + if isinstance(component, UmiSchedule) and not isinstance( + component, (DaySchedule, WeekSchedule, YearSchedule) + ): + y, ws, ds = component.to_year_week_day() + if not any(o.id == y.id for o in self.YearSchedules): + self.YearSchedules.append(y) + for w in ws: + if not any(o.id == w.id for o in self.WeekSchedules): + self.WeekSchedules.append(w) + for d in ds: + if not any(o.id == d.id for o in self.DaySchedules): + self.DaySchedules.append(d) + # finally, replace it with y + component.replace_me_with(y) - for key, group in self: - for component in group: - for parent, key, child in parent_key_child_traversal(component): - if isinstance(child, UmiSchedule) and not isinstance( - child, (DaySchedule, WeekSchedule, YearSchedule) - ): - y, ws, ds = child.to_year_week_day() - if not any(o.id == y.id for o in self.YearSchedules): - self.YearSchedules.append(y) - for w in ws: - if not any(o.id == w.id for o in self.WeekSchedules): - self.WeekSchedules.append(w) - for d in ds: - if not any(o.id == d.id for o in self.DaySchedules): - self.DaySchedules.append(d) - # finally, replace it with y - setattr(parent, key, y) - elif isinstance(child, UmiBase): - obj_list = self.__dict__[child.__class__.__name__ + "s"] - if not any(o.id == child.id for o in obj_list): - # Important to compare on UmiBase.id and not on identity. - obj_list.append(child) - - def to_graph(self, include_orphans=False): - """Create a :class:`networkx.DiGraph` of self. + else: + if component.id not in [ + n.id for n in self[component.__class__.__name__ + "s"] + ]: + self[component.__class__.__name__ + "s"].append(component) + + def to_graph(self, include_orphans=False, fast_return=False): + """Create a :class:`networkx.MultiDiGraph` of self. This networkx.DiGraph object is then useful for graph-theory operations on the hierarchy of the UmiTemplateLibrary. - """ - import networkx as nx - G = nx.DiGraph() + Todo: Test performance of nx.dfs_preorder_nodes vs nx.has_path for building graphs + + Args: + include_orphans (bool): If `True`, components which do not have a path to a Building Template will be included. + fast_return: If `True` a copy of the entire Graph is returned, which is much more performant than filtering out orphans + Returns: + :class:`networkx.MultiDiGraph`: a copy of the UmiBase:_GRAPH or just the graph for the current lib. - for bldg in self.BuildingTemplates: - for parent, child in parent_child_traversal(bldg): - if parent: - G.add_edge(parent, child) + """ + import networkx as nx - if include_orphans: - orphans = [ - obj for obj in self.object_list if obj.id not in (n.id for n in G) - ] - for orphan in orphans: - G.add_node(orphan) - for parent, child in parent_child_traversal(orphan): - if parent: - G.add_edge(parent, child) + G = UmiBase._GRAPH.copy() + if fast_return: + return G + if len(UmiTemplateLibrary._CREATED_LIBS) == 1: + if include_orphans: + return G + else: + nodes_to_remove = [] + for node in G: + if node in self.BuildingTemplates: + continue # all building templates always included + remove_node = True + for bt in self.BuildingTemplates: + if nx.has_path(G, bt, node): + remove_node = False + break + if remove_node: + nodes_to_remove.append(node) + G.remove_nodes_from(nodes_to_remove) + return G + else: + nodes_to_remove = [] + for node in G: + if node.id not in [n.id for n in self.object_list]: + nodes_to_remove.append(node) + continue # nodes outside of lib always removed + if not include_orphans: + if node in self.BuildingTemplates: + continue # building templates always included + remove_node = True + for bt in self.BuildingTemplates: + # if node in nx.dfs_preorder_nodes(G, bt): # why are + if nx.has_path(G, bt, node): + remove_node = False + break + if remove_node: + nodes_to_remove.append(node) + G.remove_nodes_from(nodes_to_remove) + return G - return G + @classmethod + def _clear_class_memory(cls): + cls._CREATED_LIBS = [] + UmiBase._clear_class_memory() def no_duplicates(file, attribute="Name"): @@ -850,7 +979,7 @@ def parent_child_traversal(parent: UmiBase): :attr:`UmiBase.children` attribute and had a better performance than :func:`parent_key_child_traversal`. """ - for child in parent.children: + for child in getattr(parent, "children", ()): yield parent, child yield from parent_child_traversal(child) diff --git a/tests/test_umi.py b/tests/test_umi.py index 97447246a..e713193e5 100644 --- a/tests/test_umi.py +++ b/tests/test_umi.py @@ -1,7 +1,9 @@ import collections import json import os +import time +import numpy as np import pytest from path import Path @@ -25,20 +27,65 @@ from archetypal.template.zone_construction_set import ZoneConstructionSet from archetypal.template.zonedefinition import ZoneDefinition from archetypal.umi_template import UmiTemplateLibrary, no_duplicates +from archetypal.utils import timeit, log class TestUmiTemplate: """Test suite for the UmiTemplateLibrary class""" + @pytest.fixture(autouse=True) + def cleanup(self): + UmiTemplateLibrary._clear_class_memory() + yield + UmiTemplateLibrary._clear_class_memory() + @pytest.fixture(scope="function") - def two_identical_libraries(self): + def two_identical_libraries_nodup(self): """Yield two identical libraries. Scope of this fixture is `function`.""" file = "tests/input_data/umi_samples/BostonTemplateLibrary_nodup.json" - yield UmiTemplateLibrary.open(file), UmiTemplateLibrary.open(file) + a = UmiTemplateLibrary.open(file, preserve_ids=True) + b = UmiTemplateLibrary.open(file, preserve_ids=False) + yield a, b + + @pytest.fixture(scope="function") + def two_identical_libraries_dup(self): + """Yield two identical libraries. Scope of this fixture is `function`.""" + file = "tests/input_data/umi_samples/BostonTemplateLibrary_2.json" + a = UmiTemplateLibrary.open(file, preserve_ids=True) + b = UmiTemplateLibrary.open(file, preserve_ids=False) + yield a, b + + @pytest.fixture(scope="function") + def lib_nodup(self): + """Yield a template lib. Scope of this fixture is `function`.""" + file = "tests/input_data/umi_samples/BostonTemplateLibrary_nodup.json" + yield UmiTemplateLibrary.open(file, preserve_ids=True) - def test_add(self, two_identical_libraries): + @pytest.fixture(scope="function") + def lib(self): + """Yield a template lib. Scope of this fixture is `function`.""" + file = "tests/input_data/umi_samples/BostonTemplateLibrary_2.json" + yield UmiTemplateLibrary.open(file, preserve_ids=True) + + def test_change_ids( + self, two_identical_libraries_nodup, two_identical_libraries_dup + ): + a, b = two_identical_libraries_nodup + for group in UmiTemplateLibrary._LIB_GROUPS: + for this, that in zip(a[group], b[group]): + assert this == that + assert len(this.Parents) == len(that.Parents) + assert this.id != that.id + a, b = two_identical_libraries_dup + for group in UmiTemplateLibrary._LIB_GROUPS: + for this, that in zip(a[group], b[group]): + assert this == that + assert len(this.Parents) == len(that.Parents) + assert this.id != that.id + + def test_add(self, two_identical_libraries_nodup): """Test combining two template library objects together.""" - a, b = two_identical_libraries + a, b = two_identical_libraries_nodup # add them together into `c` c = a + b @@ -55,9 +102,29 @@ def test_add(self, two_identical_libraries): c.unique_components() assert len(c.OpaqueMaterials) == nb_materials_before / 2 - def test_unique_components(self, two_identical_libraries): + def test_keep_orphaned(self, lib): + original_gas_length = len(lib.GasMaterials) + lib.unique_components(keep_orphaned=True) + assert original_gas_length == len(lib.GasMaterials) + + def test_exclude_orphaned(self, lib): + lib.unique_components(keep_orphaned=False) + assert len(lib.GasMaterials) == 1 and lib.GasMaterials[0].Type == "AIR" + + def test_unique_components(self, lib): + """Test options on UmiTemplateLibrary.unique_components""" + new_zone_load = lib.ZoneDefinitions[0].Loads.duplicate() + for i, zone in enumerate(lib.ZoneDefinitions): + if i != 0: + # Replace all but the original + zone.Loads = new_zone_load + lib.unique_components() + assert lib.ZoneDefinitions[0].Loads == new_zone_load + assert len(lib.ZoneLoads) == 1 + + def test_unique_components_with_addition(self, two_identical_libraries_nodup): """Test options on UmiTemplateLibrary.unique_components""" - a, b = two_identical_libraries + a, b = two_identical_libraries_nodup c = a + b # Only make the `OpaqueMaterial` objects unique. @@ -72,25 +139,202 @@ def test_unique_components(self, two_identical_libraries): # missing S. c.unique_components("OpaqueMaterial") - def test_graph(self): + @staticmethod + def check_graph_for_inclusion(library, g, include_orphans): + for obj in library.object_list: + if len(obj.ParentTemplates) > 0 or isinstance(obj, BuildingTemplate): + assert obj in g + else: + if not include_orphans: + assert obj not in g + else: + assert obj in g + + def test_graph(self, lib): """Test initialization of networkx DiGraph""" - file = "tests/input_data/umi_samples/BostonTemplateLibrary_2.json" - - a = UmiTemplateLibrary.open(file) - G = a.to_graph() - n_nodes = len(G) + G = lib.to_graph() + assert len(G) < len(lib.object_list) + TestUmiTemplate.check_graph_for_inclusion(lib, G, include_orphans=False) + assert G.has_edge( + lib.BuildingTemplates[0], lib.BuildingTemplates[0].Perimeter, "Perimeter" + ) # Test option to include orphaned objects. - G = a.to_graph(include_orphans=True) - assert len(G) > n_nodes + G_orphans = lib.to_graph(include_orphans=True) + assert len(G_orphans) > len(G) + assert len(G_orphans) == len(lib.object_list) + TestUmiTemplate.check_graph_for_inclusion(lib, G_orphans, include_orphans=True) + assert G_orphans.has_edge( + lib.BuildingTemplates[0], lib.BuildingTemplates[0].Perimeter, "Perimeter" + ) + + def test_graph_with_multiple_libs(self, two_identical_libraries_dup): + """ Test creating graphs when multiple libs exist in memory""" + a, b = two_identical_libraries_dup + def test_for_no_crossover(ga, gb): + for node in ga: + assert node not in gb + for node in gb: + assert node not in ga + + def test_for_no_external(lib, g): + for node in g: + if node not in lib.object_list: + print(node.__class__.__name__) + assert node in lib.object_list + + + Ga = a.to_graph() + Gb = b.to_graph() + assert len(Ga) == len(Gb) + assert len(Ga) < len(a.object_list) + + test_for_no_crossover(Ga, Gb) + test_for_no_external(a, Ga) + test_for_no_external(b, Gb) + TestUmiTemplate.check_graph_for_inclusion(a, Ga, include_orphans=False) + TestUmiTemplate.check_graph_for_inclusion(b, Gb, include_orphans=False) + + + # Test orphans + Ga_orphans = a.to_graph(include_orphans=True) + Gb_orphans = b.to_graph(include_orphans=True) + assert len(Ga_orphans) == len(Gb_orphans) + assert len(Ga_orphans) == len(a.object_list) + assert len(Ga_orphans) > len(Ga) + + test_for_no_crossover(Ga_orphans, Gb_orphans) + test_for_no_external(a, Ga_orphans) + test_for_no_external(b, Gb_orphans) + TestUmiTemplate.check_graph_for_inclusion(a, Ga_orphans, include_orphans=True) + TestUmiTemplate.check_graph_for_inclusion(b, Gb_orphans, include_orphans=True) + + + def test_parent_templates(self, lib): + """ Test that changing an object accurately updates the ParentTemplates list""" + + for bt in lib.BuildingTemplates: + assert bt in bt.Perimeter.ParentTemplates + assert bt in bt.Perimeter.Loads.ParentTemplates + assert bt in bt.Core.Loads.ParentTemplates + assert bt in bt.Core.ParentTemplates + bt.Perimeter.Loads = lib.ZoneLoads[0] + bt.Core.Loads = lib.ZoneLoads[0] + + for bt in lib.BuildingTemplates: + # Can't use == here since the other Building Templates from opening the library are still in mem + assert bt in lib.ZoneLoads[0].ParentTemplates + + def test_all_children_for_parent_templates(self, lib): + lib.unique_components(keep_orphaned=False) + for group, components in lib: + for component in components: + if group == "BuildingTemplates": + assert len(component.ParentTemplates) == 0 + else: + parent_bts_from_current_lib = [ + bt + for bt in component.ParentTemplates + if bt in lib.BuildingTemplates + ] + assert len(parent_bts_from_current_lib) > 0 + + def test_replace_component(self, lib): + + for group, components in lib: + if group != "BuildingTemplates": + original_component = components[0] + for component in components: + if hash(component) != hash(original_component): + lib.replace_component(component, original_component) + lib.unique_components(keep_orphaned=False) + for group, components in lib: + if group != "BuildingTemplates": + assert len(components) == 1 + + @pytest.fixture(scope="class") + def benchmark_results(cls): + results = {"replace_component": {}, "unique_components": {}} + yield results - def test_template_to_template(self): + @pytest.mark.skipif( + os.environ.get("CI", "False").lower() == "true", + reason="not necessary to test this on CI", + ) + @pytest.mark.parametrize("execution_count", range(10)) + @pytest.mark.parametrize("additional_building_templates_count", [0, 10, 50]) + def test_benchmark_replace_component(self, execution_count, additional_building_templates_count, benchmark_results): + lib = UmiTemplateLibrary.open("tests/input_data/umi_samples/BostonTemplateLibrary_2.json") + # extend the list of building templates with an arbitrary building template, even if its a dupe + for i in range(additional_building_templates_count): + lib.BuildingTemplates.append(lib.BuildingTemplates[-1]) + # Set all first layers to the first opaque material + for construction in lib.OpaqueConstructions: + construction.Layers[0] = lib.OpaqueMaterials[0] + @timeit + def run(): + # Replace the first opaque material with the second opaque material + lib.replace_component(lib.OpaqueMaterials[0], lib.OpaqueMaterials[1]) + + start = time.time() + run() + end = time.time() + try: + benchmark_results["replace_component"][additional_building_templates_count].append(end-start) + except KeyError: + benchmark_results["replace_component"][additional_building_templates_count] = [] + benchmark_results["replace_component"][additional_building_templates_count].append(end-start) + for count, array in benchmark_results["replace_component"].items(): + log(f"Average component replacement time for {count} extra templates: { np.average(array) } (stddev: {np.std(array)})") + + + @pytest.mark.skipif( + os.environ.get("CI", "False").lower() == "true", + reason="not necessary to test this on CI", + ) + @pytest.mark.parametrize("execution_count", range(10)) + @pytest.mark.parametrize("phantom_objects_count", [0, 10, 100, 200]) + def test_benchmark_unique_components(self, execution_count, phantom_objects_count, benchmark_results): + lib = UmiTemplateLibrary.open("tests/input_data/umi_samples/BostonTemplateLibrary_2.json") + # Add a bunch of phantom stuff to the library, e.g. objects in other templates + for i in range(phantom_objects_count): + lib.ZoneLoads[1].duplicate() + lib.DomesticHotWaterSettings[1].duplicate() + lib.ZoneConditionings[1].duplicate() + lib.VentilationSettings[1].duplicate() + lib.ZoneConstructionSets[1].duplicate() + for obj in lib.ZoneDefinitions: + obj.Loads = lib.ZoneLoads[0].duplicate() + obj.DomesticHotWater = lib.DomesticHotWaterSettings[0].duplicate() + obj.Conditioning = lib.ZoneConditionings[0].duplicate() + obj.Ventilation = lib.VentilationSettings[0].duplicate() + obj.Constructions = lib.ZoneConstructionSets[0].duplicate() + @timeit + def run(): + lib.unique_components() + start = time.time() + run() + end = time.time() + try: + benchmark_results["unique_components"][phantom_objects_count].append(end-start) + except KeyError: + benchmark_results["unique_components"][phantom_objects_count] = [] + benchmark_results["unique_components"][phantom_objects_count].append(end-start) + for count, array in benchmark_results["unique_components"].items(): + log(f"Average component replacement time for {count} extra components: { np.average(array) } (stddev: {np.std(array)})") + assert len(lib.ZoneLoads) == 1 + assert len(lib.DomesticHotWaterSettings) == 1 + assert len(lib.ZoneConditionings) == 1 + assert len(lib.VentilationSettings) == 1 + assert len(lib.ZoneConstructionSets) == 1 + + def test_template_to_template(self, lib_nodup): """load the json into UmiTemplateLibrary object, then convert back to json and compare""" file = "tests/input_data/umi_samples/BostonTemplateLibrary_nodup.json" - a = UmiTemplateLibrary.open(file).to_dict() + a = lib_nodup.to_dict() b = TestUmiTemplate.read_json(file) for key in b: