• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

urbanopt / geojson-modelica-translator / 5694679204

pending completion
5694679204

push

github-actions

web-flow
Add GHE Properties to System Parameter File (#570)

* add ghe to create system parameter function

* Add design method for borehole

* update create sys param and system_parameter.py

* update tests

* update district system type property

* reduce unnecessary indenting

* add negative sign to heat_flow to designate as cooling

* clean up test setup now that we have at least python 3.8

* update district_system_type property in system_parameters.py

* redo the un-indenting I accidentally committed

* update dependencies with poetry

* remove generated test sys-param file

* gitignore generated test sys-param file

* add sys param argument

* fix typo that prevented sys-param creation via cli

* remove nonsensical 5G district parameters

* clean up uo_des test comments a bit, and add intermediate assertions

* remove outdated spawn compilation instructions

* enable 5G models with the CLI

* add borehole length to template

* add gfunction csv file to cli test for ghe district tests

* more updates to gmt class to handle 5g districts from cli

* choose generations more explicitly in district.py

* use correct sys-param parameter name in borefield.py

* add start/stop/step times for model simulation to cli options

* add 5g cli integration test, update calls to simulate during the summer

* Quick fix to enlarge loop flow rate

* change borehole length in test sys-param files to match schema

* make pytest skip reason comment more explicit

* don't skip distribution simulation test

* clarify pytest skip reason comment

---------

Co-authored-by: Nathan Moore <nathan.moore@nrel.gov>
Co-authored-by: Jing Wang <jwang5@nrel.gov>

926 of 1104 branches covered (83.88%)

Branch coverage included in aggregate %.

77 of 77 new or added lines in 4 files covered. (100.0%)

2528 of 2801 relevant lines covered (90.25%)

1.81 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.21
/geojson_modelica_translator/system_parameters/system_parameters.py
1
# :copyright (c) URBANopt, Alliance for Sustainable Energy, LLC, and other contributors.
2
# See also https://github.com/urbanopt/geojson-modelica-translator/blob/develop/LICENSE.md
3

4
import json
2✔
5
import logging
2✔
6
import math
2✔
7
import os
2✔
8
from copy import deepcopy
2✔
9
from pathlib import Path
2✔
10
from typing import Union
2✔
11

12
import pandas as pd
2✔
13
import requests
2✔
14
from jsonpath_ng.ext import parse
2✔
15
from jsonschema.validators import Draft202012Validator as LatestValidator
2✔
16

17
logger = logging.getLogger(__name__)
2✔
18
logging.basicConfig(
2✔
19
    level=logging.INFO,
20
    format='%(asctime)s - %(levelname)s: %(message)s',
21
    datefmt='%d-%b-%y %H:%M:%S',
22
)
23

24

25
class SystemParameters(object):
2✔
26
    """
27
    Object to hold the system parameter data (and schema).
28
    """
29

30
    PATH_ELEMENTS = [
2✔
31
        {"json_path": "$.buildings[?load_model=spawn].load_model_parameters.spawn.idf_filename"},
32
        {"json_path": "$.buildings[?load_model=spawn].load_model_parameters.spawn.epw_filename"},
33
        {"json_path": "$.buildings[?load_model=spawn].load_model_parameters.spawn.mos_weather_filename"},
34
        {"json_path": "$.buildings[?load_model=rc].load_model_parameters.rc.mos_weather_filename"},
35
        {"json_path": "$.buildings[?load_model=time_series].load_model_parameters.time_series.filepath"},
36
        {"json_path": "$.buildings[?load_model=time_series_massflow_temperature].load_model_parameters.time_series.filepath"},
37
        {"json_path": "$.weather"},
38
        {"json_path": "$.combined_heat_and_power_systems.[*].performance_data_path"}
39
    ]
40

41
    def __init__(self, filename=None):
2✔
42
        """
43
        Read in the system design parameter file
44

45
        :param filename: string, (optional) path to file to load
46
        """
47
        # load the schema for validation
48
        schema = Path(__file__).parent / "schema.json"
2✔
49
        self.schema = json.loads(schema.read_text())
2✔
50
        self.param_template = {}
2✔
51
        self.filename = filename
2✔
52

53
        if self.filename:
2✔
54
            if Path(self.filename).is_file():
2✔
55
                with open(self.filename, "r") as f:
2✔
56
                    self.param_template = json.load(f)
2✔
57
            else:
58
                raise Exception(f"System design parameters file does not exist: {self.filename}")
2✔
59

60
            errors = self.validate()
2✔
61
            if len(errors) != 0:
2✔
62
                raise Exception(f"Invalid system parameter file. Errors: {errors}")
2✔
63

64
            self.resolve_paths()
2✔
65

66
        self.sys_param_filename = None
2✔
67

68
    @classmethod
2✔
69
    def loadd(cls, d, validate_on_load=True):
2✔
70
        """
71
        Create a system parameters instance from a dictionary
72
        :param d: dict, system parameter data
73

74
        :return: object, SystemParameters
75
        """
76
        sp = cls()
2✔
77
        sp.param_template = d
2✔
78

79
        if validate_on_load:
2✔
80
            errors = sp.validate()
2✔
81
            if len(errors) != 0:
2✔
82
                raise Exception(f"Invalid system parameter file. Errors: {errors}")
2✔
83

84
        return sp
2✔
85

86
    def resolve_paths(self):
2✔
87
        """Resolve the paths in the file to be absolute if they were defined as relative. This method uses
88
        the JSONPath (with ext) to find if the value exists in the self.param_template object. If so, it then uses
89
        the set_param which navigates the JSON tree to set the value as needed."""
90
        filepath = Path(self.filename).parent.resolve()
2✔
91

92
        for pe in self.PATH_ELEMENTS:
2✔
93
            matches = parse(pe["json_path"]).find(self.param_template)
2✔
94
            for index, match in enumerate(matches):
2✔
95
                # print(f"Index {index} to update match {match.path} | {match.value} | {match.context}")
96
                new_path = Path(filepath) / match.value
2✔
97
                parse(str(match.full_path)).update(self.param_template, new_path.as_posix())
2✔
98

99
    # def resolve_defaults(self):
100
    #     """This method will expand the default data blocks into all the subsequent custom sections. If the value is
101
    #     specificed in the custom block then that will be used, otherwise the default value will be replaced"""
102
    #     pass
103

104
    def get_default(self, jsonpath, default=None):
2✔
105
        """Return either the default in the system parameter file, or the specified default.
106

107
        :param jsonpath: string, raw jsonpath to what parameter was being requested
108
        :param default: variant, default value
109
        :return: value
110
        """
111
        schema_default = self.get_param(jsonpath, impute_default=False)
2✔
112
        return schema_default or default
2✔
113

114
    def get_param(self, jsonpath, data=None, default=None, impute_default=True):
2✔
115
        """Return the parameter(s) from a jsonpath. If the default is not specified, then will attempt to read the
116
        default from the "default" section of the file. If there is no default there, then it will use the value
117
        specified as the argument. It is not recommended to use the argument default as those values will not be
118
        configurable. Argument-based defaults should be used sparingly.
119

120
        :param path: string, period delimited path of the data to retrieve
121
        :param data: dict, (optional) the data to parse
122
        :param default: variant, (optional) value to return if can't find the result
123
        :return: variant, the value from the data
124
        """
125
        if jsonpath is None or jsonpath == "":
2✔
126
            return None
2✔
127

128
        # If this is the first entry into the method, then set the data to the
129
        data = data or self.param_template
2✔
130
        matches = parse(jsonpath).find(data)
2✔
131

132
        default_value = default
2✔
133
        if impute_default:
2✔
134
            default_value = self.get_default(jsonpath, default)
2✔
135

136
        results = []
2✔
137
        for index, match in enumerate(matches):
2✔
138
            # print(f"Index {index} to update match {match.path} | {match.value} | {match.context}")
139
            if match.value is None:
2!
140
                results.append(default_value)
×
141
            else:
142
                results.append(match.value)
2✔
143

144
        if len(results) == 1:
2✔
145
            # If only one value, then return that value and not a list of values
146
            results = results[0]
2✔
147
        elif len(results) == 0:
2✔
148
            return default_value
2✔
149

150
        # otherwise return the list of values
151
        return results
2✔
152

153
    def get_param_by_building_id(self, building_id, jsonpath):
2✔
154
        """
155
        return a parameter for a specific building_id. This is similar to get_param but allows the user
156
        to constrain the data based on the building id.
157

158
        :param building_id: string, id of the building to look up in the custom section of the system parameters
159
        :param jsonpath: string, jsonpath formatted string to return
160
        :param default: variant, (optional) value to return if can't find the result
161
        :return: variant, the value from the data
162
        """
163

164
        for b in self.param_template.get("buildings", []):
2✔
165
            if b.get("geojson_id", None) == building_id:
2✔
166
                return self.get_param(jsonpath, data=b)
2✔
167
        else:
168
            raise SystemExit("No building_id submitted. Please retry and include the feature_id")
2✔
169

170
    def validate(self):
2✔
171
        """
172
        Validate an instance against a loaded schema
173

174
        :param instance: dict, json instance to validate
175
        :return: validation results
176
        """
177
        results = []
2✔
178
        v = LatestValidator(self.schema)
2✔
179
        for error in sorted(v.iter_errors(self.param_template), key=str):
2✔
180
            results.append(error.message)
2✔
181

182
        return results
2✔
183

184
    def download_weatherfile(self, filename, save_directory: str) -> Union[str, Path]:
2✔
185
        """Download the MOS or EPW weather file from energyplus.net
186

187
        This routine downloads the weather file, either an MOS or EPW, which is selected based
188
        on the file extension.
189

190
            filename, str: Name of weather file to download, e.g., USA_NY_Buffalo-Greater.Buffalo.Intl.AP.725280_TMY3.mos
191
            save_directory, str: Location where to save the downloaded content. The path must exist before downloading.
192
        """
193
        p_download = Path(filename)
2✔
194
        p_save = Path(save_directory)
2✔
195

196
        if not p_save.is_dir():
2✔
197
            print(f"Creating directory to save weather file, {str(p_save)}")
2✔
198
            p_save.mkdir(parents=True, exist_ok=True)
2✔
199

200
        # get country & state from weather file name
201
        try:
2✔
202
            weatherfile_location_info = p_download.parts[-1].split("_")
2✔
203
            weatherfile_country = weatherfile_location_info[0]
2✔
204
            weatherfile_state = weatherfile_location_info[1]
2✔
205
        except IndexError:
2✔
206
            raise Exception(
2✔
207
                "Malformed location, needs underscores of location (e.g., USA_NY_Buffalo-Greater.Buffalo.Intl.AP.725280_TMY3.mos)"
208
            )
209

210
        # download file from energyplus website
211
        weatherfile_url = 'https://energyplus-weather.s3.amazonaws.com/north_and_central_america_wmo_region_4/' \
2✔
212
            f'{weatherfile_country}/{weatherfile_state}/{p_download.stem}/{p_download.name}'
213
        outputname = p_save / p_download.name
2✔
214
        logger.debug(f"Downloading weather file from {weatherfile_url}")
2✔
215
        try:
2✔
216
            weatherfile_data = requests.get(weatherfile_url)
2✔
217
            if weatherfile_data.status_code == 200:
2!
218
                with open(outputname, 'wb') as f:
2✔
219
                    f.write(weatherfile_data.content)
2✔
220
            else:
221
                raise Exception(f"Returned non 200 status code trying to download weather file: {weatherfile_data.status_code}")
×
222
        except requests.exceptions.RequestException as e:
×
223
            raise Exception(
×
224
                f"Could not download weather file: {weatherfile_url}"
225
                "\nAt this time we only support USA weather stations"
226
                f"\n{e}"
227
            )
228

229
        if not outputname.exists():
2!
230
            raise Exception(f"Could not find or download weather file for {str(p_download)}")
×
231

232
        return outputname
2✔
233

234
    def make_list(self, inputs):
2✔
235
        """ Ensure that format of inputs is a list
236
        :param inputs: object, inputs (list or dict)
237
        :return: list of inputs
238
        """
239
        list_inputs = []
2✔
240
        if isinstance(inputs, dict) and len(inputs) != 0:
2✔
241
            list_inputs.append(inputs)
2✔
242
        else:
243
            list_inputs = inputs
2✔
244

245
        return list_inputs
2✔
246

247
    def process_wind(self, inputs):
2✔
248
        """
249
        Processes wind inputs and insert into template
250
        :param inputs: object, wind inputs
251
        """
252
        wind_turbines = []
2✔
253
        for item in inputs['scenario_report']['distributed_generation']['wind']:
2✔
254
            # nominal voltage - Default
255
            wt = {}
2✔
256
            wt['nominal_voltage'] = 480
2✔
257

258
            # scaling factor: parameter used by the wind turbine model
259
            # from Modelica Buildings Library, to scale the power output
260
            # without changing other parameters. Multiplies "Power curve"
261
            # value to get a scaled up power output.
262
            # add default = 1
263
            wt['scaling_factor'] = 1
2✔
264

265
            # calculate height_over_ground and power curve from REopt
266
            #  "size_class" (defaults to commercial) res = 2.5kW, com = 100kW, mid = 250kW, large = 2000kW
267
            heights = {'residential': 20, 'commercial': 40, 'midsize': 50, 'large': 80}
2✔
268
            size_class = None
2✔
269
            if item['size_class']:
2!
270
                size_class = item['size_class']
2✔
271

272
            if size_class is None:
2!
273
                size_class = 'commercial'
×
274

275
            # height over ground. default 10m
276
            wt['height_over_ground'] = heights[size_class]
2✔
277

278
            # add power curve
279
            curves = self.get_wind_power_curves()
2✔
280
            wt['power_curve'] = curves[size_class]
2✔
281

282
            # capture size_kw just in case
283
            wt['rated_power'] = item['size_kw']
2✔
284
            # and yearly energy produced
285
            wt['annual_energy_produced'] = item['average_yearly_energy_produced_kwh']
2✔
286

287
            # append to results array
288
            wind_turbines.append(wt)
2✔
289

290
        self.param_template['wind_turbines'] = wind_turbines
2✔
291

292
    def get_wind_power_curves(self):
2✔
293
        # from: https://reopt.nrel.gov/tool/REopt%20Lite%20Web%20Tool%20User%20Manual.pdf#page=61
294
        # curves given in Watts (W)
295
        power_curves = {}
2✔
296
        power_curves['residential'] = [[2, 0],
2✔
297
                                       [3, 70.542773],
298
                                       [4, 167.2125],
299
                                       [5, 326.586914],
300
                                       [6, 564.342188],
301
                                       [7, 896.154492],
302
                                       [8, 1337.7],
303
                                       [9, 1904.654883],
304
                                       [10, 2500]]
305
        power_curves['commercial'] = [[2, 0],
2✔
306
                                      [3, 3505.95],
307
                                      [4, 8310.4],
308
                                      [5, 16231.25],
309
                                      [6, 28047.6],
310
                                      [7, 44538.55],
311
                                      [8, 66483.2],
312
                                      [9, 94660.65],
313
                                      [10, 100000]]
314
        power_curves['midsize'] = [[2, 0],
2✔
315
                                   [3, 8764.875],
316
                                   [4, 20776],
317
                                   [5, 40578.125],
318
                                   [6, 70119],
319
                                   [7, 111346.375],
320
                                   [8, 166208],
321
                                   [9, 236651.625],
322
                                   [10, 250000]]
323

324
        power_curves['large'] = [[2, 0],
2✔
325
                                 [3, 70119],
326
                                 [4, 166208],
327
                                 [5, 324625],
328
                                 [6, 560952],
329
                                 [7, 890771],
330
                                 [8, 1329664],
331
                                 [9, 1893213],
332
                                 [10, 2000000]]
333
        return power_curves
2✔
334

335
    def process_pv(self, inputs, latitude):
2✔
336
        """
337
        Processes pv inputs
338
        :param inputs: object, pv inputs
339
        :return photovoltaic_panels section to be inserted per-building or globally
340
        """
341

342
        items = self.make_list(inputs)
2✔
343
        pvs = []
2✔
344
        # hardcode nominal_system_voltage 480V
345
        # add latitude
346
        for item in items:
2✔
347
            pv = {}
2✔
348
            pv['nominal_voltage'] = 480
2✔
349
            pv['latitude'] = latitude
2✔
350
            if 'tilt' in item:
2!
351
                pv['surface_tilt'] = item['tilt']
2✔
352
            else:
353
                pv['surface_tilt'] = 0
×
354
            if 'azimuth' in item:
2!
355
                pv['surface_azimuth'] = item['azimuth']
2✔
356
            else:
357
                pv['surface_azimuth'] = 0
×
358

359
            # Size (kW) = Array Area (m²) × 1 kW/m² × Module Efficiency (%)
360
            # area = size (kW) / 1 kW/m2 / module efficiency (%)
361
            # module efficiency tied to module type: 0 -> standard: 15%, 1-> premium: 19%, 2-> thin film: 10%
362
            # defaults to standard
363
            efficiencies = {0: 15, 1: 19, 2: 10}
2✔
364
            module_type = 0
2✔
365
            if 'module_type' in item:
2!
366
                module_type = item['module_type']
2✔
367

368
            eff = efficiencies[module_type]
2✔
369
            pv['net_surface_area'] = item['size_kw'] / eff
2✔
370

371
            pvs.append(pv)
2✔
372

373
        return pvs
2✔
374

375
    def process_chp(self, inputs):
2✔
376
        """
377
        Processes global chp inputs and insert into template
378
        :param inputs: object, raw inputs
379
        """
380
        # this uses the raw inputs
381
        items = self.make_list(inputs['outputs']['Scenario']['Site']['CHP'])
2✔
382
        chps = []
2✔
383
        for item in items:
2✔
384
            # fuel type. options are: natural_gas (default), landfill_bio_gas, propane, diesel_oil
385
            chp = {}
2✔
386
            chp['fuel_type'] = 'natural_gas'
2✔
387
            if inputs['inputs']['Scenario']['Site']['FuelTariff']["chp_fuel_type"]:
2!
388
                chp['fuel_type'] = inputs['inputs']['Scenario']['Site']['FuelTariff']["chp_fuel_type"]
2✔
389

390
            # single_electricity_generation_capacity
391
            chp['single_electricity_generation_capacity'] = item['size_kw']
2✔
392

393
            # performance data filename
394
            # TODO: not sure how to pass this in
395
            # how to default this? retrieve from the template, or right here in code?
396
            chp['performance_data_path'] = ''
2✔
397

398
            # number of machines
399
            # TODO: not in REopt...default?
400
            chp['number_of_machines'] = 1
2✔
401

402
            chps.append(chp)
2✔
403

404
        self.param_template['combined_heat_and_power_systems'] = chps
2✔
405

406
    def process_storage(self, inputs):
2✔
407
        """
408
        Processes global battery bank outputs and insert into template
409
        :param inputs: object, raw inputs
410
        """
411
        # this uses the raw inputs
412
        items = []
2✔
413
        try:
2✔
414
            items = self.make_list(inputs['scenario_report']['distributed_generation']['storage'])
2✔
415
        except KeyError:
×
416
            pass
×
417

418
        batts = []
2✔
419
        for item in items:
2✔
420

421
            batt = {}
2✔
422

423
            # energy capacity 'size_kwh' % 1000 to convert to MWh
424
            batt['capacity'] = item['size_kwh'] / 1000
2✔
425

426
            # Nominal Voltage - DEFAULT
427
            batt['nominal_voltage'] = 480
2✔
428

429
            batts.append(batt)
2✔
430

431
        self.param_template['battery_banks'] = batts
2✔
432

433
    def process_generators(self, inputs):
2✔
434
        """
435
        Processes generators outputs and insert into template
436
        :param inputs: object, raw inputs
437
        """
438
        # this uses the raw inputs
439
        items = []
×
440
        try:
×
441
            items = self.make_list(inputs['scenario_report']['distributed_generation']['generators'])
×
442
        except KeyError:
×
443
            pass
×
444

445
        generators = []
×
446
        for item in items:
×
447

448
            generator = {}
×
449

450
            # size_kw, then convert to W
451
            generator['nominal_power_generation'] = item['size_kw'] * 1000
×
452

453
            # source phase shift
454
            # TODO: Not in REopt
455
            generator['source_phase_shift'] = 0
×
456

457
            generators.append(generator)
×
458

459
        self.param_template['diesel_generators'] = generators
×
460

461
    def process_grid(self):
2✔
462
        grid = {}
2✔
463

464
        # frequency - default
465
        grid['frequency'] = 60
2✔
466
        # TODO: RMS voltage source - default
467
        # grid['source_rms_voltage'] = 0
468

469
        # TODO: phase shift (degrees) - default
470
        # grid['source_phase_shift'] = 0
471

472
        self.param_template['electrical_grid'] = grid
2✔
473

474
    def process_electrical_components(self, scenario_dir: Path):
2✔
475
        """ process electrical results from OpenDSS
476
            electrical grid
477
            substations
478
            transformers
479
            distribution lines
480
            capacitor banks (todo)
481
        """
482
        dss_data = {}
2✔
483
        opendss_json_file = os.path.join(scenario_dir, 'scenario_report_opendss.json')
2✔
484
        if (os.path.exists(opendss_json_file)):
2✔
485
            with open(opendss_json_file, "r") as f:
2✔
486
                dss_data = json.load(f)
2✔
487

488
        if dss_data:
2✔
489
            # ELECTRICAL GRID: completely defaulted for now
490
            self.process_grid()
2✔
491

492
            # SUBSTATIONS
493
            substations = []
2✔
494
            try:
2✔
495
                data = dss_data['scenario_report']['scenario_power_distribution']['substations']
2✔
496
                for item in data:
2✔
497
                    try:
2✔
498
                        s = {}
2✔
499
                        # TODO: default RNM Voltage (high side?)
500

501
                        # RMS Voltage (low side)
502
                        s['RMS_voltage_low_side'] = item['nominal_voltage']
2✔
503
                        substations.append(s)
2✔
504
                    except KeyError:
×
505
                        pass
×
506
            except KeyError:
×
507
                pass
×
508

509
            self.param_template['substations'] = substations
2✔
510

511
            # DISTRIBUTION LINES
512
            lines = []
2✔
513
            try:
2✔
514
                data = dss_data['scenario_report']['scenario_power_distribution']['distribution_lines']
2✔
515
                for item in data:
2✔
516
                    try:
2✔
517
                        line = {}
2✔
518
                        line['length'] = item['length']
2✔
519
                        line['ampacity'] = item['ampacity']
2✔
520

521
                        # nominal voltage is defaulted (data not available in OpenDSS)
522
                        line['nominal_voltage'] = 480
2✔
523

524
                        line['commercial_line_type'] = item['commercial_line_type']
2✔
525

526
                        lines.append(line)
2✔
527
                    except KeyError:
×
528
                        pass
×
529
            except KeyError:
×
530
                pass
×
531

532
            self.param_template['distribution_lines'] = lines
2✔
533

534
            # CAPACITOR BANKS
535
            caps = []
2✔
536
            try:
2✔
537
                data = dss_data['scenario_report']['scenario_power_distribution']['capacitors']
2✔
538
                for item in data:
2!
539
                    try:
×
540
                        cap = {}
×
541
                        # nominal capacity (var)
542
                        cap['nominal_capacity'] = item['nominal_capacity']
×
543

544
                        caps.append(cap)
×
545
                    except KeyError:
×
546
                        pass
×
547
            except KeyError:
×
548
                pass
×
549

550
            self.param_template['capacitor_banks'] = caps
2✔
551

552
            # TRANSFORMERS
553
            transformers = []
2✔
554
            data = [d for d in dss_data['feature_reports'] if d['id'].startswith('Transformer')]
2✔
555
            for item in data:
2✔
556
                t = {}
2✔
557
                t['id'] = item['id']
2✔
558
                t['nominal_capacity'] = None
2✔
559
                if item['power_distribution']['nominal_capacity']:
2!
560
                    t['nominal_capacity'] = item['power_distribution']['nominal_capacity']
2✔
561

562
                t['reactance_resistance_ratio'] = None
2✔
563
                if item['power_distribution']['reactance_resistance_ratio']:
2!
564
                    t['reactance_resistance_ratio'] = item['power_distribution']['reactance_resistance_ratio']
2✔
565
                transformers.append(t)
2✔
566

567
            self.param_template['transformers'] = transformers
2✔
568

569
            # Loads (buildings from geojson file)
570
            # grab all the building loads
571
            data = [d for d in dss_data['feature_reports'] if d['feature_type'] == 'Building']
2✔
572

573
            # grab records to modify
574
            for bldg in self.param_template['buildings']:
2✔
575
                # find match in data
576
                match = [d for d in data if d['id'] == bldg['geojson_id']]
2✔
577
                if match:
2!
578
                    # add data
579
                    bldg['load'] = {}
2✔
580
                    # print("Found match for {}: {}".format(bldg['geojson_id'], match[0]['id']))
581
                    bldg['load']['nominal_voltage'] = match[0]['power_distribution']['nominal_voltage']
2✔
582
                    bldg['load']['max_power_kw'] = match[0]['power_distribution']['max_power_kw']
2✔
583
                    bldg['load']['max_reactive_power_kvar'] = match[0]['power_distribution']['max_reactive_power_kvar']
2✔
584

585
    def process_building_microgrid_inputs(self, building, scenario_dir: Path):
2✔
586
        """
587
        Processes microgrid inputs for a single building
588
        :param building: list, building
589
        :param scenario_dir: Path, location/name of folder with uo_sdk results
590
        :return building, updated building list object
591
        """
592
        feature_opt_file = os.path.join(
2✔
593
            scenario_dir, building['geojson_id'], 'feature_reports', 'feature_optimization.json')
594
        if (os.path.exists(feature_opt_file)):
2!
595
            with open(feature_opt_file, "r") as f:
2✔
596
                reopt_data = json.load(f)
2✔
597

598
        # extract Latitude
599
        try:
2✔
600
            latitude = reopt_data['location']['latitude_deg']
2✔
601
        except KeyError:
×
602
            logger.info(f"Latitude not found in {feature_opt_file}. Skipping PV.")
×
603
        except UnboundLocalError:
×
604
            logger.info(f"REopt data not found in {feature_opt_file}. Skipping PV.")
×
605

606
        # PV
607
        if reopt_data['distributed_generation'] and reopt_data['distributed_generation']['solar_pv']:
2!
608
            building['photovoltaic_panels'] = self.process_pv(
2✔
609
                reopt_data['distributed_generation']['solar_pv'], latitude)
610

611
        return building
2✔
612

613
    def process_microgrid_inputs(self, scenario_dir: Path):
2✔
614
        """
615
        Processes microgrid inputs and adds them to param_template from csv_to_sys_param method
616
        :param scenario_dir: Path, location/name of folder with uo_sdk results
617
        """
618
        reopt_data = {}
2✔
619
        raw_data = {}
2✔
620
        # look for REopt scenario_optimization.json file in scenario dir (uo report)
621
        scenario_opt_file = os.path.join(scenario_dir, 'scenario_optimization.json')
2✔
622
        if (os.path.exists(scenario_opt_file)):
2✔
623
            with open(scenario_opt_file, "r") as f:
2✔
624
                reopt_data = json.load(f)
2✔
625
        # also look for raw REopt report with inputs and xzx for non-uo results
626
        raw_scenario_file = os.path.join(scenario_dir, 'reopt', f'scenario_report_{scenario_dir.name}_reopt_run.json')
2✔
627
        if (os.path.exists(raw_scenario_file)):
2✔
628
            with open(raw_scenario_file, "r") as f:
2✔
629
                raw_data = json.load(f)
2✔
630

631
        # PV (add if results are found in scenario_report)
632
        # extract latitude
633
        try:
2✔
634
            latitude = reopt_data['scenario_report']['location']['latitude_deg']
2✔
635
            if reopt_data['scenario_report']['distributed_generation']['solar_pv']:
2!
636
                self.param_template['photovoltaic_panels'] = self.process_pv(
2✔
637
                    reopt_data['scenario_report']['distributed_generation']['solar_pv'],
638
                    latitude
639
                )
640
        except KeyError:
2✔
641
            logger.info("Latitude not found in scenario_report. Skipping PV.")
2✔
642

643
        # Wind (add if results are found in scenario_report)
644
        # if reopt_data['scenario_report']['distributed_generation']['wind']:
645
        try:
2✔
646
            self.process_wind(reopt_data)
2✔
647
        except KeyError:
2✔
648
            logger.info("Wind data not found in scenario_report. Skipping wind.")
2✔
649

650
        # CHP (add if results are found in reopt results-raw_data)
651
        # this is the only item not in the default URBANopt report file
652
        if Path(raw_scenario_file).exists() and raw_data['outputs']['Scenario']['Site']['CHP']['size_kw'] != 0.0:
2✔
653
            # there is a CHP, process
654
            self.process_chp(raw_data)
2✔
655

656
        # Battery Bank
657
        try:
2✔
658
            if reopt_data['scenario_report']['distributed_generation']['storage']:
2✔
659
                # there is storage, process
660
                self.process_storage(reopt_data)
2✔
661
        except KeyError:
2✔
662
            logger.info("Energy storage data not found in scenario_report. Skipping storage.")
2✔
663

664
        # Generators
665
        try:
2✔
666
            if reopt_data['scenario_report']['distributed_generation']['generators']:
2!
667
                # process diesel generators
668
                self.process_generators(reopt_data)
×
669
        except KeyError:
2✔
670
            logger.info("Generator data not found in scenario_report. Skipping generator.")
2✔
671

672
        # process electrical components (from OpenDSS results)
673
        self.process_electrical_components(scenario_dir)
2✔
674

675
        # Power Converters
676
        # TODO: not handled in UO / OpenDSS
677

678
    def calculate_dimensions(self, area, perimeter):
2✔
679

680
        discriminant = perimeter ** 2 - 16 * area
2✔
681

682
        if discriminant < 0:
2!
683
            raise ValueError("No valid rectangle dimensions exist for the given area and perimeter.")
×
684

685
        length = (perimeter + math.sqrt(discriminant)) / 4
2✔
686
        width = (perimeter - 2 * length) / 2
2✔
687

688
        return length, width
2✔
689

690
    def csv_to_sys_param(self,
2✔
691
                         model_type: str,
692
                         scenario_dir: Path,
693
                         feature_file: Path,
694
                         sys_param_filename: Path,
695
                         ghe=False,
696
                         overwrite=True,
697
                         microgrid=False) -> None:
698
        """
699
        Create a system parameters file using output from URBANopt SDK
700

701
        :param model_type: str, model type to select which sys_param template to use
702
        :param scenario_dir: Path, location/name of folder with uo_sdk results
703
        :param feature_file: Path, location/name of uo_sdk input file
704
        :param sys_param_filename: Path, location/name of system parameter file to be created
705
        :param overwrite: Boolean, whether to overwrite existing sys-param file
706
        :param ghe: Boolean, flag to add Ground Heat Exchanger properties to System Parameter File
707
        :param microgrid: Boolean, Optional. If set to true, also process microgrid fields
708
        :return None, file created and saved to user-specified location
709
        """
710
        self.sys_param_filename = sys_param_filename
2✔
711

712
        if model_type == 'time_series':
2✔
713
            # TODO: delineate between time_series and time_series_mft
714
            if microgrid:
2✔
715
                param_template_path = Path(__file__).parent / 'time_series_microgrid_template.json'
2✔
716
            else:
717
                param_template_path = Path(__file__).parent / 'time_series_template.json'
2✔
718
        elif model_type == 'spawn':
2!
719
            # TODO: We should support spawn as well
720
            raise SystemExit('Spawn models are not implemented at this time.')
×
721
        else:
722
            raise SystemExit(f"No template found. {model_type} is not a valid template")
2✔
723

724
        if not Path(scenario_dir).is_dir():
2✔
725
            raise SystemExit(f"Unable to find your scenario. The path you provided was: {scenario_dir}")
2✔
726

727
        if not Path(feature_file).is_file():
2✔
728
            raise SystemExit(f"Unable to find your feature file. The path you provided was: {feature_file}")
2✔
729

730
        if Path(self.sys_param_filename).is_file() and not overwrite:
2✔
731
            raise SystemExit(f"Output file already exists and overwrite is False: {self.sys_param_filename}")
2✔
732

733
        with open(param_template_path, "r") as f:
2✔
734
            self.param_template = json.load(f)
2✔
735

736
        measure_list = []
2✔
737

738
        # Grab building load filepaths from sdk output
739
        for thing in scenario_dir.iterdir():
2✔
740
            if thing.is_dir():
2✔
741
                for item in thing.iterdir():
2✔
742
                    if item.is_dir():
2✔
743
                        if str(item).endswith('_export_time_series_modelica'):
2✔
744
                            measure_list.append(Path(item) / "building_loads.csv")  # used for mfrt
2✔
745
                        elif str(item).endswith('_export_modelica_loads'):
2✔
746
                            measure_list.append(Path(item) / "modelica.mos")  # space heating/cooling & water heating
2✔
747
                            measure_list.append(Path(item) / "building_loads.csv")  # used for max electricity load
2✔
748

749
        # Get each building feature id from the SDK FeatureFile
750
        building_ids = []
2✔
751
        with open(feature_file) as json_file:
2✔
752
            sdk_input = json.load(json_file)
2✔
753
        weather_filename = sdk_input['project']['weather_filename']
2✔
754
        weather_path = self.sys_param_filename.parent / weather_filename
2✔
755
        for feature in sdk_input['features']:
2✔
756
            if feature['properties']['type'] == 'Building':
2✔
757
                building_ids.append(feature['properties']['id'])
2✔
758

759
        # Check if the EPW weatherfile exists, if not, try to download
760
        if not weather_path.exists():
2✔
761
            self.download_weatherfile(weather_path.name, weather_path.parent)
2✔
762

763
        # also download the MOS weatherfile -- this is the file that will be set in the sys param file
764
        mos_weather_path = weather_path.with_suffix('.mos')
2✔
765
        if not mos_weather_path.exists():
2✔
766
            self.download_weatherfile(mos_weather_path.name, mos_weather_path.parent)
2✔
767

768
        # Make sys_param template entries for each feature_id
769
        building_list = []
2✔
770
        for building in building_ids:
2✔
771
            feature_info = deepcopy(self.param_template['buildings'][0])
2✔
772
            feature_info['geojson_id'] = str(building)
2✔
773
            building_list.append(feature_info)
2✔
774

775
        # Grab the modelica file for the each Feature, and add it to the appropriate building dict
776
        district_nominal_mfrt = 0
2✔
777
        for building in building_list:
2✔
778
            building_nominal_mfrt = 0
2✔
779
            for measure_file_path in measure_list:
2✔
780
                # Grab the relevant 2 components of the path: feature name and measure folder name, items -3 & -2 respectively
781
                feature_name = Path(measure_file_path).parts[-3]
2✔
782
                measure_folder_name = Path(measure_file_path).parts[-2]
2✔
783
                if feature_name != building['geojson_id']:
2✔
784
                    continue
2✔
785
                if (measure_file_path.suffix == '.mos'):
2✔
786
                    building['load_model_parameters']['time_series']['filepath'] = str(measure_file_path.resolve())
2✔
787
                if (measure_file_path.suffix == '.csv') and ('_export_time_series_modelica' in str(measure_folder_name)):
2✔
788
                    mfrt_df = pd.read_csv(measure_file_path)
2✔
789
                    try:
2✔
790
                        building_nominal_mfrt = round(mfrt_df['massFlowRateHeating'].max(), 3)  # round max to 3 decimal places
2✔
791
                        # Force casting to float even if building_nominal_mfrt == 0
792
                        # FIXME: This might be related to building_type == `lodging` for non-zero building percentages
793
                        building['ets_indirect_parameters']['nominal_mass_flow_building'] = float(building_nominal_mfrt)
2✔
794
                    except KeyError:
×
795
                        # If massFlowRateHeating is not in the export_time_series_modelica output, just skip this step.
796
                        # It probably won't be in the export for hpxml residential buildings, at least as of 2022-06-29
797
                        logger.info("mass-flow-rate heating is not present. It is not expected in residential buildings. Skipping.")
×
798
                        continue
×
799
                district_nominal_mfrt += building_nominal_mfrt
2✔
800
                if measure_file_path.suffix == '.csv' and measure_folder_name.endswith('_export_modelica_loads'):
2✔
801
                    try:
2✔
802
                        building_loads = pd.read_csv(measure_file_path, usecols=['ElectricityFacility'])  # usecols to make the df small
2✔
803
                    except ValueError:  # hack to handle the case where there is no ElectricityFacility column in the csv
2✔
804
                        continue
2✔
805
                    max_electricity_load = int(building_loads['ElectricityFacility'].max())
2✔
806
                    building['load_model_parameters']['time_series']['max_electrical_load'] = max_electricity_load
2✔
807

808
        # Remove template buildings that weren't used or don't have successful simulations with modelica outputs
809
        # TODO: Another place where we only support time series for now.
810
        building_list = [x for x in building_list if not x['load_model_parameters']
2✔
811
                         ['time_series']['filepath'].endswith("populated")]
812
        if len(building_list) == 0:
2!
813
            raise SystemExit("No Modelica files found. Modelica files are expected to be found within each feature in folders "
×
814
                             "with names that include '_modelica'\n"
815
                             f"For instance: {scenario_dir / '2' / '016_export_modelica_loads'}\n"
816
                             "If these files don't exist the UO SDK simulations may not have been successful")
817

818
        # Update specific sys-param settings for each building
819
        for building in building_list:
2✔
820
            building['ets_indirect_parameters']['nominal_mass_flow_district'] = district_nominal_mfrt
2✔
821
            feature_opt_file = scenario_dir / building['geojson_id'] / 'feature_reports' / 'feature_optimization.json'
2✔
822
            if microgrid and not feature_opt_file.exists():
2!
823
                logger.debug(f"No feature optimization file found for {building['geojson_id']}. Skipping REopt for this building")
×
824
            elif microgrid and feature_opt_file.exists():
2✔
825
                building = self.process_building_microgrid_inputs(building, scenario_dir)
2✔
826

827
        # Add all buildings to the sys-param file
828
        self.param_template['buildings'] = building_list
2✔
829

830
        # Update district sys-param settings
831
        # Parens are to allow the line break
832
        self.param_template['weather'] = str(mos_weather_path)
2✔
833
        if microgrid and not feature_opt_file.exists():
2!
834
            logger.warn("Microgrid requires OpenDSS and REopt feature optimization for full functionality.\n"
×
835
                        "Run opendss and reopt-feature post-processing in the UO SDK for a full-featured microgrid.")
836
        try:
2✔
837
            self.process_microgrid_inputs(scenario_dir)
2✔
838
        except UnboundLocalError:
×
839
            raise SystemExit(f"\nError: No scenario_optimization.json file found in {scenario_dir}\n"
×
840
                             "Perhaps you haven't run REopt post-processing step in the UO sdk?")
841

842
        # Update ground heat exchanger properties if true
843
        if ghe:
2✔
844

845
            ghe_ids = []
2✔
846
            # add properties from the feature file
847
            with open(feature_file) as json_file:
2✔
848
                sdk_input = json.load(json_file)
2✔
849
            for feature in sdk_input['features']:
2✔
850
                if feature['properties']['type'] == 'District System':
2✔
851
                    try:
2✔
852
                        district_system_type = feature['properties']['district_system_type']
2✔
853
                    except KeyError:
×
854
                        pass
×
855
                    if district_system_type == 'Ground Heat Exchanger':
2!
856
                        length, width = self.calculate_dimensions(feature['properties']['footprint_area'], feature['properties']['footprint_perimeter'])
2✔
857
                        ghe_ids.append({'ghe_id': feature['properties']['id'],
2✔
858
                                        'length_of_ghe': length,
859
                                        'width_of_ghe': width})
860

861
            ghe_sys_param = self.param_template['district_system']['fifth_generation']['ghe_parameters']
2✔
862
            # Make sys_param template entries for GHE specific properties
863
            ghe_list = []
2✔
864
            for ghe in ghe_ids:
2✔
865
                # update GHE specific properties
866
                ghe_info = deepcopy(ghe_sys_param['ghe_specific_params'][0])
2✔
867
                # Update GHE ID
868
                ghe_info['ghe_id'] = str(ghe['ghe_id'])
2✔
869
                # Add ghe geometric properties
870
                ghe_info['ghe_geometric_params']['length_of_ghe'] = ghe['length_of_ghe']
2✔
871
                ghe_info['ghe_geometric_params']['width_of_ghe'] = ghe['width_of_ghe']
2✔
872
                ghe_list.append(ghe_info)
2✔
873

874
            # Add all GHE specific properties to sys-param file
875
            ghe_sys_param['ghe_specific_params'] = ghe_list
2✔
876

877
            # Update ghe_dir
878
            ghe_dir = scenario_dir / 'ghe_dir'
2✔
879
            ghe_sys_param['ghe_dir'] = str(ghe_dir)
2✔
880

881
            # remove fourth generation district system type
882
            del self.param_template['district_system']['fourth_generation']
2✔
883

884
        else:
885
            # remove fifth generation district system type if it exists in template and ghe is not true
886
            try:
2✔
887
                del self.param_template['district_system']['fifth_generation']
2✔
888
            except KeyError:
2✔
889
                pass
2✔
890

891
        # save the file to disk
892
        self.save()
2✔
893

894
    def save(self):
2✔
895
        """
896
        Write the system parameters file with param_template and save
897
        """
898
        with open(self.sys_param_filename, 'w') as outfile:
2✔
899
            json.dump(self.param_template, outfile, indent=2)
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc