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

urbanopt / geojson-modelica-translator / 6189157937

14 Sep 2023 05:57PM UTC coverage: 87.321% (-0.1%) from 87.423%
6189157937

Pull #569

github-actions

vtnate
Merge branch 'fix-poetry-ci-failures' into microgrid-subsystem
Pull Request #569: Template initial microgrid subsystem example

1002 of 1208 branches covered (0.0%)

Branch coverage included in aggregate %.

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

2717 of 3051 relevant lines covered (89.05%)

1.78 hits per line

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

86.76
/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
from copy import deepcopy
2✔
8
from pathlib import Path
2✔
9
from typing import Union
2✔
10

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

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

23

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

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

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

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

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

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

63
            self.resolve_paths()
2✔
64

65
        self.sys_param_filename = None
2✔
66

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

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

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

83
        return sp
2✔
84

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

181
        return results
2✔
182

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

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

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

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

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

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

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

231
        return outputname
2✔
232

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

244
        return list_inputs
2✔
245

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

370
            pvs.append(pv)
2✔
371

372
        return pvs
2✔
373

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

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

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

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

401
            chps.append(chp)
2✔
402

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

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

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

420
            batt = {}
2✔
421

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

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

428
            batts.append(batt)
2✔
429

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

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

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

447
            generator = {}
×
448

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

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

456
            generators.append(generator)
×
457

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

567
                transformers.append(t)
2✔
568

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

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

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

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

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

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

613
        return building
2✔
614

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

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

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

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

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

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

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

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

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

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

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

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

690
        return length, width
2✔
691

692
    def csv_to_sys_param(self,
2✔
693
                         model_type: str,
694
                         scenario_dir: Path,
695
                         feature_file: Path,
696
                         sys_param_filename: Path,
697
                         ghe=False,
698
                         overwrite=True,
699
                         microgrid=False,
700
                         **kwargs) -> 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

712
        :kwargs (optional):
713
            - relative_path: Path, set the paths (time series files, weather file, etc) relate to `relative_path`
714
        :return None, file created and saved to user-specified location
715

716

717
        """
718
        self.sys_param_filename = sys_param_filename
2✔
719
        self.rel_path = kwargs.get('relative_path', None)
2✔
720

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

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

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

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

742
        with open(param_template_path, "r") as f:
2✔
743
            self.param_template = json.load(f)
2✔
744

745
        measure_list = []
2✔
746

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

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

768
        # Check if the EPW weatherfile exists, if not, try to download
769
        if not weather_path.exists():
2✔
770
            self.download_weatherfile(weather_path.name, weather_path.parent)
2✔
771

772
        # also download the MOS weatherfile -- this is the file that will be set in the sys param file
773
        mos_weather_path = weather_path.with_suffix('.mos')
2✔
774
        if not mos_weather_path.exists():
2✔
775
            self.download_weatherfile(mos_weather_path.name, mos_weather_path.parent)
2✔
776

777
        # Make sys_param template entries for each feature_id
778
        building_list = []
2✔
779
        for building in building_ids:
2✔
780
            feature_info = deepcopy(self.param_template['buildings'][0])
2✔
781
            feature_info['geojson_id'] = str(building)
2✔
782
            building_list.append(feature_info)
2✔
783

784
        # Grab the modelica file for the each Feature, and add it to the appropriate building dict
785
        district_nominal_massflow_rate = 0
2✔
786
        for building in building_list:
2✔
787
            building_nominal_massflow_rate = 0
2✔
788
            for measure_file_path in measure_list:
2✔
789
                # Grab the relevant 2 components of the path: feature name and measure folder name, items -3 & -2 respectively
790
                feature_name = Path(measure_file_path).parts[-3]
2✔
791
                measure_folder_name = Path(measure_file_path).parts[-2]
2✔
792
                if feature_name != building['geojson_id']:
2✔
793
                    continue
2✔
794
                if (measure_file_path.suffix == '.mos'):
2✔
795
                    # if there is a relative path, then set the path relative
796
                    to_file_path = measure_file_path.resolve()
2✔
797
                    if self.rel_path:
2!
798
                        to_file_path = to_file_path.relative_to(self.rel_path)
×
799

800
                    building['load_model_parameters']['time_series']['filepath'] = str(to_file_path)
2✔
801
                if (measure_file_path.suffix == '.csv') and ('_export_time_series_modelica' in str(measure_folder_name)):
2✔
802
                    massflow_rate_df = pd.read_csv(measure_file_path)
2✔
803
                    try:
2✔
804
                        building_nominal_massflow_rate = round(massflow_rate_df['massFlowRateHeating'].max(), 3)  # round max to 3 decimal places
2✔
805
                        # Force casting to float even if building_nominal_massflow_rate == 0
806
                        # FIXME: This might be related to building_type == `lodging` for non-zero building percentages
807
                        building['ets_indirect_parameters']['nominal_mass_flow_building'] = float(building_nominal_massflow_rate)
2✔
808
                    except KeyError:
×
809
                        # If massFlowRateHeating is not in the export_time_series_modelica output, just skip this step.
810
                        # It probably won't be in the export for hpxml residential buildings, at least as of 2022-06-29
811
                        logger.info("mass-flow-rate heating is not present. It is not expected in residential buildings. Skipping.")
×
812
                        continue
×
813
                district_nominal_massflow_rate += building_nominal_massflow_rate
2✔
814
                if measure_file_path.suffix == '.csv' and measure_folder_name.endswith('_export_modelica_loads'):
2✔
815
                    try:
2✔
816
                        building_loads = pd.read_csv(measure_file_path, usecols=['ElectricityFacility'])  # only use the one column to make the df small
2✔
817
                    except ValueError:  # hack to handle the case where there is no ElectricityFacility column in the csv
2✔
818
                        continue
2✔
819
                    max_electricity_load = int(building_loads['ElectricityFacility'].max())
2✔
820
                    building['load_model_parameters']['time_series']['max_electrical_load'] = max_electricity_load
2✔
821

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

832
        # Update specific sys-param settings for each building
833
        for building in building_list:
2✔
834
            building['ets_indirect_parameters']['nominal_mass_flow_district'] = district_nominal_massflow_rate
2✔
835
            feature_opt_file = scenario_dir / building['geojson_id'] / 'feature_reports' / 'feature_optimization.json'
2✔
836
            if microgrid and not feature_opt_file.exists():
2!
837
                logger.debug(f"No feature optimization file found for {building['geojson_id']}. Skipping REopt for this building")
×
838
            elif microgrid and feature_opt_file.exists():
2✔
839
                building = self.process_building_microgrid_inputs(building, scenario_dir)
2✔
840

841
        # Add all buildings to the sys-param file
842
        self.param_template['buildings'] = building_list
2✔
843

844
        # Update district sys-param settings
845
        # Parens are to allow the line break
846
        to_file_path = mos_weather_path
2✔
847
        if self.rel_path:
2!
848
            to_file_path = to_file_path.relative_to(self.rel_path)
×
849
        self.param_template['weather'] = str(to_file_path)
2✔
850
        if microgrid and not feature_opt_file.exists():
2!
851
            logger.warn("Microgrid requires OpenDSS and REopt feature optimization for full functionality.\n"
×
852
                        "Run opendss and reopt-feature post-processing in the UO SDK for a full-featured microgrid.")
853
        try:
2✔
854
            self.process_microgrid_inputs(scenario_dir)
2✔
855
        except UnboundLocalError:
×
856
            raise SystemExit(f"\nError: No scenario_optimization.json file found in {scenario_dir}\n"
×
857
                             "Perhaps you haven't run REopt post-processing step in the UO sdk?")
858

859
        # Update ground heat exchanger properties if true
860
        if ghe:
2✔
861
            ghe_ids = []
2✔
862
            # add properties from the feature file
863
            with open(feature_file) as json_file:
2✔
864
                sdk_input = json.load(json_file)
2✔
865
            for feature in sdk_input['features']:
2✔
866
                if feature['properties']['type'] == 'District System':
2✔
867
                    try:
2✔
868
                        district_system_type = feature['properties']['district_system_type']
2✔
869
                    except KeyError:
×
870
                        pass
×
871
                    if district_system_type == 'Ground Heat Exchanger':
2!
872
                        length, width = self.calculate_dimensions(feature['properties']['footprint_area'], feature['properties']['footprint_perimeter'])
2✔
873
                        ghe_ids.append({'ghe_id': feature['properties']['id'],
2✔
874
                                        'length_of_ghe': length,
875
                                        'width_of_ghe': width})
876

877
            ghe_sys_param = self.param_template['district_system']['fifth_generation']['ghe_parameters']
2✔
878
            # Make sys_param template entries for GHE specific properties
879
            ghe_list = []
2✔
880
            for ghe in ghe_ids:
2✔
881
                # update GHE specific properties
882
                ghe_info = deepcopy(ghe_sys_param['ghe_specific_params'][0])
2✔
883
                # Update GHE ID
884
                ghe_info['ghe_id'] = str(ghe['ghe_id'])
2✔
885
                # Add ghe geometric properties
886
                ghe_info['ghe_geometric_params']['length_of_ghe'] = ghe['length_of_ghe']
2✔
887
                ghe_info['ghe_geometric_params']['width_of_ghe'] = ghe['width_of_ghe']
2✔
888
                ghe_list.append(ghe_info)
2✔
889

890
            # Add all GHE specific properties to sys-param file
891
            ghe_sys_param['ghe_specific_params'] = ghe_list
2✔
892

893
            # Update ghe_dir
894
            ghe_dir = scenario_dir / 'ghe_dir'
2✔
895
            ghe_sys_param['ghe_dir'] = str(ghe_dir)
2✔
896

897
            # remove fourth generation district system type
898
            del self.param_template['district_system']['fourth_generation']
2✔
899

900
        else:
901
            # remove fifth generation district system type if it exists in template and ghe is not true
902
            try:
2✔
903
                del self.param_template['district_system']['fifth_generation']
2✔
904
            except KeyError:
2✔
905
                pass
2✔
906

907
        # save the file to disk
908
        self.save()
2✔
909

910
    def save(self):
2✔
911
        """
912
        Write the system parameters file with param_template and save
913
        """
914
        with open(self.sys_param_filename, 'w') as outfile:
2✔
915
            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