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

urbanopt / geojson-modelica-translator / 5834567920

pending completion
5834567920

Pull #569

github-actions

vtnate
Merge branch 'develop' into microgrid-subsystem
Pull Request #569: Template initial microgrid subsystem example

927 of 1104 branches covered (83.97%)

Branch coverage included in aggregate %.

28 of 28 new or added lines in 3 files covered. (100.0%)

2549 of 2823 relevant lines covered (90.29%)

1.81 hits per line

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

87.17
/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'] = item['power_distribution'].get('nominal_capacity', None)
2✔
559
                t['reactance_resistance_ratio'] = item['power_distribution'].get('reactance_resistance_ratio', None)
2✔
560
                t['tx_incoming_voltage'] = item['power_distribution'].get('tx_incoming_voltage', None)
2✔
561
                t['tx_incoming_voltage'] = item['power_distribution'].get('tx_incoming_voltage', None)
2✔
562

563
                # Validate transformer input voltage is same as substation output voltage
564
                if t['tx_incoming_voltage'] is not None and t['tx_incoming_voltage'] != self.param_template['substations']['RMS_voltage_low_side']:
2!
565
                    raise ValueError(f"Transformer input voltage {t['tx_incoming_voltage']} does not "
×
566
                                     f"match substation output voltage {self.param_template['substations']['RMS_voltage_low_side']}")
567

568
                transformers.append(t)
2✔
569

570
            self.param_template['transformers'] = transformers
2✔
571

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

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

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

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

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

614
        return building
2✔
615

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

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

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

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

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

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

675
        # process electrical components (from OpenDSS results)
676
        self.process_electrical_components(scenario_dir)
2✔
677

678
        # Power Converters
679
        # TODO: not handled in UO / OpenDSS
680

681
    def calculate_dimensions(self, area, perimeter):
2✔
682

683
        discriminant = perimeter ** 2 - 16 * area
2✔
684

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

688
        length = (perimeter + math.sqrt(discriminant)) / 4
2✔
689
        width = (perimeter - 2 * length) / 2
2✔
690

691
        return length, width
2✔
692

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

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

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

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

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

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

736
        with open(param_template_path, "r") as f:
2✔
737
            self.param_template = json.load(f)
2✔
738

739
        measure_list = []
2✔
740

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

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

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

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

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

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

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

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

830
        # Add all buildings to the sys-param file
831
        self.param_template['buildings'] = building_list
2✔
832

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

845
        # Update ground heat exchanger properties if true
846
        if ghe:
2✔
847

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

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

877
            # Add all GHE specific properties to sys-param file
878
            ghe_sys_param['ghe_specific_params'] = ghe_list
2✔
879

880
            # Update ghe_dir
881
            ghe_dir = scenario_dir / 'ghe_dir'
2✔
882
            ghe_sys_param['ghe_dir'] = str(ghe_dir)
2✔
883

884
            # remove fourth generation district system type
885
            del self.param_template['district_system']['fourth_generation']
2✔
886

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

894
        # save the file to disk
895
        self.save()
2✔
896

897
    def save(self):
2✔
898
        """
899
        Write the system parameters file with param_template and save
900
        """
901
        with open(self.sys_param_filename, 'w') as outfile:
2✔
902
            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