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

urbanopt / geojson-modelica-translator / 8239561917

11 Mar 2024 08:51PM UTC coverage: 89.093% (+0.1%) from 88.963%
8239561917

Pull #626

github

vtnate
implement dhw workaround for MBLv10 bug
Pull Request #626: Force a dummy value for SHW in modelica loads even if not present

942 of 1125 branches covered (83.73%)

Branch coverage included in aggregate %.

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

87 existing lines in 6 files now uncovered.

2595 of 2845 relevant lines covered (91.21%)

1.82 hits per line

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

89.29
/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 contextlib import suppress
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:
2✔
26
    """
27
    Object to hold the system parameter data (and schema).
28
    """
29

30
    PATH_ELEMENTS = (  # immutable tuple of dicts
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
        {
37
            "json_path": "$.buildings[?load_model=time_series_massflow_temperature].load_model_parameters.time_series.filepath"  # noqa: E501
38
        },
39
        {"json_path": "$.weather"},
40
        {"json_path": "$.combined_heat_and_power_systems.[*].performance_data_path"},
41
    )
42

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

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

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

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

66
            self.resolve_paths()
2✔
67

68
        self.sys_param_filename = None
2✔
69

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

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

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

86
        return sp
2✔
87

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

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

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

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

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

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

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

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

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

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

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

152
        # otherwise return the list of values
153
        return results
2✔
154

155
    def get_param_by_id(self, param_id, jsonpath):
2✔
156
        """
157
        return a parameter for a specific id. This is similar to get_param but allows the user
158
        to constrain the data based on the id.
159

160
        :param param_id: string, id of the object to look up in the system parameters file
161
        :param jsonpath: string, jsonpath formatted string to return
162
        :return: variant, the value from the data
163
        """
164

165
        # TODO: check that ids are unique in the system parameters file, i.e., a building_id doesn't match a ghe_id
166
        for b in self.param_template.get("buildings", []):
2✔
167
            if b.get("geojson_id") == param_id:
2✔
168
                return self.get_param(jsonpath, data=b)
2✔
169
        try:
2✔
170
            district = self.param_template.get("district_system")
2✔
171
            for ghe in district["fifth_generation"]["ghe_parameters"]["ghe_specific_params"]:
2!
172
                if ghe.get("ghe_id") == param_id:
2✔
173
                    return self.get_param(jsonpath, data=ghe)
2✔
174
        except KeyError:
2✔
175
            # If this dict key doesn't exist then either this is a 4G district, no id was passed, or it wasn't a ghe_id
176
            # Don't crash or quit, just keep a stiff upper lip and carry on.
177
            pass
2✔
178
        if param_id is None:
2!
179
            raise SystemExit("No id submitted. Please retry and include the appropriate id")
2✔
180

181
    def validate(self):
2✔
182
        """
183
        Validate an instance against a loaded schema
184

185
        :param instance: dict, json instance to validate
186
        :return: validation results
187
        """
188
        results = []
2✔
189
        v = LatestValidator(self.schema)
2✔
190
        for error in sorted(v.iter_errors(self.param_template), key=str):
2✔
191
            results.append(error.message)
2✔
192

193
        return results
2✔
194

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

198
        This routine downloads the weather file, either an MOS or EPW, which is selected based
199
        on the file extension.
200

201
        filename, str: Name of weather file to download, e.g., USA_NY_Buffalo-Greater.Buffalo.Intl.AP.725280_TMY3.mos
202
        save_directory, str: Location where to save the downloaded content. The path must exist before downloading.
203
        """
204
        p_download = Path(filename)
2✔
205
        p_save = Path(save_directory)
2✔
206

207
        if not p_save.is_dir():
2✔
208
            print(f"Creating directory to save weather file, {p_save!s}")
2✔
209
            p_save.mkdir(parents=True, exist_ok=True)
2✔
210

211
        # get country & state from weather file name
212
        try:
2✔
213
            weatherfile_location_info = p_download.parts[-1].split("_")
2✔
214
            weatherfile_country = weatherfile_location_info[0]
2✔
215
            weatherfile_state = weatherfile_location_info[1]
2✔
216
        except IndexError:
2✔
217
            raise IndexError(
2✔
218
                "Malformed location, needs underscores of location "
219
                "(e.g., USA_NY_Buffalo-Greater.Buffalo.Intl.AP.725280_TMY3.mos)"
220
            )
221

222
        # download file from energyplus website
223
        weatherfile_url = (
2✔
224
            "https://energyplus-weather.s3.amazonaws.com/north_and_central_america_wmo_region_4/"
225
            f"{weatherfile_country}/{weatherfile_state}/{p_download.stem}/{p_download.name}"
226
        )
227
        outputname = p_save / p_download.name
2✔
228
        logger.debug(f"Downloading weather file from {weatherfile_url}")
2✔
229
        try:
2✔
230
            weatherfile_data = requests.get(weatherfile_url, timeout=(5, 5))
2✔
231
            if weatherfile_data.status_code == 200:
2!
232
                with open(outputname, "wb") as f:
2✔
233
                    f.write(weatherfile_data.content)
2✔
234
            else:
UNCOV
235
                raise requests.exceptions.RequestException(
×
236
                    f"Returned non 200 status code trying to download weather file: {weatherfile_data.status_code}"
237
                )
UNCOV
238
        except requests.exceptions.RequestException as e:
×
UNCOV
239
            raise requests.exceptions.RequestException(
×
240
                f"Could not download weather file: {weatherfile_url}"
241
                "\nAt this time we only support USA weather stations"
242
                f"\n{e}"
243
            )
244

245
        if not outputname.exists():
2!
UNCOV
246
            raise FileNotFoundError(f"Could not find or download weather file for {p_download!s}")
×
247

248
        return outputname
2✔
249

250
    def make_list(self, inputs):
2✔
251
        """Ensure that format of inputs is a list
252
        :param inputs: object, inputs (list or dict)
253
        :return: list of inputs
254
        """
255
        list_inputs = []
2✔
256
        if isinstance(inputs, dict) and len(inputs) != 0:
2✔
257
            list_inputs.append(inputs)
2✔
258
        else:
259
            list_inputs = inputs
2✔
260

261
        return list_inputs
2✔
262

263
    def process_wind(self, inputs):
2✔
264
        """
265
        Processes wind inputs and insert into template
266
        :param inputs: object, wind inputs
267
        """
268
        wind_turbines = []
2✔
269
        for item in inputs["scenario_report"]["distributed_generation"]["wind"]:
2✔
270
            # nominal voltage - Default
271
            wt = {}
2✔
272
            wt["nominal_voltage"] = 480
2✔
273

274
            # scaling factor: parameter used by the wind turbine model
275
            # from Modelica Buildings Library, to scale the power output
276
            # without changing other parameters. Multiplies "Power curve"
277
            # value to get a scaled up power output.
278
            # add default = 1
279
            wt["scaling_factor"] = 1
2✔
280

281
            # calculate height_over_ground and power curve from REopt
282
            #  "size_class" (defaults to commercial) res = 2.5kW, com = 100kW, mid = 250kW, large = 2000kW
283
            heights = {"residential": 20, "commercial": 40, "midsize": 50, "large": 80}
2✔
284
            size_class = None
2✔
285
            if item["size_class"]:
2!
286
                size_class = item["size_class"]
2✔
287

288
            if size_class is None:
2!
UNCOV
289
                size_class = "commercial"
×
290

291
            # height over ground. default 10m
292
            wt["height_over_ground"] = heights[size_class]
2✔
293

294
            # add power curve
295
            curves = self.get_wind_power_curves()
2✔
296
            wt["power_curve"] = curves[size_class]
2✔
297

298
            # capture size_kw just in case
299
            wt["rated_power"] = item["size_kw"]
2✔
300
            # and yearly energy produced
301
            wt["annual_energy_produced"] = item["average_yearly_energy_produced_kwh"]
2✔
302

303
            # append to results array
304
            wind_turbines.append(wt)
2✔
305

306
        self.param_template["wind_turbines"] = wind_turbines
2✔
307

308
    def get_wind_power_curves(self):
2✔
309
        # from: https://reopt.nrel.gov/tool/REopt%20Lite%20Web%20Tool%20User%20Manual.pdf#page=61
310
        # curves given in Watts (W)
311
        power_curves = {}
2✔
312
        power_curves["residential"] = [
2✔
313
            [2, 0],
314
            [3, 70.542773],
315
            [4, 167.2125],
316
            [5, 326.586914],
317
            [6, 564.342188],
318
            [7, 896.154492],
319
            [8, 1337.7],
320
            [9, 1904.654883],
321
            [10, 2500],
322
        ]
323
        power_curves["commercial"] = [
2✔
324
            [2, 0],
325
            [3, 3505.95],
326
            [4, 8310.4],
327
            [5, 16231.25],
328
            [6, 28047.6],
329
            [7, 44538.55],
330
            [8, 66483.2],
331
            [9, 94660.65],
332
            [10, 100000],
333
        ]
334
        power_curves["midsize"] = [
2✔
335
            [2, 0],
336
            [3, 8764.875],
337
            [4, 20776],
338
            [5, 40578.125],
339
            [6, 70119],
340
            [7, 111346.375],
341
            [8, 166208],
342
            [9, 236651.625],
343
            [10, 250000],
344
        ]
345

346
        power_curves["large"] = [
2✔
347
            [2, 0],
348
            [3, 70119],
349
            [4, 166208],
350
            [5, 324625],
351
            [6, 560952],
352
            [7, 890771],
353
            [8, 1329664],
354
            [9, 1893213],
355
            [10, 2000000],
356
        ]
357
        return power_curves
2✔
358

359
    def process_pv(self, inputs, latitude):
2✔
360
        """
361
        Processes pv inputs
362
        :param inputs: object, pv inputs
363
        :return photovoltaic_panels section to be inserted per-building or globally
364
        """
365

366
        items = self.make_list(inputs)
2✔
367
        pvs = []
2✔
368
        # hardcode nominal_system_voltage 480V
369
        # add latitude
370
        for item in items:
2✔
371
            pv = {}
2✔
372
            pv["nominal_voltage"] = 480
2✔
373
            pv["latitude"] = latitude
2✔
374
            pv["surface_tilt"] = item.get("tilt", 0)
2✔
375
            pv["surface_azimuth"] = item.get("azimuth", 0)
2✔
376

377
            # Size (kW) = Array Area (m²) * 1 kW/m² * Module Efficiency (%)
378
            # area = size (kW) / 1 kW/m2 / module efficiency (%)
379
            # module efficiency tied to module type: 0 -> standard: 15%, 1-> premium: 19%, 2-> thin film: 10%
380
            # defaults to standard
381
            efficiencies = {0: 15, 1: 19, 2: 10}
2✔
382
            module_type = 0
2✔
383
            if "module_type" in item:
2!
384
                module_type = item["module_type"]
2✔
385

386
            eff = efficiencies[module_type]
2✔
387
            pv["net_surface_area"] = item["size_kw"] / eff
2✔
388

389
            pvs.append(pv)
2✔
390

391
        return pvs
2✔
392

393
    def process_chp(self, inputs):
2✔
394
        """
395
        Processes global chp inputs and insert into template
396
        :param inputs: object, raw inputs
397
        """
398
        # this uses the raw inputs
399
        items = self.make_list(inputs["outputs"]["Scenario"]["Site"]["CHP"])
2✔
400
        chps = []
2✔
401
        for item in items:
2✔
402
            # fuel type. options are: natural_gas (default), landfill_bio_gas, propane, diesel_oil
403
            chp = {}
2✔
404
            chp["fuel_type"] = "natural_gas"
2✔
405
            if inputs["inputs"]["Scenario"]["Site"]["FuelTariff"]["chp_fuel_type"]:
2!
406
                chp["fuel_type"] = inputs["inputs"]["Scenario"]["Site"]["FuelTariff"]["chp_fuel_type"]
2✔
407

408
            # single_electricity_generation_capacity
409
            chp["single_electricity_generation_capacity"] = item["size_kw"]
2✔
410

411
            # performance data filename
412
            # TODO: not sure how to pass this in
413
            # how to default this? retrieve from the template, or right here in code?
414
            chp["performance_data_path"] = ""
2✔
415

416
            # number of machines
417
            # TODO: not in REopt...default?
418
            chp["number_of_machines"] = 1
2✔
419

420
            chps.append(chp)
2✔
421

422
        self.param_template["combined_heat_and_power_systems"] = chps
2✔
423

424
    def process_storage(self, inputs):
2✔
425
        """
426
        Processes global battery bank outputs and insert into template
427
        :param inputs: object, raw inputs
428
        """
429
        # this uses the raw inputs
430
        items = []
2✔
431
        with suppress(KeyError):
2✔
432
            items = self.make_list(inputs["scenario_report"]["distributed_generation"]["storage"])
2✔
433

434
        batts = []
2✔
435
        for item in items:
2✔
436
            batt = {}
2✔
437

438
            # energy capacity 'size_kwh' % 1000 to convert to MWh
439
            batt["capacity"] = item["size_kwh"] / 1000
2✔
440

441
            # Nominal Voltage - DEFAULT
442
            batt["nominal_voltage"] = 480
2✔
443

444
            batts.append(batt)
2✔
445

446
        self.param_template["battery_banks"] = batts
2✔
447

448
    def process_generators(self, inputs):
2✔
449
        """
450
        Processes generators outputs and insert into template
451
        :param inputs: object, raw inputs
452
        """
453
        # this uses the raw inputs
454
        items = []
×
455
        with suppress(KeyError):
×
UNCOV
456
            items = self.make_list(inputs["scenario_report"]["distributed_generation"]["generators"])
×
457

458
        generators = []
×
UNCOV
459
        for item in items:
×
UNCOV
460
            generator = {}
×
461

462
            # size_kw, then convert to W
UNCOV
463
            generator["nominal_power_generation"] = item["size_kw"] * 1000
×
464

465
            # source phase shift
466
            # TODO: Not in REopt
UNCOV
467
            generator["source_phase_shift"] = 0
×
468

UNCOV
469
            generators.append(generator)
×
470

UNCOV
471
        self.param_template["diesel_generators"] = generators
×
472

473
    def process_grid(self):
2✔
474
        grid = {}
2✔
475

476
        # frequency - default
477
        grid["frequency"] = 60
2✔
478
        # TODO: RMS voltage source - default
479
        # grid['source_rms_voltage'] = 0
480

481
        # TODO: phase shift (degrees) - default
482
        # grid['source_phase_shift'] = 0
483

484
        self.param_template["electrical_grid"] = grid
2✔
485

486
    def process_electrical_components(self, scenario_dir: Path):
2✔
487
        """process electrical results from OpenDSS
488
        electrical grid
489
        substations
490
        transformers
491
        distribution lines
492
        capacitor banks (todo)
493
        """
494
        dss_data = {}
2✔
495
        opendss_json_file = Path(scenario_dir) / "scenario_report_opendss.json"
2✔
496
        if opendss_json_file.exists():
2✔
497
            with open(opendss_json_file) as f:
2✔
498
                dss_data = json.load(f)
2✔
499

500
        if dss_data:
2✔
501
            # ELECTRICAL GRID: completely defaulted for now
502
            self.process_grid()
2✔
503

504
            # SUBSTATIONS
505
            substations = []
2✔
506
            with suppress(KeyError):
2✔
507
                data = dss_data["scenario_report"]["scenario_power_distribution"]["substations"]
2✔
508
                for item in data:
2✔
509
                    with suppress(KeyError):
2✔
510
                        s = {}
2✔
511
                        # TODO: default RNM Voltage (high side?)
512

513
                        # RMS Voltage (low side)
514
                        s["RMS_voltage_low_side"] = item["nominal_voltage"]
2✔
515
                        substations.append(s)
2✔
516

517
            self.param_template["substations"] = substations
2✔
518

519
            # DISTRIBUTION LINES
520
            lines = []
2✔
521
            with suppress(KeyError):
2✔
522
                data = dss_data["scenario_report"]["scenario_power_distribution"]["distribution_lines"]
2✔
523
                for item in data:
2✔
524
                    with suppress(KeyError):
2✔
525
                        line = {}
2✔
526
                        line["length"] = item["length"]
2✔
527
                        line["ampacity"] = item["ampacity"]
2✔
528

529
                        # nominal voltage is defaulted (data not available in OpenDSS)
530
                        line["nominal_voltage"] = 480
2✔
531

532
                        line["commercial_line_type"] = item["commercial_line_type"]
2✔
533

534
                        lines.append(line)
2✔
535

536
            self.param_template["distribution_lines"] = lines
2✔
537

538
            # CAPACITOR BANKS
539
            caps = []
2✔
540
            with suppress(KeyError):
2✔
541
                data = dss_data["scenario_report"]["scenario_power_distribution"]["capacitors"]
2✔
542
                for item in data:
2!
543
                    with suppress(KeyError):
×
UNCOV
544
                        cap = {}
×
545
                        # nominal capacity (var)
UNCOV
546
                        cap["nominal_capacity"] = item["nominal_capacity"]
×
547

UNCOV
548
                        caps.append(cap)
×
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_outgoing_voltage"] = item["power_distribution"].get("tx_outgoing_voltage", None)
2✔
562

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

573
                transformers.append(t)
2✔
574

575
            self.param_template["transformers"] = transformers
2✔
576

577
            # Loads (buildings from geojson file)
578
            # grab all the building loads
579
            data = [d for d in dss_data["feature_reports"] if d["feature_type"] == "Building"]
2✔
580

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

593
    def process_building_microgrid_inputs(self, building, scenario_dir: Path):
2✔
594
        """
595
        Processes microgrid inputs for a single building
596
        :param building: dict, single building being built for the sys-param file
597
        :param scenario_dir: Path, location/name of folder with uo_sdk results
598
        :return building, updated building list object
599
        """
600
        feature_opt_file = Path(scenario_dir) / building["geojson_id"] / "feature_reports" / "feature_optimization.json"
2✔
601
        if feature_opt_file.exists():
2!
602
            with open(feature_opt_file) as f:
2✔
603
                reopt_data = json.load(f)
2✔
604

605
        # extract Latitude
606
        try:
2✔
607
            latitude = reopt_data["location"]["latitude_deg"]
2✔
UNCOV
608
        except KeyError:
×
UNCOV
609
            logger.info(f"Latitude not found in {feature_opt_file}. Skipping PV.")
×
UNCOV
610
        except UnboundLocalError:
×
UNCOV
611
            logger.info(f"REopt data not found in {feature_opt_file}. Skipping PV.")
×
612

613
        # PV
614
        if reopt_data["distributed_generation"] and reopt_data["distributed_generation"]["solar_pv"]:
2!
615
            building["photovoltaic_panels"] = self.process_pv(
2✔
616
                reopt_data["distributed_generation"]["solar_pv"], latitude
617
            )
618

619
        return building
2✔
620

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

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

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

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

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

671
        # Generators
672
        try:
2✔
673
            if reopt_data["scenario_report"]["distributed_generation"]["generators"]:
2!
674
                # process diesel generators
UNCOV
675
                self.process_generators(reopt_data)
×
676
        except KeyError:
2✔
677
            logger.info("Generator data not found in scenario_report. Skipping generator.")
2✔
678

679
        # process electrical components (from OpenDSS results)
680
        self.process_electrical_components(scenario_dir)
2✔
681

682
        # Power Converters
683
        # TODO: not handled in UO / OpenDSS
684

685
    def process_ghe_inputs(self, scenario_dir: Path):
2✔
686
        ghe_ids = []
2✔
687
        # add properties from the feature file
688
        for feature in self.sdk_input["features"]:
2✔
689
            if feature["properties"]["type"] == "District System":
2✔
690
                district_system_type = feature["properties"]["district_system_type"]
2✔
691
                if district_system_type == "Ground Heat Exchanger":
2!
692
                    length, width = self.calculate_dimensions(
2✔
693
                        feature["properties"]["footprint_area"], feature["properties"]["footprint_perimeter"]
694
                    )
695
                    ghe_ids.append(
2✔
696
                        {"ghe_id": feature["properties"]["id"], "length_of_ghe": length, "width_of_ghe": width}
697
                    )
698

699
        ghe_sys_param = self.param_template["district_system"]["fifth_generation"]["ghe_parameters"]
2✔
700
        # Make sys_param template entries for GHE specific properties
701
        ghe_list = []
2✔
702
        for ghe in ghe_ids:
2✔
703
            # update GHE specific properties
704
            ghe_info = deepcopy(ghe_sys_param["ghe_specific_params"][0])
2✔
705
            # Update GHE ID
706
            ghe_info["ghe_id"] = str(ghe["ghe_id"])
2✔
707
            # Add ghe geometric properties
708
            ghe_info["ghe_geometric_params"]["length_of_ghe"] = ghe["length_of_ghe"]
2✔
709
            ghe_info["ghe_geometric_params"]["width_of_ghe"] = ghe["width_of_ghe"]
2✔
710
            ghe_list.append(ghe_info)
2✔
711

712
        # Add all GHE specific properties to sys-param file
713
        ghe_sys_param["ghe_specific_params"] = ghe_list
2✔
714

715
        # Update ghe_dir
716
        ghe_dir = scenario_dir / "ghe_dir"
2✔
717
        ghe_sys_param["ghe_dir"] = str(ghe_dir)
2✔
718

719
        # remove fourth generation district system type
720
        del self.param_template["district_system"]["fourth_generation"]
2✔
721

722
        return ghe_sys_param
2✔
723

724
    def retrieve_building_data_from_sdk(self, scenario_dir: Path, modelica_load_filename, model_type: str):
2✔
725
        measure_list = []
2✔
726

727
        # Grab building load filepaths from sdk output
728
        for thing in scenario_dir.iterdir():
2✔
729
            if thing.is_dir():
2✔
730
                for item in thing.iterdir():
2✔
731
                    if item.is_dir():
2✔
732
                        if str(item).endswith("_export_time_series_modelica"):
2✔
733
                            measure_list.append(Path(item) / "building_loads.csv")  # used for mfrt
2✔
734
                        elif str(item).endswith("_export_modelica_loads"):
2✔
735
                            measure_list.append(
2✔
736
                                Path(item) / modelica_load_filename
737
                            )  # space heating/cooling & water heating
738
                            measure_list.append(Path(item) / "building_loads.csv")  # used for max electricity load
2✔
739

740
        # Get each building feature id from the SDK FeatureFile
741
        building_ids = []
2✔
742
        for feature in self.sdk_input["features"]:
2✔
743
            if feature["properties"]["type"] == "Building":
2✔
744
                building_ids.append(feature["properties"]["id"])
2✔
745

746
        # Make sys_param template entries for each feature_id
747
        building_list = []
2✔
748
        for building in building_ids:
2✔
749
            feature_info = deepcopy(self.param_template["buildings"][0])
2✔
750
            feature_info["geojson_id"] = str(building)
2✔
751
            building_list.append(feature_info)
2✔
752

753
        # Grab the modelica file for the each Feature, and add it to the appropriate building dict
754
        district_nominal_massflow_rate = 0
2✔
755
        for building in building_list:
2✔
756
            building_nominal_massflow_rate = 0
2✔
757
            for measure_file_path in measure_list:
2✔
758
                # Grab the feature name and measure folder name from the path, items -3 & -2 respectively
759
                feature_name = Path(measure_file_path).parts[-3]
2✔
760
                measure_folder_name = Path(measure_file_path).parts[-2]
2✔
761
                if feature_name != building["geojson_id"]:
2✔
762
                    continue
2✔
763
                if measure_file_path.suffix == ".mos":
2✔
764
                    # if there is a relative path, then set the path relative
765
                    timeseries_load_file_path = measure_file_path.resolve()
2✔
766
                    if self.rel_path:
2!
UNCOV
767
                        timeseries_load_file_path = timeseries_load_file_path.relative_to(self.rel_path)
×
768

769
                    building["load_model_parameters"][model_type]["filepath"] = str(timeseries_load_file_path)
2✔
770
                if (measure_file_path.suffix == ".csv") and (
2✔
771
                    "_export_time_series_modelica" in str(measure_folder_name)
772
                ):
773
                    massflow_rate_df = pd.read_csv(measure_file_path)
2✔
774
                    try:
2✔
775
                        building_nominal_massflow_rate = round(
2✔
776
                            massflow_rate_df["massFlowRateHeating"].max(), 3
777
                        )  # round max to 3 decimal places
778
                        # Force casting to float even if building_nominal_massflow_rate == 0
779
                        # FIXME: This might be related to building_type == `lodging` for non-zero building percentages
780
                        building["ets_indirect_parameters"]["nominal_mass_flow_building"] = float(
2✔
781
                            building_nominal_massflow_rate
782
                        )
UNCOV
783
                    except KeyError:
×
784
                        # If massFlowRateHeating is not in the export_time_series_modelica output, just skip this step.
785
                        # It probably won't be in the export for hpxml residential buildings, at least as of 2022-06-29
UNCOV
786
                        logger.info("mass-flow-rate heating is not expected in residential buildings. Skipping.")
×
UNCOV
787
                        continue
×
788
                district_nominal_massflow_rate += building_nominal_massflow_rate
2✔
789
                if measure_file_path.suffix == ".csv" and measure_folder_name.endswith("_export_modelica_loads"):
2✔
790
                    try:
2✔
791
                        # only use the one column to make the df small
792
                        building_loads = pd.read_csv(measure_file_path, usecols=["ElectricityFacility"])
2✔
793
                    except (
2✔
794
                        ValueError
795
                    ):  # hack to handle the case where there is no ElectricityFacility column in the csv
796
                        continue
2✔
797
                    max_electricity_load = int(building_loads["ElectricityFacility"].max())
2✔
798
                    building["load_model_parameters"][model_type]["max_electrical_load"] = max_electricity_load
2✔
799

800
        # Remove template buildings that weren't used or don't have successful simulations with modelica outputs
801
        building_list = [
2✔
802
            x for x in building_list if not x["load_model_parameters"][model_type]["filepath"].endswith("populated")
803
        ]
804
        if len(building_list) == 0:
2!
UNCOV
805
            raise SystemExit(
×
806
                "No Modelica files found. Modelica files are expected to be found within each feature in folders "
807
                "with names that include '_modelica'\n"
808
                f"For instance: {scenario_dir / '2' / '016_export_modelica_loads'}\n"
809
                "If these files don't exist the UO SDK simulations may not have been successful"
810
            )
811
        return building_list, district_nominal_massflow_rate
2✔
812

813
    def calculate_dimensions(self, area, perimeter):
2✔
814
        discriminant = perimeter**2 - 16 * area
2✔
815

816
        if discriminant < 0:
2!
UNCOV
817
            raise ValueError("No valid rectangle dimensions exist for the given area and perimeter.")
×
818

819
        length = (perimeter + math.sqrt(discriminant)) / 4
2✔
820
        width = (perimeter - 2 * length) / 2
2✔
821

822
        return length, width
2✔
823

824
    def csv_to_sys_param(
2✔
825
        self,
826
        model_type: str,
827
        scenario_dir: Path,
828
        feature_file: Path,
829
        sys_param_filename: Path,
830
        ghe=False,
831
        overwrite=True,
832
        microgrid=False,
833
        **kwargs,
834
    ) -> None:
835
        """
