diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c037fe3..a0db6fcca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,26 @@ Classify the change according to the following categories: ##### Deprecated ##### Removed ### Patches + + +## v2.5.0 +### Minor Updates +##### Added +- `0011_coolingloadinputs....` file used to add new models to the db +In `job/models.py`: +- added **ExistingChillerInputs** model +- added **ExistingChillerOutputs** model +- added **CoolingLoadInputs** model +- added **CoolingLoadOutputs** model +- added **HeatingLoadOutputs** model +In `job/process_results.py`: +- add **ExistingChillerOutputs** +- add **CoolingLoadOutputs** +- add **HeatingLoadOutputs** +In `job/validators.py: +- add time series length validation on **CoolingLoadInputs->thermal_loads_ton** and **CoolingLoadInputs->per_time_step_fractions_of_electric_load** +In `job/views.py`: +- add new input/output models to properly save the inputs/outputs ## v2.4.0 ### Minor Updates @@ -43,7 +63,6 @@ Classify the change according to the following categories: ### Minor Updates ##### Fixed Lookback charge parameters expected from the URDB API call were changed to the non-caplitalized format, so they are now used properly. - ## v2.3.0 ##### Changed The following name changes were made in the `job/` endpoint and `julia_src/http.jl`: @@ -70,7 +89,6 @@ The following name changes were made in the `job/` endpoint and `julia_src/http. ##### Added - `0005_boilerinputs....` file used to add new models to the db - `job/` endpoint: Add inputs and validation to model off-grid wind -In `job/models.py`: - added **ExistingBoilerInputs** model - added **ExistingBoilerOutputs** model - added **SpaceHeatingLoadInputs** model @@ -102,6 +120,7 @@ In `job/test/test_job_endpoint.py`: - add a testcase to validate that API is accepting/returning fields related to new models. In `'job/validators.py`: - add new input models +- added `update_pv_defaults_offgrid()` to prevent validation failure when PV is not provided as input In `job/views.py`: - Added **SiteInputs** to `help` endpoint - Added **SiteOutputs** to `outputs` endpoint diff --git a/job/migrations/0015_coolingloadinputs_coolingloadoutputs_and_more.py b/job/migrations/0015_coolingloadinputs_coolingloadoutputs_and_more.py new file mode 100644 index 000000000..d5749d07a --- /dev/null +++ b/job/migrations/0015_coolingloadinputs_coolingloadoutputs_and_more.py @@ -0,0 +1,89 @@ +# Generated by Django 4.0.7 on 2022-12-09 03:31 + +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import job.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('job', '0014_rename_thermal_to_tes_series_mmbtu_per_hour_existingboileroutputs_year_one_fuel_consumption_series_m'), + ] + + operations = [ + migrations.CreateModel( + name='CoolingLoadInputs', + fields=[ + ('meta', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='CoolingLoadInputs', serialize=False, to='job.apimeta')), + ('doe_reference_name', models.TextField(blank=True, choices=[('FastFoodRest', 'Fastfoodrest'), ('FullServiceRest', 'Fullservicerest'), ('Hospital', 'Hospital'), ('LargeHotel', 'Largehotel'), ('LargeOffice', 'Largeoffice'), ('MediumOffice', 'Mediumoffice'), ('MidriseApartment', 'Midriseapartment'), ('Outpatient', 'Outpatient'), ('PrimarySchool', 'Primaryschool'), ('RetailStore', 'Retailstore'), ('SecondarySchool', 'Secondaryschool'), ('SmallHotel', 'Smallhotel'), ('SmallOffice', 'Smalloffice'), ('StripMall', 'Stripmall'), ('Supermarket', 'Supermarket'), ('Warehouse', 'Warehouse'), ('FlatLoad', 'Flatload'), ('FlatLoad_24_5', 'Flatload 24 5'), ('FlatLoad_16_7', 'Flatload 16 7'), ('FlatLoad_16_5', 'Flatload 16 5'), ('FlatLoad_8_7', 'Flatload 8 7'), ('FlatLoad_8_5', 'Flatload 8 5')], help_text="Building type to use in selecting a simulated load profile from DOE Commercial Reference Buildings.By default, the doe_reference_name of the ElectricLoad is used.")), + ('blended_doe_reference_names', django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, choices=[('FastFoodRest', 'Fastfoodrest'), ('FullServiceRest', 'Fullservicerest'), ('Hospital', 'Hospital'), ('LargeHotel', 'Largehotel'), ('LargeOffice', 'Largeoffice'), ('MediumOffice', 'Mediumoffice'), ('MidriseApartment', 'Midriseapartment'), ('Outpatient', 'Outpatient'), ('PrimarySchool', 'Primaryschool'), ('RetailStore', 'Retailstore'), ('SecondarySchool', 'Secondaryschool'), ('SmallHotel', 'Smallhotel'), ('SmallOffice', 'Smalloffice'), ('StripMall', 'Stripmall'), ('Supermarket', 'Supermarket'), ('Warehouse', 'Warehouse'), ('FlatLoad', 'Flatload'), ('FlatLoad_24_5', 'Flatload 24 5'), ('FlatLoad_16_7', 'Flatload 16 7'), ('FlatLoad_16_5', 'Flatload 16 5'), ('FlatLoad_8_7', 'Flatload 8 7'), ('FlatLoad_8_5', 'Flatload 8 5')]), blank=True, default=list, help_text='Used in concert with blended_doe_reference_percents to create a blended load profile from multiple DoE Commercial Reference Buildings.', size=None)), + ('blended_doe_reference_percents', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1.0)]), blank=True, default=list, help_text='Used in concert with blended_doe_reference_names to create a blended load profile from multiple DoE Commercial Reference Buildings to simulate buildings/campuses. Must sum to 1.0.', size=None)), + ('annual_tonhour', models.FloatField(blank=True, help_text="Annual electric chiller thermal energy production, in [Ton-Hour],used to scale simulated default electric chiller load profile for the site's climate zone", null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100000000.0)])), + ('monthly_tonhour', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), blank=True, default=list, help_text="Monthly site space cooling requirement in [Ton-Hour], used to scale simulated default building load profile for the site's climate zone", size=None)), + ('thermal_loads_ton', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True), blank=True, default=list, help_text='Typical electric chiller thermal production to serve the load for all hours in one year. Must be hourly (8,760 samples), 30 minute (17,520 samples), or 15 minute (35,040 samples).', size=None)), + ('annual_fraction_of_electric_load', models.FloatField(blank=True, help_text="Annual electric chiller energy consumption scalar as a fraction of total electric load applied to every time stepused to scale simulated default electric chiller load profile for the site's climate zone", null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1)])), + ('monthly_fractions_of_electric_load', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1)]), blank=True, default=list, help_text="Monthly fraction of site's total electric consumption used up by electric chiller, applied to every hour of each month,to scale simulated default building load profile for the site's climate zone", size=None)), + ('per_time_step_fractions_of_electric_load', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True), blank=True, default=list, help_text="Per timestep fraction of site's total electric consumption used up by electric chiller.Must be hourly (8,760 samples), 30 minute (17,520 samples), or 15 minute (35,040 samples).", size=None)), + ], + bases=(job.models.BaseModel, models.Model), + ), + migrations.CreateModel( + name='CoolingLoadOutputs', + fields=[ + ('meta', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='CoolingLoadOutputs', serialize=False, to='job.apimeta')), + ('load_series_ton', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), blank=True, default=list, help_text='Hourly total cooling load [ton]', size=None)), + ('electric_chiller_base_load_series_kw', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), blank=True, default=list, help_text='Hourly total base load drawn from chiller [kW-electric]', size=None)), + ('annual_calculated_tonhour', models.FloatField(blank=True, default=0, help_text='Annual site total cooling load [tonhr]', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)])), + ('annual_electric_chiller_base_load_kwh', models.FloatField(blank=True, default=0, help_text='Annual total base load drawn from chiller [kWh-electric]', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)])), + ], + bases=(job.models.BaseModel, models.Model), + ), + migrations.CreateModel( + name='ExistingChillerInputs', + fields=[ + ('meta', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='ExistingChillerInputs', serialize=False, to='job.apimeta')), + ('cop', models.FloatField(blank=True, help_text='Existing electric chiller system coefficient of performance (COP) (ratio of usable cooling thermal energy produced per unit electric energy consumed)', null=True, validators=[django.core.validators.MinValueValidator(0.01), django.core.validators.MaxValueValidator(20)])), + ('max_thermal_factor_on_peak_load', models.FloatField(blank=True, default=1.25, help_text='Factor on peak thermal LOAD which the electric chiller can supply. This accounts for the assumed size of the electric chiller which typically has a safety factor above the peak load.This factor limits the max production which could otherwise be exploited with ColdThermalStorage', validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(5.0)])), + ], + bases=(job.models.BaseModel, models.Model), + ), + migrations.CreateModel( + name='ExistingChillerOutputs', + fields=[ + ('meta', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='ExistingChillerOutputs', serialize=False, to='job.apimeta')), + ('year_one_to_tes_series_ton', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True), blank=True, default=list, help_text='Year one hourly time series of electric chiller thermal to cold TES [Ton]', null=True, size=None)), + ('year_one_to_load_series_ton', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True), blank=True, default=list, help_text='Year one hourly time series of electric chiller thermal to cooling load [Ton]', null=True, size=None)), + ('year_one_electric_consumption_series_kw', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True), blank=True, default=list, help_text='Year one hourly time series of chiller electric consumption [kW]', null=True, size=None)), + ('year_one_electric_consumption_kwh', models.FloatField(blank=True, help_text='Year one chiller electric consumption [kWh]', null=True)), + ('year_one_thermal_production_tonhour', models.FloatField(blank=True, help_text='Year one chiller thermal production [Ton Hour', null=True)), + ], + bases=(job.models.BaseModel, models.Model), + ), + migrations.CreateModel( + name='HeatingLoadOutputs', + fields=[ + ('meta', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='HeatingLoadOutputs', serialize=False, to='job.apimeta')), + ('dhw_thermal_load_series_mmbtu_per_hour', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), blank=True, default=list, help_text='Hourly domestic hot water load [MMBTU/hr]', size=None)), + ('space_heating_thermal_load_series_mmbtu_per_hour', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), blank=True, default=list, help_text='Hourly domestic space heating load [MMBTU/hr]', size=None)), + ('total_heating_thermal_load_series_mmbtu_per_hour', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), blank=True, default=list, help_text='Hourly total heating load [MMBTU/hr]', size=None)), + ('dhw_boiler_fuel_load_series_mmbtu_per_hour', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), blank=True, default=list, help_text='Hourly domestic hot water load [MMBTU/hr]', size=None)), + ('space_heating_boiler_fuel_load_series_mmbtu_per_hour', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), blank=True, default=list, help_text='Hourly domestic space heating load [MMBTU/hr]', size=None)), + ('total_heating_boiler_fuel_load_series_mmbtu_per_hour', django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)]), blank=True, default=list, help_text='Hourly total heating load [MMBTU/hr]', size=None)), + ('annual_calculated_dhw_thermal_load_mmbtu', models.FloatField(blank=True, default=0, help_text='Annual site DHW load [MMBTU]', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)])), + ('annual_calculated_space_heating_thermal_load_mmbtu', models.FloatField(blank=True, default=0, help_text='Annual site space heating load [MMBTU]', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)])), + ('annual_calculated_total_heating_thermal_load_mmbtu', models.FloatField(blank=True, default=0, help_text='Annual site total heating load [MMBTU]', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)])), + ('annual_calculated_dhw_boiler_fuel_load_mmbtu', models.FloatField(blank=True, default=0, help_text='Annual site DHW boiler fuel load [MMBTU]', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)])), + ('annual_calculated_space_heating_boiler_fuel_load_mmbtu', models.FloatField(blank=True, default=0, help_text='Annual site space heating boiler fuel load [MMBTU]', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)])), + ('annual_calculated_total_heating_boiler_fuel_load_mmbtu', models.FloatField(blank=True, default=0, help_text='Annual site total heating boiler fuel load [MMBTU]', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100000000.0)])), + ], + bases=(job.models.BaseModel, models.Model), + ), + migrations.AlterField( + model_name='domestichotwaterloadinputs', + name='blended_doe_reference_percents', + field=django.contrib.postgres.fields.ArrayField(base_field=models.FloatField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1.0)]), blank=True, default=list, help_text='Used in concert with blended_doe_reference_names to create a blended load profile from multiple DoE Commercial Reference Buildings to simulate buildings/campuses. Must sum to 1.0.', size=None), + ), + ] diff --git a/job/models.py b/job/models.py index e9dcc7885..92b725ca0 100644 --- a/job/models.py +++ b/job/models.py @@ -3832,7 +3832,7 @@ def clean(self): class CHPOutputs(BaseModel, models.Model): - key = "CHP" + key = "CHPOutputs" meta = models.OneToOneField( to=APIMeta, on_delete=models.CASCADE, @@ -3956,17 +3956,296 @@ class Message(BaseModel, models.Model): # TODO other necessary models from reo/models.py -class ExistingBoilerInputs(BaseModel, models.Model): +class CoolingLoadInputs(BaseModel, models.Model): - key = "ExistingBoiler" + key = "CoolingLoad" + meta = models.OneToOneField( + APIMeta, + on_delete=models.CASCADE, + related_name="CoolingLoadInputs", + primary_key=True + ) + + possible_sets = [ + ["thermal_loads_ton"], + ["doe_reference_name"], + ["blended_doe_reference_names", "blended_doe_reference_percents"], + ["annual_fraction_of_electric_load"], + ["monthly_fractions_of_electric_load"], + ["per_time_step_fractions_of_electric_load"], + [] + ] + + DOE_REFERENCE_NAME = models.TextChoices('DOE_REFERENCE_NAME', ( + 'FastFoodRest ' + 'FullServiceRest ' + 'Hospital ' + 'LargeHotel ' + 'LargeOffice ' + 'MediumOffice ' + 'MidriseApartment ' + 'Outpatient ' + 'PrimarySchool ' + 'RetailStore ' + 'SecondarySchool ' + 'SmallHotel ' + 'SmallOffice ' + 'StripMall ' + 'Supermarket ' + 'Warehouse ' + 'FlatLoad ' + 'FlatLoad_24_5 ' + 'FlatLoad_16_7 ' + 'FlatLoad_16_5 ' + 'FlatLoad_8_7 ' + 'FlatLoad_8_5' + )) + + doe_reference_name = models.TextField( + null=False, + blank=True, + choices=DOE_REFERENCE_NAME.choices, + help_text=("Building type to use in selecting a simulated load profile from DOE " + "Commercial Reference Buildings." + "By default, the doe_reference_name of the ElectricLoad is used.") + ) + + blended_doe_reference_names = ArrayField( + models.TextField( + choices=DOE_REFERENCE_NAME.choices, + blank=True + ), + default=list, + blank=True, + help_text=("Used in concert with blended_doe_reference_percents to create a blended load profile from multiple " + "DoE Commercial Reference Buildings.") + ) + + blended_doe_reference_percents = ArrayField( + models.FloatField( + null=True, blank=True, + validators=[ + MinValueValidator(0), + MaxValueValidator(1.0) + ], + ), + default=list, + blank=True, + help_text=("Used in concert with blended_doe_reference_names to create a blended load profile from multiple " + "DoE Commercial Reference Buildings to simulate buildings/campuses. Must sum to 1.0.") + ) + + annual_tonhour = models.FloatField( + validators=[ + MinValueValidator(1), + MaxValueValidator(MAX_BIG_NUMBER) + ], + null=True, + blank=True, + help_text=("Annual electric chiller thermal energy production, in [Ton-Hour]," + "used to scale simulated default electric chiller load profile for the site's climate zone") + ) + + monthly_tonhour = ArrayField( + models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + blank=True + ), + default=list, + blank=True, + help_text=("Monthly site space cooling requirement in [Ton-Hour], used " + "to scale simulated default building load profile for the site's climate zone") + ) + + thermal_loads_ton = ArrayField( + models.FloatField( + blank=True + ), + default=list, + blank=True, + help_text=("Typical electric chiller thermal production to serve the load for all hours in one year. Must be hourly (8,760 samples), 30 minute (17," + "520 samples), or 15 minute (35,040 samples)." + ) + ) + + annual_fraction_of_electric_load = models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(1) + ], + null=True, + blank=True, + help_text=("Annual electric chiller energy consumption scalar as a fraction of total electric load applied to every time step" + "used to scale simulated default electric chiller load profile for the site's climate zone" + ) + ) + + monthly_fractions_of_electric_load = ArrayField( + models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(1) + ], + blank=True + ), + default=list, blank=True, + help_text=("Monthly fraction of site's total electric consumption used up by electric chiller, applied to every hour of each month," + "to scale simulated default building load profile for the site's climate zone") + ) + + per_time_step_fractions_of_electric_load = ArrayField( + models.FloatField( + blank=True + ), + default=list, + blank=True, + help_text=("Per timestep fraction of site's total electric consumption used up by electric chiller." + "Must be hourly (8,760 samples), 30 minute (17,520 samples), or 15 minute (35,040 samples)." + ) + ) + + def clean(self): + error_messages = {} + + # possible sets for defining load profile + if not at_least_one_set(self.dict, self.possible_sets): + error_messages["required inputs"] = \ + "Must provide at least one set of valid inputs from {}.".format(self.possible_sets) + + if len(self.blended_doe_reference_names) > 0 and self.doe_reference_name == "": + if len(self.blended_doe_reference_names) != len(self.blended_doe_reference_percents): + error_messages["blended_doe_reference_names"] = \ + "The number of blended_doe_reference_names must equal the number of blended_doe_reference_percents." + if not math.isclose(sum(self.blended_doe_reference_percents), 1.0): + error_messages["blended_doe_reference_percents"] = "Sum must = 1.0." + + if self.doe_reference_name != "" or \ + len(self.blended_doe_reference_names) > 0: + self.year = 2017 # the validator provides an "info" message regarding this) + + if len(self.monthly_fractions_of_electric_load) > 0: + if len(self.monthly_fractions_of_electric_load) != 12: + error_messages["monthly_fractions_of_electric_load"] = \ + "Provided cooling monthly_fractions_of_electric_load array does not have 12 values." + + # Require 12 values if monthly_tonhours is provided. + if 12 > len(self.monthly_tonhour) > 0: + error_messages["required inputs"] = \ + "Must provide 12 elements as inputs to monthly_tonhour. Received {}.".format(self.monthly_tonhour) + + if error_messages: + raise ValidationError(error_messages) + + pass + + +class ExistingChillerInputs(BaseModel, models.Model): + + key = "ExistingChiller" + + meta = models.OneToOneField( + APIMeta, + on_delete=models.CASCADE, + related_name="ExistingChillerInputs", + primary_key=True + ) + + cop = models.FloatField( + validators=[ + MinValueValidator(0.01), + MaxValueValidator(20) + ], + null=True, + blank=True, + help_text=("Existing electric chiller system coefficient of performance (COP) " + "(ratio of usable cooling thermal energy produced per unit electric energy consumed)") + ) + + max_thermal_factor_on_peak_load = models.FloatField( + validators=[ + MinValueValidator(0.0), + MaxValueValidator(5.0) + ], + default=1.25, + blank=True, + help_text=("Factor on peak thermal LOAD which the electric chiller can supply. " + "This accounts for the assumed size of the electric chiller which typically has a safety factor above the peak load." + "This factor limits the max production which could otherwise be exploited with ColdThermalStorage") + ) + + def clean(self): + pass + + +class ExistingChillerOutputs(BaseModel, models.Model): + + key = "ExistingChillerOutputs" meta = models.OneToOneField( APIMeta, on_delete=models.CASCADE, - related_name="ExistingBoilerInputs", + related_name="ExistingChillerOutputs", primary_key=True ) + year_one_to_tes_series_ton = ArrayField( + models.FloatField( + blank=True + ), + default=list, + blank=True, + null=True, + help_text=("Year one hourly time series of electric chiller thermal to cold TES [Ton]") + ) + + year_one_to_load_series_ton = ArrayField( + models.FloatField( + blank=True + ), + default=list, + blank=True, + null=True, + help_text=("Year one hourly time series of electric chiller thermal to cooling load [Ton]") + ) + + year_one_electric_consumption_series_kw = ArrayField( + models.FloatField( + blank=True + ), + default=list, + blank=True, + null=True, + help_text=("Year one hourly time series of chiller electric consumption [kW]") + ) + + year_one_electric_consumption_kwh = models.FloatField( + null=True, + blank=True, + help_text=("Year one chiller electric consumption [kWh]") + ) + + year_one_thermal_production_tonhour = models.FloatField( + null=True, + blank=True, + help_text=("Year one chiller thermal production [Ton Hour") + ) + + def clean(self): + pass + +class ExistingBoilerInputs(BaseModel, models.Model): + + key = "ExistingBoiler" + meta = models.OneToOneField( + APIMeta, + on_delete=models.CASCADE, + related_name="ExistingBoilerInputs", + primary_key=True + ) + PRODUCTION_TYPE = models.TextChoices('PRODUCTION_TYPE', ( 'steam', 'hot_water' @@ -4116,7 +4395,7 @@ def clean(self): class ExistingBoilerOutputs(BaseModel, models.Model): - key = "ExistingBoiler" + key = "ExistingBoilerOutputs" meta = models.OneToOneField( APIMeta, @@ -4564,6 +4843,7 @@ class DomesticHotWaterLoadInputs(BaseModel, models.Model): "520 samples), or 15 minute (35,040 samples). All non-net load values must be greater than or " "equal to zero. " ) + ) blended_doe_reference_names = ArrayField( @@ -4588,11 +4868,11 @@ class DomesticHotWaterLoadInputs(BaseModel, models.Model): default=list, blank=True, help_text=("Used in concert with blended_doe_reference_names to create a blended load profile from multiple " - "DoE Commercial Reference Buildings. Must sum to 1.0.") + "DoE Commercial Reference Buildings to simulate buildings/campuses. Must sum to 1.0.") ) ''' - Latitude and longitude are passed on to SpaceHeating struct using the Site struct. + Latitude and longitude are passed on to DomesticHotWater struct using the Site struct. City is not used as an input here because it is found using find_ashrae_zone_city() when needed. If a blank key is provided, then default DOE load profile from electricload is used [cross-clean] ''' @@ -4616,12 +4896,217 @@ def clean(self): len(self.blended_doe_reference_names) > 0: self.year = 2017 # the validator provides an "info" message regarding this) - if error_messages: - raise ValidationError(error_messages) - +class HeatingLoadOutputs(BaseModel, models.Model): + + key = "HeatingLoadOutputs" + + meta = models.OneToOneField( + APIMeta, + on_delete=models.CASCADE, + related_name="HeatingLoadOutputs", + primary_key=True + ) + + dhw_thermal_load_series_mmbtu_per_hour = ArrayField( + models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + blank=True + ), + default=list, blank=True, + help_text=("Hourly domestic hot water load [MMBTU/hr]") + ) + + space_heating_thermal_load_series_mmbtu_per_hour = ArrayField( + models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + blank=True + ), + default=list, blank=True, + help_text=("Hourly domestic space heating load [MMBTU/hr]") + ) + + total_heating_thermal_load_series_mmbtu_per_hour = ArrayField( + models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + blank=True + ), + default=list, blank=True, + help_text=("Hourly total heating load [MMBTU/hr]") + ) + + dhw_boiler_fuel_load_series_mmbtu_per_hour = ArrayField( + models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + blank=True + ), + default=list, blank=True, + help_text=("Hourly domestic hot water load [MMBTU/hr]") + ) + + space_heating_boiler_fuel_load_series_mmbtu_per_hour = ArrayField( + models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + blank=True + ), + default=list, blank=True, + help_text=("Hourly domestic space heating load [MMBTU/hr]") + ) + + total_heating_boiler_fuel_load_series_mmbtu_per_hour = ArrayField( + models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + blank=True + ), + default=list, blank=True, + help_text=("Hourly total heating load [MMBTU/hr]") + ) + + annual_calculated_dhw_thermal_load_mmbtu = models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + null=True, + blank=True, + default=0, + help_text=("Annual site DHW load [MMBTU]") + ) + + annual_calculated_space_heating_thermal_load_mmbtu = models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + null=True, + blank=True, + default=0, + help_text=("Annual site space heating load [MMBTU]") + ) + + annual_calculated_total_heating_thermal_load_mmbtu = models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + null=True, + blank=True, + default=0, + help_text=("Annual site total heating load [MMBTU]") + ) + + annual_calculated_dhw_boiler_fuel_load_mmbtu = models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + null=True, + blank=True, + default=0, + help_text=("Annual site DHW boiler fuel load [MMBTU]") + ) + + annual_calculated_space_heating_boiler_fuel_load_mmbtu = models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + null=True, + blank=True, + default=0, + help_text=("Annual site space heating boiler fuel load [MMBTU]") + ) + + annual_calculated_total_heating_boiler_fuel_load_mmbtu = models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + null=True, + blank=True, + default=0, + help_text=("Annual site total heating boiler fuel load [MMBTU]") + ) + + def clean(self): pass -# TODO Add domestic hot water input model. +class CoolingLoadOutputs(BaseModel, models.Model): + + key = "CoolingLoadOutputs" + + meta = models.OneToOneField( + APIMeta, + on_delete=models.CASCADE, + related_name="CoolingLoadOutputs", + primary_key=True + ) + + load_series_ton = ArrayField( + models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + blank=True + ), + default=list, blank=True, + help_text=("Hourly total cooling load [ton]") + ) + + electric_chiller_base_load_series_kw = ArrayField( + models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + blank=True + ), + default=list, blank=True, + help_text=("Hourly total base load drawn from chiller [kW-electric]") + ) + + annual_calculated_tonhour = models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + null=True, + blank=True, + default=0, + help_text=("Annual site total cooling load [tonhr]") + ) + + annual_electric_chiller_base_load_kwh = models.FloatField( + validators=[ + MinValueValidator(0), + MaxValueValidator(MAX_BIG_NUMBER) + ], + null=True, + blank=True, + default=0, + help_text=("Annual total base load drawn from chiller [kWh-electric]") + ) + + def clean(self): + pass def get_input_dict_from_run_uuid(run_uuid:str): """ @@ -4672,6 +5157,12 @@ def filter_none_and_empty_array(d:dict): try: d["Wind"] = filter_none_and_empty_array(meta.WindInputs.dict) except: pass + try: d["CoolingLoad"] = filter_none_and_empty_array(meta.CoolingLoadInputs.dict) + except: pass + + try: d["ExistingChiller"] = filter_none_and_empty_array(meta.ExistingChillerInputs.dict) + except: pass + # try: d["Boiler"] = filter_none_and_empty_array(meta.BoilerInputs.dict) # except: pass @@ -4700,4 +5191,4 @@ def scalar_to_vector(vec:list): days_per_month = [31,28,31,30,31,30,31,31,30,31,30,31] return numpy.repeat(vec, [i * 24 for i in days_per_month]).tolist() else: - return vec # the vector len was not 1 or 12, handle it elsewhere \ No newline at end of file + return vec # the vector len was not 1 or 12, handle it elsewhere diff --git a/job/src/process_results.py b/job/src/process_results.py index e79665469..8dec1e8e7 100644 --- a/job/src/process_results.py +++ b/job/src/process_results.py @@ -27,9 +27,10 @@ # OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED # OF THE POSSIBILITY OF SUCH DAMAGE. # ********************************************************************************* + from job.models import FinancialOutputs, APIMeta, PVOutputs, ElectricStorageOutputs, ElectricTariffOutputs, SiteOutputs,\ ElectricUtilityOutputs, GeneratorOutputs, ElectricLoadOutputs, WindOutputs, FinancialInputs, ElectricUtilityInputs,\ - ExistingBoilerOutputs, CHPOutputs, CHPInputs + ExistingBoilerOutputs, CHPOutputs, CHPInputs, ExistingChillerOutputs, CoolingLoadOutputs, HeatingLoadOutputs import logging log = logging.getLogger(__name__) @@ -39,6 +40,7 @@ def process_results(results: dict, run_uuid: str) -> None: Called in job/run_jump_model (a celery task) """ + meta = APIMeta.objects.get(run_uuid=run_uuid) meta.status = results.get("status") meta.save(update_fields=["status"]) @@ -59,10 +61,16 @@ def process_results(results: dict, run_uuid: str) -> None: GeneratorOutputs.create(meta=meta, **results["Generator"]).save() if "Wind" in results.keys(): WindOutputs.create(meta=meta, **results["Wind"]).save() + if "ExistingChiller" in results.keys(): + ExistingChillerOutputs.create(meta=meta, **results["ExistingChiller"]).save() # if "Boiler" in results.keys(): # BoilerOutputs.create(meta=meta, **results["Boiler"]).save() if "ExistingBoiler" in results.keys(): ExistingBoilerOutputs.create(meta=meta, **results["ExistingBoiler"]).save() + if "HeatingLoad" in results.keys(): + HeatingLoadOutputs.create(meta=meta, **results["HeatingLoad"]).save() + if "CoolingLoad" in results.keys(): + CoolingLoadOutputs.create(meta=meta, **results["CoolingLoad"]).save() if "CHP" in results.keys(): CHPOutputs.create(meta=meta, **results["CHP"]).save() # TODO process rest of results diff --git a/job/test/test_job_endpoint.py b/job/test/test_job_endpoint.py index 32d7c8fb1..4ef75823a 100644 --- a/job/test/test_job_endpoint.py +++ b/job/test/test_job_endpoint.py @@ -161,6 +161,68 @@ def test_off_grid_defaults(self): self.assertAlmostEqual(sum(results["ElectricLoad"]["offgrid_load_met_series_kw"]), 8760.0, places=-1) self.assertAlmostEqual(results["Financial"]["lifecycle_offgrid_other_annual_costs_after_tax"], 0.0, places=-2) + def test_cooling_possible_sets_and_results(self): + """ + Purpose of this test is to test the validity of Cooling Load possible_sets, in particular []/null and blend/hybrid + """ + scenario = { + "Settings": {"run_bau": False}, + "Site": {"longitude": -118.1164613, "latitude": 34.5794343}, + "ElectricTariff": {"urdb_label": "5ed6c1a15457a3367add15ae"}, + "PV": {"max_kw": 0.0}, + "ElectricStorage":{"max_kw": 0.0, "max_kwh": 0.0}, + "ElectricLoad": { + "blended_doe_reference_names": ["Hospital", "LargeOffice"], + "blended_doe_reference_percents": [0.75, 0.25], + "annual_kwh": 876000.0 + }, + "CoolingLoad": { + "doe_reference_name": "Hospital", + "annual_tonhour": 5000.0 + }, + "SpaceHeatingLoad": { + "doe_reference_name": "Hospital", + "annual_mmbtu": 500.0 + }, + "ExistingBoiler": { + "efficiency": 0.72, + "production_type": "steam", + "fuel_cost_per_mmbtu": 10 + }, + "ExistingChiller": { + "cop": 3.4, + "max_thermal_factor_on_peak_load": 1.25 + }, + "CHP": { + "prime_mover": "recip_engine", + "fuel_cost_per_mmbtu": 10, + "min_kw": 100, + "max_kw": 100, + "electric_efficiency_full_load": 0.35, + "electric_efficiency_half_load": 0.35, + "min_turn_down_fraction": 0.1, + "thermal_efficiency_full_load": 0.45, + "thermal_efficiency_half_load": 0.45 + } + } + + resp = self.api_client.post('/dev/job/', format='json', data=scenario) + self.assertHttpCreated(resp) + r = json.loads(resp.content) + run_uuid = r.get('run_uuid') + + resp = self.api_client.get(f'/dev/job/{run_uuid}/results') + r = json.loads(resp.content) + inputs = r["inputs"] + results = r["outputs"] + self.assertIn("CoolingLoad", list(inputs.keys())) + self.assertIn("CoolingLoad", list(results.keys())) + self.assertIn("CHP", list(results.keys())) + self.assertIn("ExistingChiller",list(results.keys())) + self.assertIn("ExistingBoiler", list(results.keys())) + self.assertIn("HeatingLoad", list(results.keys())) + + def test_chp_defaults_from_julia(self): # Test that the inputs_with_defaults_set_in_julia feature worked for CHP, consistent with /chp_defaults post_file = os.path.join('job', 'test', 'posts', 'chp_defaults_post.json') @@ -193,3 +255,4 @@ def test_chp_defaults_from_julia(self): self.assertEquals(inputs_chp[key], view_response["default_inputs"][key]) else: # Make sure we didn't overwrite user-input self.assertEquals(inputs_chp[key], post["CHP"][key]) + diff --git a/job/validators.py b/job/validators.py index b03182992..bafa20465 100644 --- a/job/validators.py +++ b/job/validators.py @@ -31,7 +31,7 @@ import pandas as pd from job.models import MAX_BIG_NUMBER, APIMeta, ExistingBoilerInputs, UserProvidedMeta, SiteInputs, Settings, ElectricLoadInputs, ElectricTariffInputs, \ FinancialInputs, BaseModel, Message, ElectricUtilityInputs, PVInputs, ElectricStorageInputs, GeneratorInputs, WindInputs, SpaceHeatingLoadInputs, \ - DomesticHotWaterLoadInputs, CHPInputs + DomesticHotWaterLoadInputs, CHPInputs, CoolingLoadInputs, ExistingChillerInputs from django.core.exceptions import ValidationError from pyproj import Proj from typing import Tuple @@ -94,6 +94,8 @@ def __init__(self, raw_inputs: dict): ElectricStorageInputs, GeneratorInputs, WindInputs, + CoolingLoadInputs, + ExistingChillerInputs, ExistingBoilerInputs, SpaceHeatingLoadInputs, DomesticHotWaterLoadInputs, @@ -279,6 +281,7 @@ def update_pv_defaults_offgrid(self): if len(self.pvnames) > 0: # multiple PV for pvname in self.pvnames: cross_clean_pv(self.models[pvname]) + update_pv_defaults_offgrid(self) """ Time series values are up or down sampled to align with Settings.time_steps_per_hour @@ -398,6 +401,17 @@ def update_pv_defaults_offgrid(self): self.add_validation_error("ElectricUtility", "outage_end_time_step", f"Value is greater than the max allowable ({max_ts})") + """ + CoolingLoad + """ + if "CoolingLoad" in self.models.keys(): + + if len(self.models["CoolingLoad"].thermal_loads_ton) > 0: + self.clean_time_series("CoolingLoad", "thermal_loads_ton") + + if len(self.models["CoolingLoad"].per_time_step_fractions_of_electric_load) > 0: + self.clean_time_series("CoolingLoad", "per_time_step_fractions_of_electric_load") + """ ExistingBoiler """ @@ -522,6 +536,10 @@ def validate_offgrid_keys(self): if self.models["Settings"].off_grid_flag==True: validate_offgrid_keys(self) + """ + ExistingChiller - skip, no checks + """ + def save(self): """ Save all values to database diff --git a/job/views.py b/job/views.py index 0565cc753..942821cfc 100644 --- a/job/views.py +++ b/job/views.py @@ -34,9 +34,11 @@ from django.http import JsonResponse from reo.exceptions import UnexpectedError from job.models import Settings, PVInputs, ElectricStorageInputs, WindInputs, GeneratorInputs, ElectricLoadInputs,\ - ElectricTariffInputs, ElectricUtilityInputs, SpaceHeatingLoadInputs, PVOutputs, ElectricStorageOutputs, WindOutputs, ExistingBoilerInputs,\ - GeneratorOutputs, ElectricTariffOutputs, ElectricUtilityOutputs, ElectricLoadOutputs, ExistingBoilerOutputs, \ - DomesticHotWaterLoadInputs, SiteInputs, SiteOutputs, APIMeta, UserProvidedMeta, CHPInputs, CHPOutputs + ElectricTariffInputs, ElectricUtilityInputs, SpaceHeatingLoadInputs, PVOutputs, ElectricStorageOutputs,\ + WindOutputs, ExistingBoilerInputs, GeneratorOutputs, ElectricTariffOutputs, ElectricUtilityOutputs,\ + ElectricLoadOutputs, ExistingBoilerOutputs, DomesticHotWaterLoadInputs, SiteInputs, SiteOutputs, APIMeta,\ + UserProvidedMeta, CHPInputs, CHPOutputs, CoolingLoadInputs, ExistingChillerInputs, ExistingChillerOutputs,\ + CoolingLoadOutputs, HeatingLoadOutputs import os import requests import logging @@ -64,6 +66,8 @@ def help(request): d["ElectricStorage"] = ElectricStorageInputs.info_dict(ElectricStorageInputs) d["Wind"] = WindInputs.info_dict(WindInputs) d["Generator"] = GeneratorInputs.info_dict(GeneratorInputs) + d["CoolingLoad"] = CoolingLoadInputs.info_dict(CoolingLoadInputs) + d["ExistingChiller"] = ExistingChillerInputs.info_dict(ExistingChillerInputs) d["ExistingBoiler"] = ExistingBoilerInputs.info_dict(ExistingBoilerInputs) # d["Boiler"] = BoilerInputs.info_dict(BoilerInputs) d["SpaceHeatingLoad"] = SpaceHeatingLoadInputs.info_dict(SpaceHeatingLoadInputs) @@ -103,8 +107,11 @@ def outputs(request): d["ElectricStorage"] = ElectricStorageOutputs.info_dict(ElectricStorageOutputs) d["Wind"] = WindOutputs.info_dict(WindOutputs) d["Generator"] = GeneratorOutputs.info_dict(GeneratorOutputs) + d["ExistingChiller"] = ExistingChillerOutputs.info_dict(ExistingChillerOutputs) d["ExistingBoiler"] = ExistingBoilerOutputs.info_dict(ExistingBoilerOutputs) # d["Boiler"] = BoilerOutputs.info_dict(BoilerOutputs) + d["HeatingLoad"] = HeatingLoadOutputs.info_dict(HeatingLoadOutputs) + d["CoolingLoad"] = CoolingLoadOutputs.info_dict(CoolingLoadOutputs) d["CHP"] = CHPOutputs.info_dict(CHPOutputs) return JsonResponse(d) @@ -191,6 +198,12 @@ def results(request, run_uuid): try: r["inputs"]["Wind"] = meta.WindInputs.dict except: pass + try: r["inputs"]["CoolingLoad"] = meta.CoolingLoadInputs.dict + except: pass + + try: r["inputs"]["ExistingChiller"] = meta.ExistingChillerInputs.dict + except: pass + try: r["inputs"]["ExistingBoiler"] = meta.ExistingBoilerInputs.dict except: pass @@ -238,12 +251,18 @@ def results(request, run_uuid): except: pass try: r["outputs"]["Wind"] = meta.WindOutputs.dict except: pass + try: r["outputs"]["ExistingChiller"] = meta.ExistingChillerOutputs.dict + except: pass try: r["outputs"]["ExistingBoiler"] = meta.ExistingBoilerOutputs.dict except: pass # try: r["outputs"]["Boiler"] = meta.BoilerOutputs.dict # except: pass try: r["outputs"]["CHP"] = meta.CHPOutputs.dict except: pass + try: r["outputs"]["HeatingLoad"] = meta.HeatingLoadOutputs.dict + except: pass + try: r["outputs"]["CoolingLoad"] = meta.CoolingLoadOutputs.dict + except: pass for d in r["outputs"].values(): if isinstance(d, dict):