836
        Create a system parameters file using output from URBANopt SDK
837

838
        :param model_type: str, model type to select which sys_param template to use
839
        :param scenario_dir: Path, location/name of folder with uo_sdk results
840
        :param feature_file: Path, location/name of uo_sdk input file
841
        :param sys_param_filename: Path, location/name of system parameter file to be created
842
        :param overwrite: Boolean, whether to overwrite existing sys-param file
843
        :param ghe: Boolean, flag to add Ground Heat Exchanger properties to System Parameter File
844
        :param microgrid: Boolean, Optional. If set to true, also process microgrid fields
845

846
        :kwargs (optional):
847
            - relative_path: Path, set the paths (time series files, weather file, etc) relate to `relative_path`
848
            - skip_weather_download: Boolean, set to True to not download the weather file, defaults to False
849
            - modelica_load_filename: str, name (only) of file for the modelica load file, defaults to "modelica.mos"
850
        :return None, file created and saved to user-specified location
851

852

853
        """
854
        self.sys_param_filename = sys_param_filename
2✔
855
        self.rel_path = kwargs.get("relative_path", None)
2✔
856
        skip_weather_download = kwargs.get("skip_weather_download", False)
2✔
857
        modelica_load_filename = kwargs.get("modelica_load_filename", "modelica.mos")
2✔
858

859
        if model_type == "time_series":
2✔
860
            # TODO: delineate between time_series and time_series_massflow_rate
861
            if microgrid:
2✔
862
                param_template_path = Path(__file__).parent / "time_series_microgrid_template.json"
2✔
863
            else:
864
                param_template_path = Path(__file__).parent / "time_series_template.json"
2✔
865
        elif model_type == "spawn":
2!
866
            # TODO: We should support spawn as well
UNCOV
867
            raise SystemExit("Spawn models are not implemented at this time.")
×
868
        else:
869
            raise SystemExit(f"No template found. {model_type} is not a valid template")
2✔
870

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

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

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

880
        with open(param_template_path) as f:
2✔
881
            self.param_template = json.load(f)
2✔
882

883
        self.sdk_input = json.loads(feature_file.read_text())
2✔
884

885
        # Get weather data
886
        weather_filename = self.sdk_input["project"]["weather_filename"]
2✔
887
        weather_path = self.sys_param_filename.parent / weather_filename
2✔
888
        # Check if the EPW weatherfile exists, if not, try to download
889
        if not skip_weather_download and not weather_path.exists():
2✔
890
            self.download_weatherfile(weather_path.name, weather_path.parent)
2✔
891

892
        # also download the MOS weatherfile -- this is the file that will be set in the sys param file
893
        mos_weather_path = weather_path.with_suffix(".mos")
2✔
894
        if not skip_weather_download and not mos_weather_path.exists():
2✔
895
            self.download_weatherfile(mos_weather_path.name, mos_weather_path.parent)
2✔
896

897
        # Use building data from URBANopt SDK
898
        building_list, district_nominal_massflow_rate = self.retrieve_building_data_from_sdk(
2✔
899
            scenario_dir, modelica_load_filename, model_type
900
        )
901

902
        # Update specific sys-param settings for each building
903
        for building in building_list:
2✔
904
            building["ets_indirect_parameters"]["nominal_mass_flow_district"] = district_nominal_massflow_rate
2✔
905
            feature_opt_file = scenario_dir / building["geojson_id"] / "feature_reports" / "feature_optimization.json"
2✔
906
            if microgrid and not feature_opt_file.exists():
2!
UNCOV
907
                logger.debug(
×
908
                    f"No feature optimization file found for {building['geojson_id']}. Skipping REopt for this building"
909
                )
910
            elif microgrid and feature_opt_file.exists():
2✔
911
                self.process_building_microgrid_inputs(building, scenario_dir)
2✔
912

913
        # Add all buildings to the sys-param file
914
        self.param_template["buildings"] = building_list
2✔
915

916
        # Update district sys-param settings
917
        if self.rel_path:
2!
UNCOV
918
            mos_weather_path = mos_weather_path.relative_to(self.rel_path)
×
919
        self.param_template["weather"] = str(mos_weather_path)
2✔
920
        if microgrid and not feature_opt_file.exists():
2!
UNCOV
921
            logger.warning(
×
922
                "Microgrid requires OpenDSS and REopt feature optimization for full functionality.\n"
923
                "Run opendss and reopt-feature post-processing in the UO SDK for a full-featured microgrid."
924
            )
925
        try:
2✔
926
            self.process_microgrid_inputs(scenario_dir)
2✔
UNCOV
927
        except UnboundLocalError:
×
UNCOV
928
            raise SystemExit(
×
929
                f"\nError: No scenario_optimization.json file found in {scenario_dir}\n"
930
                "Perhaps you haven't run REopt post-processing step in the UO sdk?"
931
            )
932

933
        # Update ground heat exchanger properties if true
934
        if ghe:
2✔
935
            self.process_ghe_inputs(scenario_dir)
2✔
936
        else:
937
            # remove fifth generation district system type if it exists in template and ghe is not true
938
            with suppress(KeyError):
2✔
939
                del self.param_template["district_system"]["fifth_generation"]
2✔
940

941
        # save the file to disk
942
        self.save()
2✔
943

944
    def save(self):
2✔
945
        """
946
        Write the system parameters file with param_template and save
947
        """
948
        with open(self.sys_param_filename, "w") as outfile:
2✔
949
            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