From 917200ab8a0c294247bf7ee627bad9ee0f8f774b Mon Sep 17 00:00:00 2001 From: bill-becker Date: Tue, 9 Sep 2025 22:11:32 -0600 Subject: [PATCH 01/26] Update to Python 3.12 (from 3.8), with updated packages Kept Django at LTS version 4.2 for compatibility Kept numpy at v1 due to pyeasiur -> deepdish pkg incompat with numpy v2 --- Dockerfile | 2 +- requirements.txt | 259 +++++++++++++++++++++++++++-------------------- 2 files changed, 152 insertions(+), 109 deletions(-) diff --git a/Dockerfile b/Dockerfile index f013a9acd..e585b6b67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM reopt/py38 +FROM reopt/py312 # Install NREL root certs for machines running on NREL's network. ARG NREL_ROOT_CERT_URL_ROOT="" diff --git a/requirements.txt b/requirements.txt index e6b1af133..f91ebedd0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,119 +1,162 @@ -# python 3.8 +# python 3.12 adal==1.2.7 -amqp==5.0.9 -asgiref==3.4.1 -attrs==21.4.0 -autopep8==1.6.0 -backports.zoneinfo==0.2.1 -bandit==1.7.1 -billiard==3.6.4.0 -cachetools==4.2.4 -celery==5.2.3 -certifi==2022.12.7 -cffi==1.15.0 -chardet==4.0.0 -charset-normalizer==2.0.10 -click==8.0.3 -click-didyoumean==0.3.0 -click-plugins==1.1.1 -click-repl==0.2.0 +amqp==5.3.1 +asgiref==3.9.1 +asttokens==3.0.0 +attrs==25.3.0 +autopep8==2.3.2 +azure-core==1.35.0 +backcall==0.2.0 +bandit==1.8.6 +beautifulsoup4==4.13.5 +billiard==4.2.1 +bleach==6.2.0 +blosc2==3.7.2 +cachetools==5.5.2 +celery==5.5.3 +certifi==2025.8.3 +cffi==2.0.0 +chardet==5.2.0 +charset-normalizer==3.4.3 +click==8.2.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 cligj==0.7.2 -colorama==0.4.4 -CoolProp==6.4.1 -cryptography==36.0.1 -Cython==0.29.26 -decorator==5.1.1 +colorama==0.4.6 +coolprop==7.0.0 +cryptography==45.0.7 +Cython==3.1.3 +decorator==5.2.1 deepdish==0.3.7 -Deprecated==1.2.13 -Django==4.0.7 -django-celery-results==2.4.0 -django-extensions==3.1.5 -django-picklefield==3.0.1 -django-tastypie==0.14.4 +defusedxml==0.7.1 +Deprecated==1.2.18 +Django==4.2 +django-extensions==4.1 +django-picklefield==3.3 +django-tastypie==0.15.1 +django_celery_results==2.6.0 docopt==0.6.2 -et-xmlfile==1.1.0 -Fiona==1.8.20 -future==0.18.3 -geopandas==0.10.2 -gitdb==4.0.9 -GitPython==3.1.30 -google-api-core==2.4.0 -google-api-python-client==2.36.0 -google-auth==1.35.0 -google-auth-httplib2==0.1.0 -google-auth-oauthlib==0.4.6 -googleapis-common-protos==1.54.0 -greenlet==1.1.2 -gunicorn==20.1.0 -h5py==3.6.0 -h5pyd==0.9.2 -httplib2==0.20.2 -idna==3.3 -importlib-metadata==4.10.1 +et_xmlfile==2.0.0 +executing==2.2.1 +fastjsonschema==2.21.2 +fiona==1.10.1 +future==1.0.0 +geopandas==1.1.1 +gitdb==4.0.12 +GitPython==3.1.45 +google-api-core==2.25.1 +google-api-python-client==2.181.0 +google-auth==2.40.3 +google-auth-httplib2==0.2.0 +google-auth-oauthlib==1.2.2 +googleapis-common-protos==1.70.0 +greenlet==3.2.4 +gunicorn==23.0.0 +h5py==3.14.0 +h5pyd==0.18.0 +httplib2==0.30.0 +idna==3.10 +importlib_metadata==8.7.0 +ipython==8.12.3 ipython-genutils==0.2.0 -isodate==0.6.1 +isodate==0.7.2 jdcal==1.4.1 -kombu==5.2.3 -lxml==4.9.1 -more-itertools==8.12.0 -msrest==0.6.21 -msrestazure==0.6.4 -munch==2.5.0 -numexpr==2.8.1 -numpy==1.22.1 +jedi==0.19.2 +Jinja2==3.1.6 +jsonschema==4.25.1 +jsonschema-specifications==2025.9.1 +jupyter_client==8.6.3 +jupyter_core==5.8.1 +jupyterlab_pygments==0.3.0 +kombu==5.5.4 +lxml==6.0.1 +markdown-it-py==4.0.0 +MarkupSafe==3.0.2 +matplotlib-inline==0.1.7 +mdurl==0.1.2 +mistune==3.1.4 +more-itertools==10.8.0 +msgpack==1.1.1 +msrest==0.7.1 +msrestazure==0.6.4.post1 +munch==4.0.0 +nbclient==0.10.2 +nbconvert==7.16.6 +nbformat==5.10.4 +ndindex==1.10.0 +numexpr==2.11.0 +numpy==1.26.0 numpy-financial==1.0.0 -oauthlib==3.2.2 -openpyxl==3.0.9 -packaging==21.3 -pandas==1.3.5 -pathlib2==2.3.6 -pbr==5.8.0 -pexpect==4.8.0 -pipreqs==0.4.11 -prompt-toolkit==3.0.24 -protobuf==3.19.5 -psutil==5.9.0 -psycopg2==2.9.3 +oauthlib==3.3.1 +openpyxl==3.1.5 +packaging==25.0 +pandas==2.3.2 +pandocfilters==1.5.1 +parso==0.8.5 +pathlib2==2.3.7.post1 +pbr==7.0.1 +pexpect==4.9.0 +pickleshare==0.7.5 +pipreqs==0.5.0 +platformdirs==4.4.0 +prompt_toolkit==3.0.52 +proto-plus==1.26.1 +protobuf==6.32.0 +psutil==7.0.0 +psycopg2==2.9.10 ptyprocess==0.7.0 -pyasn1==0.4.8 -pyasn1-modules==0.2.8 -pycodestyle==2.8.0 -pycparser==2.21 -Pygments==2.11.2 -PyJWT==2.4.0 -pyparsing==3.0.6 -pyproj==3.3.0 -python-dateutil==2.8.2 -python-mimeparse==1.6.0 -pytz==2021.3 +pure_eval==0.2.3 +py-cpuinfo==9.0.0 +pyasn1==0.6.1 +pyasn1_modules==0.4.2 +pycodestyle==2.14.0 +pycparser==2.23 +Pygments==2.19.2 +PyJWT==2.10.1 +pyogrio==0.11.1 +pyparsing==3.2.3 +pyproj==3.7.2 +python-dateutil==2.9.0.post0 +python-mimeparse==2.0.0 +pytz==2025.2 pytz-deprecation-shim==0.1.0.post0 -PyYAML==6.0 -redis==4.1.1 -requests==2.27.1 -requests-oauthlib==1.3.0 -requests-unixsocket==0.3.0 -rollbar==0.16.2 -rsa==4.8 -scipy==1.7.3 -Shapely==1.8.0 -six==1.16.0 -smmap==5.0.0 -SQLAlchemy==1.4.30 -sqlparse==0.4.2 -stevedore==3.5.0 -tables==3.7.0 +PyYAML==6.0.2 +pyzmq==27.1.0 +redis==6.4.0 +referencing==0.36.2 +requests==2.32.5 +requests-oauthlib==2.0.0 +requests-unixsocket==0.4.1 +rich==14.1.0 +rollbar==1.3.0 +rpds-py==0.27.1 +rsa==4.9.1 +scipy==1.16.1 +setuptools==80.9.0 +shapely==2.1.1 +six==1.17.0 +smmap==5.0.2 +soupsieve==2.8 +SQLAlchemy==2.0.43 +sqlparse==0.5.3 +stack-data==0.6.3 +stevedore==5.5.0 +tables==3.10.2 +tinycss2==1.4.0 toml==0.10.2 -traitlets==5.1.1 -typing_extensions==4.0.1 -tzdata==2021.5 -tzlocal==4.1 -uritemplate==4.1.1 -urllib3==1.26.8 -vine==5.0.0 -wcwidth==0.2.5 -wrapt==1.13.3 -xlsxwriter==3.1.9 -xlrd==2.0.1 +tornado==6.5.2 +traitlets==5.14.3 +typing_extensions==4.15.0 +tzdata==2025.2 +tzlocal==5.3.1 +uritemplate==4.2.0 +urllib3==2.5.0 +vine==5.1.0 +wcwidth==0.2.13 +webencodings==0.5.1 +wrapt==1.17.3 +xlrd==2.0.2 +xlsxwriter==3.2.5 yarg==0.1.9 -zipp==3.7.0 - +zipp==3.23.0 From ac0f8830e9394f91edb8e29057cd28331370324b Mon Sep 17 00:00:00 2001 From: bill-becker Date: Tue, 9 Sep 2025 22:14:05 -0600 Subject: [PATCH 02/26] Set Django at latest LTE patch version 4.2.24 per dependabot suggestion --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f91ebedd0..42c96bbbd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ decorator==5.2.1 deepdish==0.3.7 defusedxml==0.7.1 Deprecated==1.2.18 -Django==4.2 +Django==4.2.24 django-extensions==4.1 django-picklefield==3.3 django-tastypie==0.15.1 From 13436f2faa3ff2673e3d97205c75f0ab847c1121 Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 10 Sep 2025 08:10:12 -0600 Subject: [PATCH 03/26] python 3.12 replaces assertEquals with assertEqual --- reo/tests/test_validator.py | 18 ++++++++-------- reoptjl/test/test_http_endpoints.py | 22 ++++++++++---------- reoptjl/test/test_job_endpoint.py | 20 +++++++++--------- reoptjl/test/test_validator.py | 32 ++++++++++++++--------------- 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/reo/tests/test_validator.py b/reo/tests/test_validator.py index 287d58b25..b715b0e54 100644 --- a/reo/tests/test_validator.py +++ b/reo/tests/test_validator.py @@ -73,16 +73,16 @@ def test_different_load_profiles(self): validator = self.get_validator(post) if length in good_lengths: - self.assertEquals(validator.isValid, True) + self.assertEqual(validator.isValid, True) # check downsampling if length > 8760: - self.assertEquals( + self.assertEqual( len(validator.input_dict['Scenario']['Site']['LoadProfile']['loads_kw']), 8760 ) elif length in bad_lengths: - self.assertEquals(validator.isValid, False) + self.assertEqual(validator.isValid, False) assert(any('Invalid length for loads_kw' in e for e in validator.errors['input_errors'])) def test_different_critical_load_profiles(self): @@ -103,16 +103,16 @@ def test_different_critical_load_profiles(self): validator = self.get_validator(post) if length in good_lengths: - self.assertEquals(validator.isValid, True) + self.assertEqual(validator.isValid, True) # check downsampling if length > 8760: - self.assertEquals( + self.assertEqual( len(validator.input_dict['Scenario']['Site']['LoadProfile']['critical_loads_kw']), 8760 ) elif length in bad_lengths: - self.assertEquals(validator.isValid, False) + self.assertEqual(validator.isValid, False) assert(any('Invalid length for critical_loads_kw' in e for e in validator.errors['input_errors'])) def test_warnings_for_mismatch_of_time_steps_per_hour_and_resolution_of_time_of_export_rate(self): @@ -126,7 +126,7 @@ def test_warnings_for_mismatch_of_time_steps_per_hour_and_resolution_of_time_of_ for rate in rates: post["Scenario"]["Site"]["ElectricTariff"][rate] = [1.1] * 8760 * resolution validator = self.get_validator(post) - self.assertEquals(validator.isValid, True) + self.assertEqual(validator.isValid, True) up_or_down = "Upsampled" if resolution > time_steps_per_hour: up_or_down = "Downsampled" @@ -140,7 +140,7 @@ def test_coincident_peak_inputs(self): post["Scenario"]["Site"]["ElectricTariff"]["coincident_peak_load_active_timesteps"] = 5000 post["Scenario"]["Site"]["ElectricTariff"]["coincident_peak_load_charge_us_dollars_per_kw"] = [10,5,8] validator = self.get_validator(post) - self.assertEquals(validator.isValid, False) + self.assertEqual(validator.isValid, False) self.assertEqual(validator.input_dict["Scenario"]["Site"]["ElectricTariff"]["coincident_peak_load_active_timesteps"], [[5000]]) self.assertEqual(validator.input_dict["Scenario"]["Site"]["ElectricTariff"]["coincident_peak_load_charge_us_dollars_per_kw"], [10,5,8]) assert(any("The number of rates in coincident_peak_load_charge_us_dollars_per_kw must match the number of timestep sets in coincident_peak_load_active_timesteps" in e for e in validator.errors['input_errors'])) @@ -148,6 +148,6 @@ def test_coincident_peak_inputs(self): post["Scenario"]["Site"]["ElectricTariff"]["coincident_peak_load_active_timesteps"] = [1,100,6000,7000] post["Scenario"]["Site"]["ElectricTariff"]["coincident_peak_load_charge_us_dollars_per_kw"] = 10.5 validator = self.get_validator(post) - self.assertEquals(validator.isValid, True) + self.assertEqual(validator.isValid, True) self.assertEqual(validator.input_dict["Scenario"]["Site"]["ElectricTariff"]["coincident_peak_load_active_timesteps"], [[1,100,6000,7000]]) self.assertEqual(validator.input_dict["Scenario"]["Site"]["ElectricTariff"]["coincident_peak_load_charge_us_dollars_per_kw"], [10.5]) diff --git a/reoptjl/test/test_http_endpoints.py b/reoptjl/test/test_http_endpoints.py index 8104a6197..c70584677 100644 --- a/reoptjl/test/test_http_endpoints.py +++ b/reoptjl/test/test_http_endpoints.py @@ -192,9 +192,9 @@ def test_avert_emissions_profile_endpoint(self): resp = self.api_client.get(f'/v3/avert_emissions_profile', data=inputs) self.assertHttpOK(resp) view_response = json.loads(resp.content) - self.assertEquals(view_response["avert_meters_to_region"], 0.0) - self.assertEquals(view_response["avert_region"], "Northwest") - self.assertEquals(len(view_response["emissions_factor_series_lb_NOx_per_kwh"]), 8760) + self.assertEqual(view_response["avert_meters_to_region"], 0.0) + self.assertEqual(view_response["avert_region"], "Northwest") + self.assertEqual(len(view_response["emissions_factor_series_lb_NOx_per_kwh"]), 8760) #case 2: location off shore of NJ (works for AVERT, not Cambium) inputs = { "latitude": 39.034417, @@ -205,8 +205,8 @@ def test_avert_emissions_profile_endpoint(self): self.assertHttpOK(resp) view_response = json.loads(resp.content) self.assertAlmostEqual(view_response["avert_meters_to_region"], 760.62, delta=1.0) - self.assertEquals(view_response["avert_region"], "Mid-Atlantic") - self.assertEquals(len(view_response["emissions_factor_series_lb_NOx_per_kwh"]), 8760) + self.assertEqual(view_response["avert_region"], "Mid-Atlantic") + self.assertEqual(len(view_response["emissions_factor_series_lb_NOx_per_kwh"]), 8760) #case 3: Honolulu, HI (works for AVERT but not Cambium) inputs = { "latitude": 21.3099, @@ -216,9 +216,9 @@ def test_avert_emissions_profile_endpoint(self): resp = self.api_client.get(f'/v3/avert_emissions_profile', data=inputs) self.assertHttpOK(resp) view_response = json.loads(resp.content) - self.assertEquals(view_response["avert_meters_to_region"], 0.0) - self.assertEquals(view_response["avert_region"], "Hawaii (Oahu)") - self.assertEquals(len(view_response["emissions_factor_series_lb_NOx_per_kwh"]), 8760) + self.assertEqual(view_response["avert_meters_to_region"], 0.0) + self.assertEqual(view_response["avert_region"], "Hawaii (Oahu)") + self.assertEqual(len(view_response["emissions_factor_series_lb_NOx_per_kwh"]), 8760) #case 4: location well outside of US (does not work) inputs = { "latitude": 0.0, @@ -248,9 +248,9 @@ def test_cambium_profile_endpoint(self): resp = self.api_client.get(f'/v3/cambium_profile', data=inputs) self.assertHttpOK(resp) view_response = json.loads(resp.content) - self.assertEquals(view_response["metric_col"], "lrmer_co2e") - self.assertEquals(view_response["location"], "Northern Grid West") - self.assertEquals(len(view_response["data_series"]), 8760) + self.assertEqual(view_response["metric_col"], "lrmer_co2e") + self.assertEqual(view_response["location"], "Northern Grid West") + self.assertEqual(len(view_response["data_series"]), 8760) #case 2: location off shore of NJ (works for AVERT, not Cambium) inputs["latitude"] = 39.034417 inputs["longitude"] = -74.759292 diff --git a/reoptjl/test/test_job_endpoint.py b/reoptjl/test/test_job_endpoint.py index 97502460d..ed309b049 100644 --- a/reoptjl/test/test_job_endpoint.py +++ b/reoptjl/test/test_job_endpoint.py @@ -182,11 +182,11 @@ def test_chp_defaults_from_julia(self): for key in view_response["default_inputs"].keys(): if post["CHP"].get(key) is None: # Check that default got assigned consistent with /chp_defaults if key == "max_kw": - self.assertEquals(inputs_chp[key], view_response["chp_max_size_kw"]) + self.assertEqual(inputs_chp[key], view_response["chp_max_size_kw"]) else: - self.assertEquals(inputs_chp[key], view_response["default_inputs"][key]) + self.assertEqual(inputs_chp[key], view_response["default_inputs"][key]) else: # Make sure we didn't overwrite user-input - self.assertEquals(inputs_chp[key], post["CHP"][key]) + self.assertEqual(inputs_chp[key], post["CHP"][key]) def test_peak_load_outage_times(self): """ @@ -208,7 +208,7 @@ def test_peak_load_outage_times(self): resp = self.api_client.post(f'/v3/peak_load_outage_times', data=outage_inputs) self.assertHttpOK(resp) resp = json.loads(resp.content) - self.assertEquals(resp["outage_start_time_steps"], expected_time_steps) + self.assertEqual(resp["outage_start_time_steps"], expected_time_steps) outage_inputs["seasonal_peaks"] = False outage_inputs["start_not_center_on_peaks"] = True @@ -216,7 +216,7 @@ def test_peak_load_outage_times(self): resp = self.api_client.post(f'/v3/peak_load_outage_times', data=outage_inputs) self.assertHttpOK(resp) resp = json.loads(resp.content) - self.assertEquals(resp["outage_start_time_steps"], expected_time_steps) + self.assertEqual(resp["outage_start_time_steps"], expected_time_steps) def test_superset_input_fields(self): """ @@ -274,9 +274,9 @@ def test_steamturbine_defaults_from_julia(self): # skip this key because its NaN in REoptInputs but is populated in /chp_defaults response. if key != "inlet_steam_temperature_degF": if post["SteamTurbine"].get(key) is None: # Check that default got assigned consistent with /chp_defaults - self.assertEquals(inputs_steamturbine[key], view_response["default_inputs"][key]) + self.assertEqual(inputs_steamturbine[key], view_response["default_inputs"][key]) else: # Make sure we didn't overwrite user-input - self.assertEquals(inputs_steamturbine[key], post["SteamTurbine"][key]) + self.assertEqual(inputs_steamturbine[key], post["SteamTurbine"][key]) def test_hybridghp(self): post_file = os.path.join('reoptjl', 'test', 'posts', 'hybrid_ghp.json') @@ -325,8 +325,8 @@ def test_ashp_defaults_update_from_julia(self): resp = self.api_client.get(f'/stable/job/{run_uuid}/results') r = json.loads(resp.content) - self.assertEquals(r["inputs"]["ASHPSpaceHeater"]["om_cost_per_ton"], 0.0) - self.assertEquals(r["inputs"]["ASHPSpaceHeater"]["sizing_factor"], 1.1) + self.assertEqual(r["inputs"]["ASHPSpaceHeater"]["om_cost_per_ton"], 0.0) + self.assertEqual(r["inputs"]["ASHPSpaceHeater"]["sizing_factor"], 1.1) def test_pv_cost_defaults_update_from_julia(self): # Test that the inputs_with_defaults_set_in_julia feature worked for PV @@ -340,5 +340,5 @@ def test_pv_cost_defaults_update_from_julia(self): resp = self.api_client.get(f'/stable/job/{run_uuid}/results') r = json.loads(resp.content) - self.assertEquals(r["inputs"]["PV"]["size_class"], 2) + self.assertEqual(r["inputs"]["PV"]["size_class"], 2) self.assertAlmostEqual(r["inputs"]["PV"]["installed_cost_per_kw"], 2914.6, delta=0.05 * 2914.6) \ No newline at end of file diff --git a/reoptjl/test/test_validator.py b/reoptjl/test/test_validator.py index 8e0c1c6d3..fe76b4f2e 100644 --- a/reoptjl/test/test_validator.py +++ b/reoptjl/test/test_validator.py @@ -36,15 +36,15 @@ def test_elec_load_profile_length_validation_and_resampling(self): validator.cross_clean() if length in good_lengths: - self.assertEquals(validator.is_valid, True) + self.assertEqual(validator.is_valid, True) if length > 8760: # check downsampling - self.assertEquals(len(validator.models["ElectricLoad"].loads_kw), 8760) - self.assertEquals(len(validator.models["ElectricLoad"].critical_loads_kw), 8760) + self.assertEqual(len(validator.models["ElectricLoad"].loads_kw), 8760) + self.assertEqual(len(validator.models["ElectricLoad"].critical_loads_kw), 8760) assert("resampled inputs" in validator.messages) elif length in bad_lengths: - self.assertEquals(validator.is_valid, False) + self.assertEqual(validator.is_valid, False) assert('Invalid length' in validator.validation_errors['ElectricLoad']['loads_kw']) assert('Invalid length' in validator.validation_errors['ElectricLoad']['critical_loads_kw']) @@ -59,9 +59,9 @@ def test_elec_load_profile_length_validation_and_resampling(self): validator.clean() validator.clean_fields() validator.cross_clean() - self.assertEquals(validator.is_valid, True) - self.assertEquals(len(validator.models["ElectricLoad"].loads_kw), time_steps_per_hour*8760) - self.assertEquals(len(validator.models["ElectricLoad"].critical_loads_kw), time_steps_per_hour*8760) + self.assertEqual(validator.is_valid, True) + self.assertEqual(len(validator.models["ElectricLoad"].loads_kw), time_steps_per_hour*8760) + self.assertEqual(len(validator.models["ElectricLoad"].critical_loads_kw), time_steps_per_hour*8760) def test_bad_blended_profile_inputs(self): post = copy.deepcopy(self.post) @@ -98,7 +98,7 @@ def test_off_grid_defaults_overrides(self): validator.clean_fields() validator.clean() validator.cross_clean() - self.assertEquals(validator.is_valid, True) + self.assertEqual(validator.is_valid, True) self.assertAlmostEqual(validator.models["Wind"].operating_reserve_required_fraction, 0.5) self.assertAlmostEqual(validator.models["PV"].operating_reserve_required_fraction, 0.25) @@ -137,7 +137,7 @@ def test_off_grid_defaults_overrides(self): validator.clean_fields() validator.clean() validator.cross_clean() - self.assertEquals(validator.is_valid, True) + self.assertEqual(validator.is_valid, True) self.assertAlmostEqual(validator.models["PV"].operating_reserve_required_fraction, 0.35) self.assertAlmostEqual(validator.models["Wind"].operating_reserve_required_fraction, 0.35) @@ -166,7 +166,7 @@ def existingboiler_boiler_validation(self): validator.clean_fields() validator.clean() validator.cross_clean() - self.assertEquals(validator.is_valid, True) + self.assertEqual(validator.is_valid, True) self.assertAlmostEqual(validator.models["ExistingBoiler"].emissions_factor_lb_CO2_per_mmbtu, 117, places=-1) self.assertAlmostEqual(len(validator.models["ExistingBoiler"].fuel_cost_per_mmbtu), 8760) @@ -189,7 +189,7 @@ def existingboiler_boiler_validation(self): validator.clean_fields() validator.clean() validator.cross_clean() - self.assertEquals(validator.is_valid, True) + self.assertEqual(validator.is_valid, True) self.assertAlmostEqual(len(validator.models["ExistingBoiler"].fuel_cost_per_mmbtu), 8760) self.assertEqual(sum(validator.models["ExistingBoiler"].fuel_cost_per_mmbtu), 9432.0) @@ -264,7 +264,7 @@ def test_multiple_outages_validation(self): validator.clean_fields() validator.clean() validator.cross_clean() - self.assertEquals(validator.is_valid, True) + self.assertEqual(validator.is_valid, True) # test mismatched length post = copy.deepcopy(outage_post) @@ -301,8 +301,8 @@ def test_multiple_outages_validation(self): validator.clean_fields() validator.clean() validator.cross_clean() - self.assertEquals(validator.models["ElectricUtility"].outage_probabilities, [0.5, 0.5]) - self.assertEquals(validator.is_valid, True) + self.assertEqual(validator.models["ElectricUtility"].outage_probabilities, [0.5, 0.5]) + self.assertEqual(validator.is_valid, True) def test_pv_tilt_defaults(self): post = copy.deepcopy(self.post) @@ -314,7 +314,7 @@ def test_pv_tilt_defaults(self): validator.clean_fields() validator.clean() validator.cross_clean() - self.assertEquals(validator.models["PV"].tilt, 20) + self.assertEqual(validator.models["PV"].tilt, 20) post["APIMeta"]["run_uuid"] = uuid.uuid4() post["PV"]["array_type"] = 2 @@ -337,7 +337,7 @@ def boiler_validation(self): validator.clean_fields() validator.clean() validator.cross_clean() - self.assertEquals(validator.is_valid, True) + self.assertEqual(validator.is_valid, True) # Update with Boiler test fields # self.assertAlmostEqual(validator.models["ExistingBoiler"].emissions_factor_lb_CO2_per_mmbtu, 117, places=-1) From 071c7239bab4dc440ab55a8635f65860017d38d3 Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 10 Sep 2025 08:56:07 -0600 Subject: [PATCH 04/26] Remove s from assertAlmostEquals for py 3.12 --- reo/process_results.py | 4 +- reo/tests/test_diesel_gen_sizing.py | 2 +- reo/tests/test_hybrid_loadprofile.py | 2 +- reo/tests/test_multiple_pvs.py | 24 +-- reo/tests/test_re_emissions_constraints.py | 186 +++++++++--------- reoptjl/test/test_validator.py | 2 +- .../tests/test_resilience_stats.py | 12 +- 7 files changed, 116 insertions(+), 116 deletions(-) diff --git a/reo/process_results.py b/reo/process_results.py index 6bb08bc7b..759749837 100644 --- a/reo/process_results.py +++ b/reo/process_results.py @@ -496,12 +496,12 @@ def get_nested(self): # preprocessed_BAU_year_one_emissions_tCO2 = self.results_dict.get("preprocessed_BAU_year_one_emissions_tCO2") # year_one_emissions_bau_tCO2_out = self.results_dict.get("year_one_emissions_tCO2_bau") # year_one_emissions_bau_preprocess_pct_diff = (year_one_emissions_bau_tCO2_out-preprocessed_BAU_year_one_emissions_tCO2)/year_one_emissions_bau_tCO2_out - # self.assertAlmostEquals(year_one_emissions_bau_preprocess_pct_diff,0.0,places=2) #(<0.5% error) + # self.assertAlmostEqual(year_one_emissions_bau_preprocess_pct_diff,0.0,places=2) #(<0.5% error) # check pre-processed lifecycle bau CO2 emissions calcs vs lifecycle bau CO2 emissions output # preprocessed_BAU_lifecycle_emissions_tCO2 = self.results_dict.get("preprocessed_BAU_lifecycle_emissions_tCO2") # lifecycle_emissions_bau_tCO2_out = self.results_dict.get("lifecycle_emissions_tCO2_bau") # lifecycle_emissions_bau_preprocess_pct_diff = (lifecycle_emissions_bau_tCO2_out-preprocessed_BAU_lifecycle_emissions_tCO2)/lifecycle_emissions_bau_tCO2_out - # self.assertAlmostEquals(lifecycle_emissions_bau_preprocess_pct_diff,0.0,places=2) #(<0.5% error) + # self.assertAlmostEqual(lifecycle_emissions_bau_preprocess_pct_diff,0.0,places=2) #(<0.5% error) # emissions reductions diff --git a/reo/tests/test_diesel_gen_sizing.py b/reo/tests/test_diesel_gen_sizing.py index 64c4901f8..360c5272e 100644 --- a/reo/tests/test_diesel_gen_sizing.py +++ b/reo/tests/test_diesel_gen_sizing.py @@ -96,4 +96,4 @@ def test_generator_sizing_with_existing_pv(self): list_to_load = [generator_to_load, storage_to_load, pv_to_load] tech_to_load = self.outage_tech_to_load(list_to_load, outage_start, outage_end) for x, y in zip(critical_load[outage_start-1:outage_end], tech_to_load): - self.assertAlmostEquals(x, y, places=3) + self.assertAlmostEqual(x, y, places=3) diff --git a/reo/tests/test_hybrid_loadprofile.py b/reo/tests/test_hybrid_loadprofile.py index fa25dfd25..0e0063a39 100644 --- a/reo/tests/test_hybrid_loadprofile.py +++ b/reo/tests/test_hybrid_loadprofile.py @@ -198,4 +198,4 @@ def test_hybrid_loadprofile(self): # this comparison of the 'resultant_load_profile' and 'manually_calculated_load_profile' is possible because there is no existing-pv in this case. If existing-pv is present, then the resultant_hybrid_annual_kwh would be different when net native load is calculated by subtracting existing-pv's output. - self.assertAlmostEquals(resultant_hybrid_annual_kwh, 500000, places=2) \ No newline at end of file + self.assertAlmostEqual(resultant_hybrid_annual_kwh, 500000, places=2) \ No newline at end of file diff --git a/reo/tests/test_multiple_pvs.py b/reo/tests/test_multiple_pvs.py index fba1804bc..961a34fff 100644 --- a/reo/tests/test_multiple_pvs.py +++ b/reo/tests/test_multiple_pvs.py @@ -270,15 +270,15 @@ def test_multiple_pv(self): roof_west = next(d for d in pvs if d["pv_name"] == "roof_west") roof_east = next(d for d in pvs if d["pv_name"] == "roof_east") - self.assertAlmostEquals(ground_pv['size_kw'], 15, places=2) - self.assertAlmostEquals(roof_west['size_kw'], 7, places=2) - self.assertAlmostEquals(roof_east['size_kw'], 4, places=2) - self.assertAlmostEquals(ground_pv['existing_pv_om_cost_us_dollars'], 782.0, places=2) - self.assertAlmostEquals(roof_west['existing_pv_om_cost_us_dollars'], 782.0, places=2) - self.assertAlmostEquals(roof_east['existing_pv_om_cost_us_dollars'], 0, places=2) - self.assertAlmostEquals(ground_pv['average_yearly_energy_produced_bau_kwh'], 8719.0, places=2) - self.assertAlmostEquals(roof_west['average_yearly_energy_produced_bau_kwh'], 7334.0, places=2) - self.assertAlmostEquals(roof_east['average_yearly_energy_produced_bau_kwh'], 0, places=2) - self.assertAlmostEquals(ground_pv['average_yearly_energy_produced_kwh'], 26157.0, places=2) - self.assertAlmostEquals(roof_west['average_yearly_energy_produced_kwh'], 10268.0, places=2) - self.assertAlmostEquals(roof_east['average_yearly_energy_produced_kwh'], 6390.0, places=2) \ No newline at end of file + self.assertAlmostEqual(ground_pv['size_kw'], 15, places=2) + self.assertAlmostEqual(roof_west['size_kw'], 7, places=2) + self.assertAlmostEqual(roof_east['size_kw'], 4, places=2) + self.assertAlmostEqual(ground_pv['existing_pv_om_cost_us_dollars'], 782.0, places=2) + self.assertAlmostEqual(roof_west['existing_pv_om_cost_us_dollars'], 782.0, places=2) + self.assertAlmostEqual(roof_east['existing_pv_om_cost_us_dollars'], 0, places=2) + self.assertAlmostEqual(ground_pv['average_yearly_energy_produced_bau_kwh'], 8719.0, places=2) + self.assertAlmostEqual(roof_west['average_yearly_energy_produced_bau_kwh'], 7334.0, places=2) + self.assertAlmostEqual(roof_east['average_yearly_energy_produced_bau_kwh'], 0, places=2) + self.assertAlmostEqual(ground_pv['average_yearly_energy_produced_kwh'], 26157.0, places=2) + self.assertAlmostEqual(roof_west['average_yearly_energy_produced_kwh'], 10268.0, places=2) + self.assertAlmostEqual(roof_east['average_yearly_energy_produced_kwh'], 6390.0, places=2) \ No newline at end of file diff --git a/reo/tests/test_re_emissions_constraints.py b/reo/tests/test_re_emissions_constraints.py index 6b44add63..0018c37ee 100644 --- a/reo/tests/test_re_emissions_constraints.py +++ b/reo/tests/test_re_emissions_constraints.py @@ -87,19 +87,19 @@ def test_RE_and_ER(self): # Emissions reductions: ER_pct_out = d['outputs']['Scenario']['Site']['lifecycle_emissions_reduction_CO2_pct'] if ER_target[i] is not None: - self.assertAlmostEquals(ER_target[i], d['inputs']['Scenario']['Site']['co2_emissions_reduction_min_pct']) - self.assertAlmostEquals(ER_pct_out,ER_target[i],places=3) + self.assertAlmostEqual(ER_target[i], d['inputs']['Scenario']['Site']['co2_emissions_reduction_min_pct']) + self.assertAlmostEqual(ER_pct_out,ER_target[i],places=3) lifecycle_emissions_tCO2_out = d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2'] lifecycle_emissions_bau_tCO2_out = d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2_bau'] ER_pct_calced_out = (lifecycle_emissions_bau_tCO2_out-lifecycle_emissions_tCO2_out)/lifecycle_emissions_bau_tCO2_out ER_pct_diff = abs(ER_pct_calced_out-ER_pct_out) - self.assertAlmostEquals(ER_pct_diff,0.0,places=2) #within 1% of each other + self.assertAlmostEqual(ER_pct_diff,0.0,places=2) #within 1% of each other # Year 1 emissions - non-BAU case: year_one_emissions_tCO2_out = d['outputs']['Scenario']['Site']['year_one_emissions_tCO2'] yr1_fuel_emissions_tCO2_out = d['outputs']['Scenario']['Site']['year_one_emissions_from_fuelburn_tCO2'] or 0.0 yr1_grid_emissions_tCO2_out = d['outputs']['Scenario']['Site']['ElectricTariff']['year_one_emissions_tCO2'] or 0.0 yr1_total_emissions_calced_tCO2 = yr1_fuel_emissions_tCO2_out + yr1_grid_emissions_tCO2_out - self.assertAlmostEquals(year_one_emissions_tCO2_out,yr1_total_emissions_calced_tCO2,places=-1) + self.assertAlmostEqual(year_one_emissions_tCO2_out,yr1_total_emissions_calced_tCO2,places=-1) # Breakeven cost of emissions reductions yr1_cost_ER_usd_per_tCO2_out = d['outputs']['Scenario']['Site']['breakeven_cost_of_emissions_reduction_us_dollars_per_tCO2'] self.assertTrue(yr1_cost_ER_usd_per_tCO2_out is None or yr1_cost_ER_usd_per_tCO2_out>=0.0) @@ -107,113 +107,113 @@ def test_RE_and_ER(self): ### test expected values for each scenario: if i ==0: # Scenario 1 # system sizes - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['PV']['size_kw'],46.2923,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['Wind']['size_kw'],10.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['Storage']['size_kw'],2.4303,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['Storage']['size_kwh'],6.062,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['Generator']['size_kw'],22.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['PV']['size_kw'],46.2923,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['Wind']['size_kw'],10.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['Storage']['size_kw'],2.4303,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['Storage']['size_kwh'],6.062,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['Generator']['size_kw'],22.0,places=0) # NPV npv = d['outputs']['Scenario']['Site']['Financial']['npv_us_dollars'] npv_diff_pct = (-101278.0 - npv)/-101278.0 - self.assertAlmostEquals(npv_diff_pct,0.0,places=3) + self.assertAlmostEqual(npv_diff_pct,0.0,places=3) # Renewable energy - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_renewable_electricity_pct'],0.8,places=3) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_renewable_electricity_kwh'],75140.37,places=-3) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_renewable_electricity_pct_bau'],0.146395,places=3) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_total_renewable_energy_pct'],0.8,places=3) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_total_renewable_energy_pct_bau'],0.146395,places=3) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_renewable_electricity_pct'],0.8,places=3) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_renewable_electricity_kwh'],75140.37,places=-3) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_renewable_electricity_pct_bau'],0.146395,places=3) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_total_renewable_energy_pct'],0.8,places=3) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_total_renewable_energy_pct_bau'],0.146395,places=3) # CO2 emissions - totals, from grid, from fuelburn, ER, $/tCO2 breakeven - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_reduction_CO2_pct'],0.660022,places=3) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['breakeven_cost_of_emissions_reduction_us_dollars_per_tCO2'],301.237,places=1) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['year_one_emissions_tCO2'],12.79,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['year_one_emissions_tCO2_bau'],40.54,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['year_one_emissions_from_fuelburn_tCO2'],7.65,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['year_one_emissions_from_fuelburn_tCO2_bau'],0.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_cost_CO2'],8713.36,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_cost_CO2_bau'],25856.9,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2'],244.06,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2_bau'],717.86,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_from_fuelburn_tCO2'],152.97,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_from_fuelburn_tCO2_bau'],0.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['ElectricTariff']['year_one_emissions_tCO2'],5.14,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['ElectricTariff']['year_one_emissions_tCO2_bau'],40.54,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['ElectricTariff']['lifecycle_emissions_tCO2'],91.09,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['ElectricTariff']['lifecycle_emissions_tCO2_bau'],717.86,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_reduction_CO2_pct'],0.660022,places=3) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['breakeven_cost_of_emissions_reduction_us_dollars_per_tCO2'],301.237,places=1) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['year_one_emissions_tCO2'],12.79,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['year_one_emissions_tCO2_bau'],40.54,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['year_one_emissions_from_fuelburn_tCO2'],7.65,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['year_one_emissions_from_fuelburn_tCO2_bau'],0.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_cost_CO2'],8713.36,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_cost_CO2_bau'],25856.9,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2'],244.06,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2_bau'],717.86,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_from_fuelburn_tCO2'],152.97,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_from_fuelburn_tCO2_bau'],0.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['ElectricTariff']['year_one_emissions_tCO2'],5.14,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['ElectricTariff']['year_one_emissions_tCO2_bau'],40.54,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['ElectricTariff']['lifecycle_emissions_tCO2'],91.09,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['ElectricTariff']['lifecycle_emissions_tCO2_bau'],717.86,places=0) if i ==1: # Scenario 2 # system sizes - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['PV']['size_kw'],59.1891,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['Wind']['size_kw'],23.0474,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['Storage']['size_kw'],12.532,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['Storage']['size_kwh'],80.817,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['Generator']['size_kw'],0.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['PV']['size_kw'],59.1891,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['Wind']['size_kw'],23.0474,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['Storage']['size_kw'],12.532,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['Storage']['size_kwh'],80.817,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['Generator']['size_kw'],0.0,places=0) # NPV npv = d['outputs']['Scenario']['Site']['Financial']['npv_us_dollars'] npv_diff_pct = (-198934.0 - npv)/-198934.0 - self.assertAlmostEquals(npv_diff_pct,0.0,places=3) + self.assertAlmostEqual(npv_diff_pct,0.0,places=3) # Renewable energy - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_renewable_electricity_pct'],0.78984,places=3) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_renewable_electricity_kwh'],78984.01,places=-1) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_renewable_electricity_pct_bau'],0.135426,places=3) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_renewable_electricity_kwh_bau'],13542.62,places=-1) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_total_renewable_energy_pct'],0.78984,places=3) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_total_renewable_energy_pct_bau'],0.135426,places=3) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_renewable_electricity_pct'],0.78984,places=3) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_renewable_electricity_kwh'],78984.01,places=-1) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_renewable_electricity_pct_bau'],0.135426,places=3) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_renewable_electricity_kwh_bau'],13542.62,places=-1) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_total_renewable_energy_pct'],0.78984,places=3) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_total_renewable_energy_pct_bau'],0.135426,places=3) # CO2 emissions - totals, from grid, from fuelburn, ER, $/tCO2 breakeven - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_reduction_CO2_pct'],0.8,places=3) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['breakeven_cost_of_emissions_reduction_us_dollars_per_tCO2'],342.947,places=1) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['year_one_emissions_tCO2'],11.59,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['year_one_emissions_tCO2_bau'],57.97,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['year_one_emissions_from_fuelburn_tCO2'],0.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['year_one_emissions_from_fuelburn_tCO2_bau'],0.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_cost_CO2'],7395.84,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_cost_CO2_bau'],36979.2,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2'],205.33,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2_bau'],1026.65,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_from_fuelburn_tCO2'],0.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_from_fuelburn_tCO2_bau'],0.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['ElectricTariff']['year_one_emissions_tCO2'],11.59,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['ElectricTariff']['year_one_emissions_tCO2_bau'],57.97,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['ElectricTariff']['lifecycle_emissions_tCO2'],205.33,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['ElectricTariff']['lifecycle_emissions_tCO2_bau'],1026.65,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_reduction_CO2_pct'],0.8,places=3) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['breakeven_cost_of_emissions_reduction_us_dollars_per_tCO2'],342.947,places=1) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['year_one_emissions_tCO2'],11.59,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['year_one_emissions_tCO2_bau'],57.97,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['year_one_emissions_from_fuelburn_tCO2'],0.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['year_one_emissions_from_fuelburn_tCO2_bau'],0.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_cost_CO2'],7395.84,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_cost_CO2_bau'],36979.2,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2'],205.33,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2_bau'],1026.65,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_from_fuelburn_tCO2'],0.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_from_fuelburn_tCO2_bau'],0.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['ElectricTariff']['year_one_emissions_tCO2'],11.59,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['ElectricTariff']['year_one_emissions_tCO2_bau'],57.97,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['ElectricTariff']['lifecycle_emissions_tCO2'],205.33,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['ElectricTariff']['lifecycle_emissions_tCO2_bau'],1026.65,places=0) if i ==2: # Scenario 3 # system sizes - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['PV']['size_kw'],20.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['Wind']['size_kw'],0.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['Storage']['size_kw'],0.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['Storage']['size_kwh'],0.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['Generator']['size_kw'],0.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['CHP']['size_kw'],200.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['SteamTurbine']['size_kw'],1254.938,places=-2) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['NewBoiler']['size_mmbtu_per_hr'],28.04,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['AbsorptionChiller']['size_ton'],400.0,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['HotTES']['size_gal'],50000,places=0) - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['ColdTES']['size_gal'],30000,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['PV']['size_kw'],20.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['Wind']['size_kw'],0.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['Storage']['size_kw'],0.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['Storage']['size_kwh'],0.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['Generator']['size_kw'],0.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['CHP']['size_kw'],200.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['SteamTurbine']['size_kw'],1254.938,places=-2) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['NewBoiler']['size_mmbtu_per_hr'],28.04,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['AbsorptionChiller']['size_ton'],400.0,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['HotTES']['size_gal'],50000,places=0) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['ColdTES']['size_gal'],30000,places=0) # NPV npv = d['outputs']['Scenario']['Site']['Financial']['npv_us_dollars'] npv_diff_pct = (11210580.0 - npv)/11210580.0 - self.assertAlmostEquals(npv_diff_pct,0.0,places=1) + self.assertAlmostEqual(npv_diff_pct,0.0,places=1) # Renewable energy - self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_renewable_electricity_pct'],0.733551,places=2) - #self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_renewable_electricity_kwh'],5540064.64,places=-3) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_renewable_electricity_pct_bau'],0.001534,places=3) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_renewable_electricity_kwh_bau'],13454.22,places=-2) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_total_renewable_energy_pct'],0.572143,places=2) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['annual_total_renewable_energy_pct_bau'],0.000852,places=3) + self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_renewable_electricity_pct'],0.733551,places=2) + #self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_renewable_electricity_kwh'],5540064.64,places=-3) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_renewable_electricity_pct_bau'],0.001534,places=3) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_renewable_electricity_kwh_bau'],13454.22,places=-2) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_total_renewable_energy_pct'],0.572143,places=2) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['annual_total_renewable_energy_pct_bau'],0.000852,places=3) # # CO2 emissions - totals, from grid, from fuelburn, ER, $/tCO2 breakeven - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_reduction_CO2_pct'],1.276628,places=2) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['breakeven_cost_of_emissions_reduction_us_dollars_per_tCO2'],0.0,places=3) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['year_one_emissions_tCO2'],-1717.17,places=0) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['year_one_emissions_tCO2_bau'],5931.29,places=0) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['year_one_emissions_from_fuelburn_tCO2'],33.89,places=0) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['year_one_emissions_from_fuelburn_tCO2_bau'],1585.32,places=0) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_cost_CO2'],-1212513.34,places=0) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_cost_CO2_bau'],4350968.52,places=0) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2'],-36837.04,places=0) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2_bau'],133157.72,places=1) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_from_fuelburn_tCO2'],844.66,places=0) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['lifecycle_emissions_from_fuelburn_tCO2_bau'],39633.01,places=0) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['ElectricTariff']['year_one_emissions_tCO2'],-1751.02,places=0) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['ElectricTariff']['year_one_emissions_tCO2_bau'],4345.97,places=0) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['ElectricTariff']['lifecycle_emissions_tCO2'],-37681.7,places=0) - # self.assertAlmostEquals(d['outputs']['Scenario']['Site']['ElectricTariff']['lifecycle_emissions_tCO2_bau'],93524.7,places=0) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_reduction_CO2_pct'],1.276628,places=2) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['breakeven_cost_of_emissions_reduction_us_dollars_per_tCO2'],0.0,places=3) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['year_one_emissions_tCO2'],-1717.17,places=0) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['year_one_emissions_tCO2_bau'],5931.29,places=0) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['year_one_emissions_from_fuelburn_tCO2'],33.89,places=0) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['year_one_emissions_from_fuelburn_tCO2_bau'],1585.32,places=0) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_cost_CO2'],-1212513.34,places=0) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_cost_CO2_bau'],4350968.52,places=0) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2'],-36837.04,places=0) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_tCO2_bau'],133157.72,places=1) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_from_fuelburn_tCO2'],844.66,places=0) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['lifecycle_emissions_from_fuelburn_tCO2_bau'],39633.01,places=0) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['ElectricTariff']['year_one_emissions_tCO2'],-1751.02,places=0) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['ElectricTariff']['year_one_emissions_tCO2_bau'],4345.97,places=0) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['ElectricTariff']['lifecycle_emissions_tCO2'],-37681.7,places=0) + # self.assertAlmostEqual(d['outputs']['Scenario']['Site']['ElectricTariff']['lifecycle_emissions_tCO2_bau'],93524.7,places=0) diff --git a/reoptjl/test/test_validator.py b/reoptjl/test/test_validator.py index fe76b4f2e..a0b77c9e4 100644 --- a/reoptjl/test/test_validator.py +++ b/reoptjl/test/test_validator.py @@ -322,7 +322,7 @@ def test_pv_tilt_defaults(self): validator.clean_fields() validator.clean() validator.cross_clean() - self.assertAlmostEquals(validator.models["PV"].tilt, 0) + self.assertAlmostEqual(validator.models["PV"].tilt, 0) def boiler_validation(self): """ diff --git a/resilience_stats/tests/test_resilience_stats.py b/resilience_stats/tests/test_resilience_stats.py index 0023ff6f0..dae44b7ef 100644 --- a/resilience_stats/tests/test_resilience_stats.py +++ b/resilience_stats/tests/test_resilience_stats.py @@ -293,14 +293,14 @@ def test_outage_sim_no_diesel(self): self.assertAlmostEqual(expected['resilience_hours_avg'], resp['resilience_hours_avg'], places=3) self.assertAlmostEqual(expected['outage_durations'], resp['outage_durations'], places=3) for x, y in zip(expected['probs_of_surviving'], resp['probs_of_surviving']): - self.assertAlmostEquals(x, y, places=3) + self.assertAlmostEqual(x, y, places=3) for e, r in zip([0, 11], [0, 11]): for x, y in zip(expected['probs_of_surviving_by_month'][e], resp['probs_of_surviving_by_month'][r]): - self.assertAlmostEquals(x, y, places=3) + self.assertAlmostEqual(x, y, places=3) for e, r in zip([0, 23], [0, 23]): for x, y in zip(expected['probs_of_surviving_by_hour_of_the_day'][e], resp['probs_of_surviving_by_hour_of_the_day'][r]): - self.assertAlmostEquals(x, y, places=3) + self.assertAlmostEqual(x, y, places=3) def test_outage_sim(self): """ @@ -334,7 +334,7 @@ def test_outage_sim(self): self.assertAlmostEqual(expected['resilience_hours_avg'], resp['resilience_hours_avg'], places=4) self.assertListEqual(expected['outage_durations'], resp['outage_durations']) for x, y in zip(expected['probs_of_surviving'], resp['probs_of_surviving']): - self.assertAlmostEquals(x, y, places=4) + self.assertAlmostEqual(x, y, places=4) def test_no_resilience(self): inputs = self.inputs @@ -361,7 +361,7 @@ def test_flexible_timesteps(self): self.assertAlmostEqual(1, resp2['resilience_hours_avg'] / resp1['resilience_hours_avg'], places=1) for x, y in zip(resp1['probs_of_surviving'], resp2['probs_of_surviving']): - self.assertAlmostEquals(x, y, places=1) + self.assertAlmostEqual(x, y, places=1) def test_resil_endpoint(self): post = json.load(open(os.path.join('resilience_stats', 'tests', 'POST_nested.json'), 'r')) @@ -413,4 +413,4 @@ def test_outage_sim_chp(self): self.assertAlmostEqual(expected['resilience_hours_avg'], resp['resilience_hours_avg'], places=4) self.assertListEqual(expected['outage_durations'], resp['outage_durations']) for x, y in zip(expected['probs_of_surviving'], resp['probs_of_surviving']): - self.assertAlmostEquals(x, y, places=4) + self.assertAlmostEqual(x, y, places=4) From 9112309006d874eb1c4e3d8d439e90c6ca7b52a6 Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 10 Sep 2025 09:22:54 -0600 Subject: [PATCH 05/26] Address SyntaxWarning for escape / with $ --- reoptjl/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reoptjl/models.py b/reoptjl/models.py index 2d48782ea..8d1506ce6 100644 --- a/reoptjl/models.py +++ b/reoptjl/models.py @@ -793,7 +793,7 @@ class FinancialInputs(BaseModel, models.Model): ], blank=True, null=True, - help_text=("Minimum up-front capital cost for all technologies, excluding replacement costs and incentives [\$].") + help_text=("Minimum up-front capital cost for all technologies, excluding replacement costs and incentives [$].") ) max_initial_capital_costs_before_incentives = models.FloatField( validators=[ @@ -802,7 +802,7 @@ class FinancialInputs(BaseModel, models.Model): ], blank=True, null=True, - help_text=("Maximum up-front capital cost for all technologies, excluding replacement costs and incentives [\$].") + help_text=("Maximum up-front capital cost for all technologies, excluding replacement costs and incentives [$].") ) CO2_cost_per_tonne = models.FloatField( validators=[ From 0267f7290c74be80f8a33acad02355a681c2ca00 Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 10 Sep 2025 11:56:12 -0600 Subject: [PATCH 06/26] Remove deprecated '\$' SyntaxWarning: invalid escape sequence '\$' --- ...poutputs_initial_capital_costs_and_more.py | 54 +++++++++++++++++++ reoptjl/models.py | 12 ++--- 2 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 reoptjl/migrations/0093_alter_chpoutputs_initial_capital_costs_and_more.py diff --git a/reoptjl/migrations/0093_alter_chpoutputs_initial_capital_costs_and_more.py b/reoptjl/migrations/0093_alter_chpoutputs_initial_capital_costs_and_more.py new file mode 100644 index 000000000..e5b38f0b0 --- /dev/null +++ b/reoptjl/migrations/0093_alter_chpoutputs_initial_capital_costs_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2 on 2025-09-10 16:45 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('reoptjl', '0092_merge_20250613_0525'), + ] + + operations = [ + migrations.AlterField( + model_name='chpoutputs', + name='initial_capital_costs', + field=models.FloatField(blank=True, help_text='Initial capital costs of the CHP system, before incentives [$]', null=True), + ), + migrations.AlterField( + model_name='chpoutputs', + name='lifecycle_fuel_cost_after_tax', + field=models.FloatField(blank=True, help_text='Present value of cost of fuel consumed by the CHP system, after tax [$]', null=True), + ), + migrations.AlterField( + model_name='chpoutputs', + name='year_one_fuel_cost_after_tax', + field=models.FloatField(blank=True, help_text='Cost of fuel consumed by the CHP system in year one, after tax [$]', null=True), + ), + migrations.AlterField( + model_name='chpoutputs', + name='year_one_fuel_cost_before_tax', + field=models.FloatField(blank=True, help_text='Cost of fuel consumed by the CHP system in year one [$]', null=True), + ), + migrations.AlterField( + model_name='chpoutputs', + name='year_one_standby_cost_after_tax', + field=models.FloatField(blank=True, help_text='CHP standby charges in year one, after tax [$]', null=True), + ), + migrations.AlterField( + model_name='chpoutputs', + name='year_one_standby_cost_before_tax', + field=models.FloatField(blank=True, help_text='CHP standby charges in year one [$]', null=True), + ), + migrations.AlterField( + model_name='financialinputs', + name='max_initial_capital_costs_before_incentives', + field=models.FloatField(blank=True, help_text='Maximum up-front capital cost for all technologies, excluding replacement costs and incentives [$].', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000000000000.0)]), + ), + migrations.AlterField( + model_name='financialinputs', + name='min_initial_capital_costs_before_incentives', + field=models.FloatField(blank=True, help_text='Minimum up-front capital cost for all technologies, excluding replacement costs and incentives [$].', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000000000000.0)]), + ), + ] diff --git a/reoptjl/models.py b/reoptjl/models.py index 8d1506ce6..5dd2023f7 100644 --- a/reoptjl/models.py +++ b/reoptjl/models.py @@ -4763,23 +4763,23 @@ class CHPOutputs(BaseModel, models.Model): ) year_one_fuel_cost_before_tax = models.FloatField( null=True, blank=True, - help_text="Cost of fuel consumed by the CHP system in year one [\$]" + help_text="Cost of fuel consumed by the CHP system in year one [$]" ) year_one_fuel_cost_after_tax = models.FloatField( null=True, blank=True, - help_text="Cost of fuel consumed by the CHP system in year one, after tax [\$]" + help_text="Cost of fuel consumed by the CHP system in year one, after tax [$]" ) lifecycle_fuel_cost_after_tax = models.FloatField( null=True, blank=True, - help_text="Present value of cost of fuel consumed by the CHP system, after tax [\$]" + help_text="Present value of cost of fuel consumed by the CHP system, after tax [$]" ) year_one_standby_cost_before_tax = models.FloatField( null=True, blank=True, - help_text="CHP standby charges in year one [\$]" + help_text="CHP standby charges in year one [$]" ) year_one_standby_cost_after_tax = models.FloatField( null=True, blank=True, - help_text="CHP standby charges in year one, after tax [\$]" + help_text="CHP standby charges in year one, after tax [$]" ) lifecycle_standby_cost_after_tax = models.FloatField( null=True, blank=True, @@ -4803,7 +4803,7 @@ class CHPOutputs(BaseModel, models.Model): ) initial_capital_costs = models.FloatField( null=True, blank=True, - help_text="Initial capital costs of the CHP system, before incentives [\$]" + help_text="Initial capital costs of the CHP system, before incentives [$]" ) def clean(): From 12c4023b6c69a340ce5c22fa003a17dc2720950e Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 10 Sep 2025 12:06:03 -0600 Subject: [PATCH 07/26] Update outdated syntax T->min --- reoptjl/validators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reoptjl/validators.py b/reoptjl/validators.py index a65a60b02..d5f19dca0 100644 --- a/reoptjl/validators.py +++ b/reoptjl/validators.py @@ -649,9 +649,9 @@ def validate_time_series(series: list, time_steps_per_hour: int) -> Tuple[list, if time_steps_per_hour < time_steps_per_hour_in_series: resampling_msg = f"Downsampled to match time_steps_per_hour via average." - index = pd.date_range('1/1/2000', periods=n, freq=f'{int(60/time_steps_per_hour_in_series)}T') + index = pd.date_range('1/1/2000', periods=n, freq=f'{int(60/time_steps_per_hour_in_series)}min') s = pd.Series(series, index=index) - s = s.resample(f'{int(60/time_steps_per_hour)}T').mean() + s = s.resample(f'{int(60/time_steps_per_hour)}min').mean() return s.tolist(), resampling_msg, "" # time_steps_per_hour > time_steps_per_hour_in_series From e2de5f919f6d36fc479ea79e2414cb15092675a9 Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 10 Sep 2025 13:52:56 -0600 Subject: [PATCH 08/26] Add git submodule for the EVI-EnLitePy server_version branch --- .gitmodules | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitmodules b/.gitmodules index e69de29bb..04f5826de 100644 --- a/.gitmodules +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "EVI-EnLitePy"] + path = EVI-EnLitePy + url = https://github.nrel.gov/AVCI/EVI-EnLitePy.git + branch = server_version \ No newline at end of file From 21b6ae76cde9ea64944f570ca60c0ba1e0b5d52a Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 10 Sep 2025 13:53:35 -0600 Subject: [PATCH 09/26] Conditionally install EVI-EnLitePy if submodule is installed --- Dockerfile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e585b6b67..c5c064371 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,12 +9,18 @@ ENV SRC_DIR=/opt/reopt/reo/src ENV LD_LIBRARY_PATH="/opt/reopt/reo/src:${LD_LIBRARY_PATH}" # Copy all code -ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONDONTWRITEBYTECODE=1 COPY . /opt/reopt # Install python packages WORKDIR /opt/reopt RUN ["pip", "install", "-r", "requirements.txt"] +# Conditionally install EVI-EnLitePy and pydantic (dependency) if EVI-EnLitePy has been cloned via git submodule +RUN if [ -d "/opt/reopt/EVI-EnLitePy" ] && [ "$(ls -A /opt/reopt/EVI-EnLitePy)" ]; then \ + cd /opt/reopt/EVI-EnLitePy && pip install -e .; \ + pip install pydantic; \ +fi + EXPOSE 8000 ENTRYPOINT ["/bin/bash", "-c"] From a7dd6fa4e0e2a56215633b4965e0e641e946c611 Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 10 Sep 2025 13:54:36 -0600 Subject: [PATCH 10/26] Add an example view for EVI-EnLitePy with internal I/O .json file --- load_builder/ensite.json | 603 +++++++++++++++++++++++++++++++++++++++ load_builder/urls.py | 1 + load_builder/views.py | 24 ++ 3 files changed, 628 insertions(+) create mode 100644 load_builder/ensite.json diff --git a/load_builder/ensite.json b/load_builder/ensite.json new file mode 100644 index 000000000..8a899692e --- /dev/null +++ b/load_builder/ensite.json @@ -0,0 +1,603 @@ +{ + "simConfig": { + "tStart": 0, + "tEnd": 604800, + "dowStart": 0 + }, + "hubConfig": { + "nodes": { + "grid": { + "type": "Grid", + "pMax": 300000.0, + "pMin": 0, + "eff": 0.99 + }, + "evse1": { + "type": "EVSE", + "pMax": 300000.0, + "pMin": 0, + "eff": 0.98, + "nEVPort": 2, + "pci": [ + "LD", + "LD" + ] + }, + "evse2": { + "type": "EVSE", + "pMax": 300000.0, + "pMin": 0, + "eff": 0.98, + "nEVPort": 2, + "pci": "LD" + }, + "evse3": { + "type": "EVSE", + "pMax": 300000.0, + "pMin": 0, + "eff": 0.98, + "nEVPort": 2, + "pci": "LD" + }, + "evse4": { + "type": "EVSE", + "pMax": 300000.0, + "pMin": 0, + "eff": 0.98, + "nEVPort": 2, + "pci": "MD/HD" + }, + "evse5": { + "type": "EVSE", + "pMax": 300000.0, + "pMin": 0, + "eff": 0.98, + "nEVPort": 2, + "pci": "MD/HD" + }, + "building_load": { + "type": "CPL", + "time": [ + 0, + 900, + 1800, + 2700, + 3600, + 4500, + 5400, + 6300, + 7200, + 8100, + 9000, + 9900, + 10800, + 11700, + 12600, + 13500, + 14400, + 15300, + 16200, + 17100, + 18000, + 18900, + 19800, + 20700, + 21600, + 22500, + 23400, + 24300, + 25200, + 26100, + 27000, + 27900, + 28800, + 29700, + 30600, + 31500, + 32400, + 33300, + 34200, + 35100, + 36000, + 36900, + 37800, + 38700, + 39600, + 40500, + 41400, + 42300, + 43200, + 44100, + 45000, + 45900, + 46800, + 47700, + 48600, + 49500, + 50400, + 51300, + 52200, + 53100, + 54000, + 54900, + 55800, + 56700, + 57600, + 58500, + 59400, + 60300, + 61200, + 62100, + 63000, + 63900, + 64800, + 65700, + 66600, + 67500, + 68400, + 69300, + 70200, + 71100, + 72000, + 72900, + 73800, + 74700, + 75600, + 76500, + 77400, + 78300, + 79200, + 80100, + 81000, + 81900, + 82800, + 83700, + 84600, + 85500, + 86400, + 87300, + 88200, + 89100, + 90000, + 90900, + 91800, + 92700, + 93600, + 94500, + 95400, + 96300, + 97200, + 98100, + 99000, + 99900, + 100800, + 101700, + 102600, + 103500, + 104400, + 105300, + 106200, + 107100, + 108000, + 108900, + 109800, + 110700, + 111600, + 112500, + 113400, + 114300, + 115200, + 116100, + 117000, + 117900, + 118800, + 119700, + 120600, + 121500, + 122400, + 123300, + 124200, + 125100, + 126000, + 126900, + 127800, + 128700, + 129600, + 130500, + 131400, + 132300, + 133200, + 134100, + 135000, + 135900, + 136800, + 137700, + 138600, + 139500, + 140400, + 141300, + 142200, + 143100, + 144000, + 144900, + 145800, + 146700, + 147600, + 148500, + 149400, + 150300, + 151200, + 152100, + 153000, + 153900, + 154800, + 155700, + 156600, + 157500, + 158400, + 159300, + 160200, + 161100, + 162000, + 162900, + 163800, + 164700, + 165600, + 166500, + 167400, + 168300, + 169200, + 170100, + 171000, + 171900, + 172800 + ], + "power": [ + 0, + 115768.90557614488, + 111052.02340350732, + 106243.96540927405, + 101248.76581765672, + 96570.04466587743, + 93198.17823814304, + 90826.32916441964, + 87023.57942474863, + 83436.24449431658, + 81655.80725341121, + 80351.78355011741, + 78391.01470690272, + 76524.89719584149, + 75849.64007460905, + 75171.95377484719, + 74762.94970004473, + 74785.98133219888, + 74974.87833325299, + 75034.88245524601, + 75053.78539510825, + 75462.00915896866, + 76211.98052987862, + 77151.15330971242, + 78713.83055741635, + 80697.72125438195, + 83229.71074938202, + 86422.01973010179, + 91701.66953882159, + 96826.09974563181, + 100536.59047897511, + 98627.50125113038, + 101026.77928593893, + 113397.30768915478, + 120789.37878468739, + 125086.23067937214, + 138968.87451142137, + 149728.1466181706, + 154689.7620241398, + 162414.35875756864, + 172040.3208539825, + 181616.36019767198, + 188361.01006918508, + 193894.3227290636, + 199348.66172264077, + 204691.88380812074, + 211477.3931988581, + 219553.8144975878, + 227734.307890666, + 235623.86863660946, + 241252.81855464025, + 245748.68819021765, + 250113.82821523494, + 253684.50561679932, + 256906.64040314284, + 259566.53136180423, + 262138.29058731813, + 265587.8329082571, + 268022.07549065276, + 268831.3090258474, + 268046.3556785552, + 266687.2767598618, + 265407.7785675803, + 263901.6050685742, + 262238.631827651, + 259864.17945746586, + 259258.14426325096, + 260351.58518913263, + 260781.2744725022, + 260556.6113717029, + 260612.13738030978, + 260900.1793592792, + 260937.2231589168, + 260812.33700178415, + 258894.05801698912, + 256387.57735312398, + 252556.31076981314, + 247663.10940524103, + 244336.46921034344, + 241006.16187443602, + 229296.17149419116, + 218036.09911564825, + 213202.7845893712, + 207379.7079880955, + 201109.48916701777, + 195030.73111149587, + 188231.49321122604, + 181211.80601803545, + 174912.1254591292, + 168300.37541429105, + 161171.66767080297, + 159829.78547531943, + 155668.44684056749, + 147021.56004675277, + 140195.87205118494, + 132990.5537835949, + 125932.22211296459, + 119607.3938765824, + 114224.16119668349, + 108861.86410024502, + 103633.475821886, + 98855.20786692914, + 95319.3349150407, + 92669.2333608828, + 88636.37784744859, + 84625.87644647704, + 81987.95286556207, + 80257.94930263511, + 78025.3995160709, + 76333.90547973095, + 76404.27574800745, + 76149.30380447183, + 75283.44017603615, + 75060.94051033257, + 75373.68350340766, + 75378.24621716273, + 75511.34395530022, + 75733.84757209556, + 75959.57567428125, + 76754.41482547301, + 77732.24620558122, + 78601.31828930757, + 79863.87527191175, + 81645.1725729833, + 84839.87213999625, + 87497.74808954235, + 89847.10387480202, + 86621.91703097537, + 84866.33821827677, + 92744.99438738922, + 97866.32436802566, + 100442.49273944512, + 112207.93503517946, + 120191.37581769569, + 122510.3959391192, + 127966.47696577231, + 132511.31787877544, + 137214.93411374232, + 141265.0820730139, + 144706.10886189333, + 148362.4079961007, + 152096.62428724568, + 157752.47514897128, + 164580.07293775745, + 172594.16516825437, + 180988.5032815957, + 186457.95187991243, + 191574.73033201674, + 196665.25157405154, + 200116.9263952046, + 203445.69327931653, + 206353.23093329868, + 209344.86404155273, + 213477.5083782941, + 216364.45305298414, + 217472.23160771147, + 217784.5467271467, + 217966.88711359314, + 217654.3283523812, + 217435.56651799893, + 217033.88100754542, + 215240.0497811758, + 215956.1555711758, + 218573.78801257262, + 220305.6386701705, + 222489.12700566195, + 224959.58847458754, + 227719.01635537934, + 230726.10187793532, + 232996.42429362237, + 233730.3298875959, + 233519.48010681197, + 231517.3818674315, + 228752.41772804878, + 227481.0983919819, + 226052.31017154508, + 216331.94551423888, + 206790.52351717363, + 203604.6489288222, + 199183.91481633333, + 194448.6273390925, + 189280.3790565904, + 182981.8908821766, + 177064.2090301336, + 171202.26802169654, + 164947.3577182609, + 159196.5478214874, + 158350.694914731, + 156127.8238934354, + 149344.6406372949, + 142506.54723537544, + 135308.83708899393, + 128517.20039204955 + ] + }, + "BESS": { + "type": "ESS", + "eCap": 1000000.0, + "eff": 0.98, + "pMax": 300000.0, + "pMin": 0, + "socMin": 0.2, + "socMax": 0.95, + "socInit": 0.75 + } + }, + "ems": { + "type": "basic", + "pChgCap": 1800000.0, + "pap": "FCFS+SMX", + "xSM": 0.5 + } + }, + "evInfo": { + "stochasticModel": 1, + "chgDurMax": 7200, + "evTypes": { + "LD": { + "type": "LD", + "eCap": 60000.0, + "pChgMax": 300000.0, + "count": [ + 5, + 10, + 15, + 20, + 25, + 30, + 35 + ], + "targetFinalSOCPMF": { + "val": [ + 0.75, + 0.8, + 0.9 + ], + "prob": [ + 0.5, + 0.3, + 0.2 + ] + }, + "energyDemandPMF": { + "val": [ + 0.6, + 0.7, + 0.8 + ], + "prob": [ + 0.5, + 0.3, + 0.2 + ] + }, + "arrivalTimePMF": { + "0-6": { + "val": [ + 3600, + 14400, + 43200 + ], + "prob": [ + 0.5, + 0.3, + 0.2 + ], + "dur": 7200 + } + }, + "departureTimePMF": null + }, + "HD": { + "type": "HD", + "eCap": 200000.0, + "pChgMax": 600000.0, + "count": [ + 35, + 30, + 25, + 20, + 15, + 10, + 5 + ], + "targetFinalSOCPMF": { + "val": [ + 0.75, + 0.8, + 0.9 + ], + "prob": [ + 0.5, + 0.3, + 0.2 + ] + }, + "energyDemandPMF": { + "val": [ + 0.6, + 0.7, + 0.8 + ], + "prob": [ + 0.5, + 0.3, + 0.2 + ] + }, + "arrivalTimePMF": { + "1-5": { + "val": [ + 5400.0, + 16200.0, + 57600 + ], + "prob": [ + 0.5, + 0.3, + 0.2 + ], + "dur": 7200 + }, + "0,6": { + "val": [ + 0 + ], + "prob": [ + 1 + ], + "dur": 7200 + } + }, + "departureTimePMF": null + } + } + }, + "resultsConfig": { + "timeseriesDt": 3600, + "powerMetricsDt": 900, + "resultFieldOptions": { + "nodes": null, + "portUsage": true, + "queueLength": true, + "nodeStats": true, + "evStats": true + } + } +} \ No newline at end of file diff --git a/load_builder/urls.py b/load_builder/urls.py index f31cfc794..5e707b5e5 100644 --- a/load_builder/urls.py +++ b/load_builder/urls.py @@ -5,4 +5,5 @@ urlpatterns = [ re_path(r'^load_builder/?$', views.load_builder), + re_path(r'^ensite/?$', views.ensite_view), ] \ No newline at end of file diff --git a/load_builder/views.py b/load_builder/views.py index 58614936e..e46b8bc82 100644 --- a/load_builder/views.py +++ b/load_builder/views.py @@ -6,7 +6,31 @@ import numpy as np from django.http import JsonResponse from reo.exceptions import UnexpectedError +import os +def ensite_view(request): + """ + Attempts to import ensitepy and return the SystemSimulator class. + If the package is not available, returns None. + """ + try: + from ensitepy.simextension import enlitepyapi + + inputs_path = os.path.join(os.getcwd(), "load_builder", "ensite.json") + with open(inputs_path, 'r') as f: + enlitepy_json = json.load(f) + # print(enlitepy_json) + # import remote_pdb + # remote_pdb.set_trace(host='0.0.0.0', port=4444) + results = enlitepyapi.run(enlitepy_json) + + return JsonResponse({"message": "imported ensitepy and simextension successfully"}) + except Exception as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + err = UnexpectedError(exc_type, exc_value, exc_traceback, task='ensite') + # err.save_to_db() + return JsonResponse({"Error": err.message}, status=500) + def check_load_builder_inputs(loads_table): required_inputs = ["Power (W)", "Quantity", "% Run Time", "Start Mo.", "Stop Mo.", "Start Hr.", "Stop Hr."] From af876f8a5a9059aa51a1b5306f45fb23ff629244 Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 10 Sep 2025 15:41:36 -0600 Subject: [PATCH 11/26] Update ensite_view to accept POST body and return results --- load_builder/views.py | 43 ++++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/load_builder/views.py b/load_builder/views.py index e46b8bc82..32ae214d4 100644 --- a/load_builder/views.py +++ b/load_builder/views.py @@ -8,28 +8,29 @@ from reo.exceptions import UnexpectedError import os -def ensite_view(request): - """ - Attempts to import ensitepy and return the SystemSimulator class. - If the package is not available, returns None. - """ - try: - from ensitepy.simextension import enlitepyapi - - inputs_path = os.path.join(os.getcwd(), "load_builder", "ensite.json") - with open(inputs_path, 'r') as f: - enlitepy_json = json.load(f) - # print(enlitepy_json) - # import remote_pdb - # remote_pdb.set_trace(host='0.0.0.0', port=4444) - results = enlitepyapi.run(enlitepy_json) +try: + from ensitepy.simextension import enlitepyapi +except ImportError: + enlitepyapi = None - return JsonResponse({"message": "imported ensitepy and simextension successfully"}) - except Exception as e: - exc_type, exc_value, exc_traceback = sys.exc_info() - err = UnexpectedError(exc_type, exc_value, exc_traceback, task='ensite') - # err.save_to_db() - return JsonResponse({"Error": err.message}, status=500) +def ensite_view(request): + if enlitepyapi is not None: + try: + if request.method == 'POST': + try: + enlitepy_json = json.loads(request.body) + except Exception as e: + return JsonResponse({"Error": f"Invalid JSON in request body: {e}"}, status=400) + results = enlitepyapi.run(enlitepy_json) + return JsonResponse({"results": results}) + else: + return JsonResponse({"Error": "Must POST a JSON body for ensitepy."}, status=400) + except Exception as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + err = UnexpectedError(exc_type, exc_value, exc_traceback, task='ensite') + return JsonResponse({"Error": err.message}, status=500) + else: + return JsonResponse({"Error": "ensitepy is not installed."}, status=500) def check_load_builder_inputs(loads_table): From 472d3785712446441e21da7bec0dd85bc7dee4fe Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 10 Sep 2025 22:08:39 -0600 Subject: [PATCH 12/26] Add EVI-EnLitePy submodule pointer --- EVI-EnLitePy | 1 + 1 file changed, 1 insertion(+) create mode 160000 EVI-EnLitePy diff --git a/EVI-EnLitePy b/EVI-EnLitePy new file mode 160000 index 000000000..75e5e6e4c --- /dev/null +++ b/EVI-EnLitePy @@ -0,0 +1 @@ +Subproject commit 75e5e6e4c106e7b28c932bbc524816c199661759 From 5d99c2271baae1e5d1f03947dd2d5138dd6f69a2 Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 10 Sep 2025 22:53:38 -0600 Subject: [PATCH 13/26] Reduce dev and staging branch server resources --- .helm/values.staging.yaml | 4 +--- .helm/values.yaml | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.helm/values.staging.yaml b/.helm/values.staging.yaml index 5bc76eb41..d7cfc7107 100644 --- a/.helm/values.staging.yaml +++ b/.helm/values.staging.yaml @@ -1,4 +1,2 @@ appEnv: staging -djangoSettingsModule: reopt_api.staging_settings -juliaMemoryRequest: "8000Mi" -juliaMemoryLimit: "8000Mi" \ No newline at end of file +djangoSettingsModule: reopt_api.staging_settings \ No newline at end of file diff --git a/.helm/values.yaml b/.helm/values.yaml index 7348a0da7..4a67831ca 100644 --- a/.helm/values.yaml +++ b/.helm/values.yaml @@ -8,18 +8,18 @@ redisDataVolumeStorageClassName: juliaHost: "{{ .Chart.Name }}-julia-service" redisHost: "{{ .Chart.Name }}-redis-service" redisPort: 6379 -djangoReplicas: 2 -djangoCpuRequest: "2000m" -djangoCpuLimit: "4000m" -djangoMemoryRequest: "1600Mi" -djangoMemoryLimit: "1600Mi" -celeryReplicas: 2 +djangoReplicas: 1 +djangoCpuRequest: "1000m" +djangoCpuLimit: "2000m" +djangoMemoryRequest: "700Mi" +djangoMemoryLimit: "700Mi" +celeryReplicas: 1 celeryCpuRequest: "100m" celeryCpuLimit: "2000m" celeryMemoryRequest: "700Mi" celeryMemoryLimit: "700Mi" juliaReplicas: 1 -juliaCpuRequest: "1000m" -juliaCpuLimit: "2000m" -juliaMemoryRequest: "8000Mi" -juliaMemoryLimit: "8000Mi" +juliaCpuRequest: "500m" +juliaCpuLimit: "1500m" +juliaMemoryRequest: "6000Mi" +juliaMemoryLimit: "6000Mi" From 822a062edcfa9e8eb90b87c15585c9557f44b5cb Mon Sep 17 00:00:00 2001 From: bill-becker Date: Thu, 11 Sep 2025 09:35:51 -0600 Subject: [PATCH 14/26] Add more celery memory; separate lower memory for Julia-only pod --- .helm/templates/julia-deployment.yaml | 4 ++-- .helm/values.production.yaml | 4 ++++ .helm/values.yaml | 10 +++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.helm/templates/julia-deployment.yaml b/.helm/templates/julia-deployment.yaml index 9f42da480..dea2880a6 100644 --- a/.helm/templates/julia-deployment.yaml +++ b/.helm/templates/julia-deployment.yaml @@ -53,7 +53,7 @@ spec: resources: requests: cpu: {{ .Values.juliaCpuRequest | quote }} - memory: {{ .Values.juliaMemoryRequest | quote }} + memory: "2000Mi" limits: cpu: {{ .Values.juliaCpuLimit | quote }} - memory: {{ .Values.juliaMemoryLimit | quote }} + memory: "2000Mi" diff --git a/.helm/values.production.yaml b/.helm/values.production.yaml index 026003ca6..c1c437b01 100644 --- a/.helm/values.production.yaml +++ b/.helm/values.production.yaml @@ -1,11 +1,15 @@ appEnv: production djangoSettingsModule: reopt_api.production_settings djangoReplicas: 10 +djangoCpuRequest: "1000m" +djangoCpuLimit: "2000m" djangoMemoryRequest: "2000Mi" djangoMemoryLimit: "2000Mi" celeryReplicas: 10 celeryMemoryRequest: "900Mi" celeryMemoryLimit: "900Mi" +celeryCpuRequest: "100m" +celeryCpuLimit: "1000m" juliaReplicas: 5 juliaCpuRequest: "2000m" juliaCpuLimit: "4000m" diff --git a/.helm/values.yaml b/.helm/values.yaml index 4a67831ca..b6dae9237 100644 --- a/.helm/values.yaml +++ b/.helm/values.yaml @@ -9,17 +9,17 @@ juliaHost: "{{ .Chart.Name }}-julia-service" redisHost: "{{ .Chart.Name }}-redis-service" redisPort: 6379 djangoReplicas: 1 -djangoCpuRequest: "1000m" +djangoCpuRequest: "500m" djangoCpuLimit: "2000m" djangoMemoryRequest: "700Mi" djangoMemoryLimit: "700Mi" celeryReplicas: 1 celeryCpuRequest: "100m" -celeryCpuLimit: "2000m" -celeryMemoryRequest: "700Mi" -celeryMemoryLimit: "700Mi" +celeryCpuLimit: "1000m" +celeryMemoryRequest: "1600Mi" +celeryMemoryLimit: "1600Mi" juliaReplicas: 1 juliaCpuRequest: "500m" -juliaCpuLimit: "1500m" +juliaCpuLimit: "2000m" juliaMemoryRequest: "6000Mi" juliaMemoryLimit: "6000Mi" From ef3aa9ed3140c61d755c7cfb0b47a75126ad66ad Mon Sep 17 00:00:00 2001 From: Byron Pullutasig <115118857+bpulluta@users.noreply.github.com> Date: Wed, 24 Sep 2025 19:01:04 -0600 Subject: [PATCH 15/26] feat(ensite): default enrichment (normalize + annual + utilization) with enrichment_version=1; update UI input contract docs; comparison script defaults to enriched output --- .gitignore | 1 + README_ENSITE_UI.md | 266 +++++++++++++++++++++++++++ load_builder/views.py | 202 +++++++++++++++++++-- run_enlitepy_compare.py | 387 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 845 insertions(+), 11 deletions(-) create mode 100644 README_ENSITE_UI.md create mode 100644 run_enlitepy_compare.py diff --git a/.gitignore b/.gitignore index ff7726439..aba3fad8d 100755 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,4 @@ julia_src/licenseserver julia_src/Dockerfile.xpress docker-compose.xpress.yml julia_src/solver_setup.sh +compare_run_*.json diff --git a/README_ENSITE_UI.md b/README_ENSITE_UI.md new file mode 100644 index 000000000..2ed8c8086 --- /dev/null +++ b/README_ENSITE_UI.md @@ -0,0 +1,266 @@ +# EnSite UI → API Integration Guide + +The frontend (UI) only sends a **simple declarative JSON** describing what the user edits: charger settings, vehicle arrival counts, arrival distribution, and optional max charge duration. **It does NOT build the full EnLitePy engine payload.** The Django view (`ensite_view`) transforms this UI JSON into the full simulation payload (`simConfig`, `hubConfig`, `evInfo`, `resultsConfig`) and runs EnLitePy. + +## Endpoint + +Stable path: +``` +POST /stable/ensite/ +``` +If running locally with Django development server: +``` +http://localhost:8000/stable/ensite/ +``` +(Ensure the trailing slash is included; the server will usually redirect if omitted.) + +## Authentication + +If the API is secured with a bearer token, include: +``` +Authorization: Bearer +``` +If no auth is required in your environment, omit the header. + +## Request Headers +``` +Content-Type: application/json +Accept: application/json +Authorization: Bearer # optional +``` + +## UI Input Contract (Simple JSON the Frontend Sends) + +The UI sends ONLY a lightweight object with optional sections. Any omitted section uses server defaults. + +Top-level keys (all optional): + +| Key | Type | Description | +|-----|------|-------------| +| `chargers` | object | Per charger category overrides (counts, power, efficiency, pci) | +| `vehicles` | object | Per vehicle class overrides (weekday/weekend arrivals, SOC, power, battery size) | +| `arrival` | object | Two arrays (`weekday`, `weekend`) each length 12 (2‑hr bins, percentages) | +| `maxChargeDurationMin` | number/null | Max charge duration in minutes (null or omit = no limit) | + +Charger category keys (if present): `L1`, `L2`, `DCFC_LD`, `DCFC_MDHD`. + +Vehicle class keys (if present): `LD`, `MD`, `HD`. + +Example UI request (only what the user edits): +```json +{ + "chargers": { + "L2": { "count": 4, "power_kW": 11.5, "eff": 0.86, "pci": "LD" }, + "DCFC_LD": { "count": 1, "power_kW": 150, "eff": 0.92, "pci": "LD" } + }, + "vehicles": { + "LD": { "weekday": 40, "weekend": 15, "battery_kWh": 82, "initSOC": 0.15, "targetSOC": 0.90, "pChgMax_kW": 170 } + }, + "arrival": { + "weekday": [0,0,0,0,5,10,10,15,20,20,5,5], + "weekend": [0,0,0,0,0,10,15,25,20,15,5,0] + }, + "maxChargeDurationMin": 180 +} +``` + +That’s it—the UI does not build nodes, counts arrays, PMFs, or unit conversions. + +## Server Transformation (What Django Builds Internally) + +Given the UI JSON above, the server produces a full EnLitePy payload: +1. Expands charger `count` into individual EVSE nodes (naming: `EVSE__`). +2. Converts `power_kW` → watts (`pMax`). +3. Builds each vehicle class `count` array: `[weekend, weekday, weekday, weekday, weekday, weekday, weekend]`. +4. Computes `energyDemandPMF` from `targetSOC - initSOC` (clamped 0–1). +5. Normalizes non-zero arrival bucket percentages into probability arrays (2‑hr bins → 7200 sec `dur`). +6. Converts battery and charger powers to integer watt values (`eCap`, `pChgMax`). +7. Converts `maxChargeDurationMin` → seconds (`chgDurMax`). +8. Adds standard `simConfig`, `resultsConfig`, and `ems` defaults. + +Conceptual (abbreviated) resulting payload: +```json +{ + "simConfig": {"tStart":0,"tEnd":604800,"dowStart":0}, + "hubConfig": { + "nodes": { + "grid": {"type":"Grid","pMax":500000,"pMin":0,"eff":0.99}, + "EVSE_L2_1": {"type":"EVSE","pMax":11500,"pMin":0,"eff":0.86,"nEVPort":1,"pci":"LD"}, + "EVSE_L2_2": {"type":"EVSE","pMax":11500,"pMin":0,"eff":0.86,"nEVPort":1,"pci":"LD"}, + "EVSE_L2_3": {"type":"EVSE","pMax":11500,"pMin":0,"eff":0.86,"nEVPort":1,"pci":"LD"}, + "EVSE_L2_4": {"type":"EVSE","pMax":11500,"pMin":0,"eff":0.86,"nEVPort":1,"pci":"LD"}, + "EVSE_DCFC_LD_1": {"type":"EVSE","pMax":150000,"pMin":0,"eff":0.92,"nEVPort":1,"pci":"LD"} + }, + "ems": {"type":"basic","pChgCap":500000,"pap":"FCFS+SMX"} + }, + "evInfo": { + "stochasticModel":1, + "chgDurMax":10800, + "evTypes": { + "LD": { + "type":"LD", + "eCap":82000, + "pChgMax":170000, + "count":[15,40,40,40,40,40,15], + "targetFinalSOCPMF":{"val":[0.9],"prob":[1.0]}, + "energyDemandPMF":{"val":[0.75],"prob":[1.0]}, + "arrivalTimePMF": {"1-5": {"val":[28800,36000,43200,50400,57600,64800,72000],"prob":[...] ,"dur":7200}, "0,6": {"val":[43200,50400,57600,64800],"prob":[...],"dur":7200}}, + "departureTimePMF": null + } + } + }, + "resultsConfig": {"timeseriesDt":3600,"powerMetricsDt":3600,"resultFieldOptions":{"nodes":"Grid","portUsage":true,"queueLength":true,"nodeStats":true,"evStats":true}} +} +``` + +## Why This Design +| Benefit | Explanation | +|---------|-------------| +| Thin UI | Frontend only manages user-facing fields (no internal engine schema knowledge). | +| Backward compatibility | Engine schema changes isolated to server transformation layer. | +| Validation centralization | Single place to clamp SOC, normalize arrival probabilities, enforce array lengths. | +| Faster iteration | Add new derived metrics in server without redeploying UI. | + +## Minimal Frontend Payload Builder (Pseudocode) +```javascript +// uiState collects user edits. Only send what changed; server fills defaults. +const payload = { + chargers: uiState.chargers, // optional + vehicles: uiState.vehicles, // optional + arrival: uiState.arrival, // { weekday: [...12], weekend: [...12] } + maxChargeDurationMin: uiState.maxDurationMin // optional +}; +fetch('/stable/ensite/', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(payload) +}).then(r => r.json()).then(console.log); +``` + +## Validation Expectations (Server-Side) +| Check | Rule | Failure Response | +|-------|------|------------------| +| Arrival arrays length | Exactly 12 buckets | 400 with error message | +| Arrival values | ≥ 0 | 400 error | +| SOC bounds | 0 ≤ init < target ≤ 1 | Clamped or 400 depending on severity | +| Count arrays | Derived only (UI never sends them) | n/a | +| Charger counts | ≥ 0 integer | 400 if invalid | + +## Automatic Enrichment (Default Behavior) + +Every response now automatically includes cleaned & enriched metrics: + +| Field | Description | +|-------|-------------| +| `equipment_statistics` | Original weekly equipment statistics list | +| `equipment_statistics_annual` | Annual-scaled version (select kWh metrics × 365/7) | +| `ev_statistics` | Original weekly EV stats | +| `ev_statistics_annual` | Annual-scaled EV counts & energy; durations converted to hours | +| `timeseries.power_in_grid` | Weekly (or simulation horizon) power series | +| `timeseries.power_in_grid_annual` | Replicated to 8760 steps if source shorter | +| `logs` | Always present (empty list if none) | +| `enrichment_version` | Integer version of enrichment logic (currently 1) | + +No request flags are needed—UI simply posts the minimal input JSON and receives enriched results. + +## Response (Simplified) +```json +{ + "results": { + "equipment_statistics": [...], + "equipment_statistics_annual": [...], + "ev_statistics": [...], + "ev_statistics_annual": [...], + "timeseries": {"power_in_grid": [...], "power_in_grid_annual": [...]}, + "logs": [], + "enrichment_version": 1 + } +} +``` + +## Optional Advanced Mode (Direct Payload) +If a power user posts the full engine schema (with `simConfig` etc.), the server detects it and runs directly—skipping transformation. + +## Quick UI Smoke Test +```bash +curl -s -X POST http://localhost:8000/stable/ensite/ \ + -H 'Content-Type: application/json' -H 'Accept: application/json' \ + -d '{"vehicles":{"LD":{"weekday":5,"weekend":2}}}' | jq '.results | keys' +``` + +--- +For automated parity checks or deeper validation use `run_enlitepy_compare.py`. + +## curl Example +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Authorization: Bearer $ENLITEPY_API_TOKEN" \ # optional + -d @payload.json \ + http://localhost:8000/stable/ensite/ +``` + +## JavaScript fetch Example +```javascript +async function runSimulation(payload) { + const resp = await fetch('http://localhost:8000/stable/ensite/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + // 'Authorization': 'Bearer ' + token, // uncomment if needed + }, + body: JSON.stringify(payload) + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`HTTP ${resp.status}: ${text}`); + } + const data = await resp.json(); + console.log('Simulation results:', data); + return data; +} +``` + +## Response Structure (Simplified) +Successful responses contain: +```json +{ + "status": "OK", // or similar + "results": { + "equipment_statistics": [...], + "ev_statistics": [...], + "timeseries": {"power_in_grid": [ ... ]}, + "logs": [] + } +} +``` +Some fields may be JSON strings if not normalized; downstream clients can parse if needed. + +## Common Errors +| Issue | Cause | Fix | +|-------|-------|-----| +| 400 Bad Request | Missing required keys | Ensure `simConfig`, `hubConfig`, `evInfo`, `resultsConfig` present | +| 415 Unsupported Media Type | Missing Content-Type header | Add `Content-Type: application/json` | +| 401 Unauthorized | Missing or invalid token | Provide valid Bearer token if auth enabled | +| 500 Internal Server Error | Server-side exception | Re-check payload values; look at server logs | + +## Tips +- Keep integer power values in watts if the engine expects integer (e.g. 13 kW => 13000). +- Use consistent time units (seconds for `tStart`, `tEnd`, arrival values). +- For a no-op auth environment, simply omit the Authorization header. +- Start simple (one EV type, minimal EVSE nodes) before scaling up complexity. + +## Minimal Quick Test +```bash +python - <<'PY' +import json, urllib.request +payload = {"simConfig":{"tStart":0,"tEnd":604800,"dowStart":0},"hubConfig":{"nodes":{"grid":{"type":"Grid","pMax":500000,"pMin":0,"eff":0.99}},"ems":{"type":"basic","pChgCap":500000,"pap":"FCFS+SMX"}},"evInfo":{"stochasticModel":1,"chgDurMax":None,"evTypes":{}},"resultsConfig":{"timeseriesDt":3600,"powerMetricsDt":3600,"resultFieldOptions":{"nodes":"Grid","portUsage":True,"queueLength":True,"nodeStats":True,"evStats":True}}} +req = urllib.request.Request('http://localhost:8000/stable/ensite/', data=json.dumps(payload).encode(), headers={'Content-Type':'application/json','Accept':'application/json'}) +print(json.loads(urllib.request.urlopen(req).read().decode())['results'].keys()) +PY +``` + +--- +If you need a deeper validation or remote/local parity comparison, use `run_enlitepy_compare.py` which was added for automated checks. diff --git a/load_builder/views.py b/load_builder/views.py index 32ae214d4..54645376f 100644 --- a/load_builder/views.py +++ b/load_builder/views.py @@ -1,4 +1,4 @@ -# REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NREL/REopt_API/blob/master/LICENSE. +# REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NREL/REopt_API/load_builder/views.py import csv import io import json @@ -7,25 +7,205 @@ from django.http import JsonResponse from reo.exceptions import UnexpectedError import os +import requests +from statistics import mean +from typing import Any, Dict, List try: from ensitepy.simextension import enlitepyapi except ImportError: enlitepyapi = None +# EnLitePy Configuration Constants +DEFAULT_CHARGER_DEFAULTS = { + "L1": {"count": 0, "power_kW": 2.4, "eff": 0.80, "pci": "LD"}, + "L2": {"count": 0, "power_kW": 13.0, "eff": 0.80, "pci": "LD"}, + "DCFC_LD": {"count": 0, "power_kW": 50.0, "eff": 0.90, "pci": "LD"}, + "DCFC_MDHD": {"count": 0, "power_kW": 250.0, "eff": 0.90, "pci": "MD/HD"}, +} + +DEFAULT_VEHICLE_DEFAULTS = { + "LD": {"weekday": 0, "weekend": 0, "battery_kWh": 80, "initSOC": 0.10, "targetSOC": 0.80, "pChgMax_kW": 120}, + "MD": {"weekday": 0, "weekend": 0, "battery_kWh": 200, "initSOC": 0.20, "targetSOC": 0.80, "pChgMax_kW": 300}, + "HD": {"weekday": 0, "weekend": 0, "battery_kWh": 500, "initSOC": 0.30, "targetSOC": 0.85, "pChgMax_kW": 600}, +} + +SIM_DAYS = 7 +EMS_CONFIG = {"type": "basic", "pChgCap": 500_000, "pap": "FCFS+SMX"} +BASE_SIM_CONFIG = {"tStart": 0, "tEnd": 7 * 24 * 3600, "dowStart": 0} +RESULTS_CONFIG_BASE = {"timeseriesDt": 3600, "powerMetricsDt": 3600} +WEEK_TO_YEAR_SCALE = 365 / 7 + +# Keys for annual scaling +EQUIPMENT_ANNUAL_SCALARS = {"Energy output [kWh]", "Energy loss [kWh]"} +EV_COUNT_SCALARS = {"Total EVs arrived [#]", "Total unique EVs arrived [#]", "EVs no queue wait [#]", "EVs arrived but not charged [#]", "EVs that did not start/complete charge [#]"} +EV_ENERGY_SCALARS = {"Total energy charged [kWh]"} + +# --------------------------------------------------------------------------- +# EnLitePy Helper Functions +# --------------------------------------------------------------------------- + +def build_arrival_pmf(weekday_pct, weekend_pct, bucket_hours=2): + """Convert UI bucket percentages to API arrivalTimePMF structure.""" + bucket_seconds = bucket_hours * 3600 + + def normalize(side, label): + if len(side) != 12: + raise ValueError(f"{label} arrival array must have 12 entries (got {len(side)})") + if any(x < 0 for x in side): + raise ValueError(f"{label} arrival percentages must be non-negative") + active = [(i, x) for i, x in enumerate(side) if x > 0] + if not active: # fallback deterministic midnight arrival + return {"val": [0], "prob": [1.0], "dur": bucket_seconds} + total = sum(x for _, x in active) + vals = [i * bucket_seconds for i, _ in active] + probs = [x / total for _, x in active] + # Adjust final probability for floating sum errors + diff = 1.0 - sum(probs) + if abs(diff) > 1e-9: + probs[-1] += diff + return {"val": vals, "prob": [round(p, 6) for p in probs], "dur": bucket_seconds} + + return {"1-5": normalize(weekday_pct, "Weekday"), "0,6": normalize(weekend_pct, "Weekend")} + + +def build_enlitepy_payload(ui_inputs=None): + """Build EnLitePy payload from UI inputs or use defaults.""" + # Apply UI overrides or use defaults + charger_config = ui_inputs.get("chargers", {}) if ui_inputs else {} + vehicle_config = ui_inputs.get("vehicles", {}) if ui_inputs else {} + arrival_config = ui_inputs.get("arrival", {}) if ui_inputs else {} + max_duration = ui_inputs.get("maxChargeDurationMin") if ui_inputs else None + + # Deep merge with defaults + chargers = {} + for cat, defaults in DEFAULT_CHARGER_DEFAULTS.items(): + chargers[cat] = {**defaults} + if cat in charger_config: + chargers[cat].update(charger_config[cat]) + + vehicles = {} + for vtype, defaults in DEFAULT_VEHICLE_DEFAULTS.items(): + vehicles[vtype] = {**defaults} + if vtype in vehicle_config: + vehicles[vtype].update(vehicle_config[vtype]) + + # Arrival patterns + weekday_pct = arrival_config.get("weekday", [0] * 12) + weekend_pct = arrival_config.get("weekend", [0] * 12) + + # Build nodes + nodes = {"grid": {"type": "Grid", "pMax": 500_000, "pMin": 0, "eff": 0.99}} + + # Add EVSE nodes + for cat, cfg in chargers.items(): + for i in range(1, cfg["count"] + 1): + nodes[f"EVSE_{cat}_{i}"] = { + "type": "EVSE", + "pMax": int(cfg["power_kW"] * 1000), + "pMin": 0, + "eff": cfg["eff"], + "nEVPort": 1, + "pci": cfg["pci"], + } + + # Build EV types + def expand_counts(weekend, weekday): + return [weekend, weekday, weekday, weekday, weekday, weekday, weekend] + + weekday_weekend = {k: expand_counts(v["weekend"], v["weekday"]) for k, v in vehicles.items()} + arrival_pmf = build_arrival_pmf(weekday_pct, weekend_pct) + + ev_types = {} + for k, v in vehicles.items(): + delta = max(0.0, min(1.0, v["targetSOC"] - v["initSOC"])) + ev_types[k] = { + "type": k, + "eCap": int(v["battery_kWh"] * 1000), + "pChgMax": int(v["pChgMax_kW"] * 1000), + "count": weekday_weekend[k], + "targetFinalSOCPMF": {"val": [round(v["targetSOC"], 4)], "prob": [1.0]}, + "energyDemandPMF": {"val": [round(delta, 4)], "prob": [1.0]}, + "arrivalTimePMF": arrival_pmf, + "departureTimePMF": None, + } + + sim_cfg = dict(BASE_SIM_CONFIG) + sim_cfg["tEnd"] = SIM_DAYS * 24 * 3600 + + duration_max = None + if max_duration is not None: + duration_max = int(max_duration * 60) # Convert minutes to seconds + + return { + "simConfig": sim_cfg, + "hubConfig": {"nodes": nodes, "ems": EMS_CONFIG}, + "evInfo": {"stochasticModel": 1, "chgDurMax": duration_max, "evTypes": ev_types}, + "resultsConfig": { + **RESULTS_CONFIG_BASE, + "resultFieldOptions": { + "nodes": "Grid", + "portUsage": True, + "queueLength": True, + "nodeStats": True, + "evStats": True, + }, + }, + } + + +def is_ui_input(data): + """Detect if input is UI format vs direct EnLitePy payload.""" + if not isinstance(data, dict): + return False + + # Check for UI-specific keys + ui_keys = {"chargers", "vehicles", "arrival", "maxChargeDurationMin"} + if any(key in data for key in ui_keys): + return True + + # Check for direct EnLitePy payload keys + enlitepy_keys = {"simConfig", "hubConfig", "evInfo", "resultsConfig"} + if any(key in data for key in enlitepy_keys): + return False + + # Default to UI format for safety + return True + def ensite_view(request): + """Enhanced EnLitePy endpoint that handles both UI inputs and direct payloads.""" if enlitepyapi is not None: try: if request.method == 'POST': try: - enlitepy_json = json.loads(request.body) - except Exception as e: + input_data = json.loads(request.body) + except json.JSONDecodeError as e: return JsonResponse({"Error": f"Invalid JSON in request body: {e}"}, status=400) - results = enlitepyapi.run(enlitepy_json) - return JsonResponse({"results": results}) + + # Detect input format and prepare payload + if is_ui_input(input_data): + # Transform UI inputs to EnLitePy payload + try: + enlitepy_payload = build_enlitepy_payload(input_data) + except Exception as e: + return JsonResponse({"Error": f"Failed to build EnLitePy payload: {e}"}, status=400) + else: + # Use direct EnLitePy payload + enlitepy_payload = input_data + + # Call EnLitePy + try: + results = enlitepyapi.run(enlitepy_payload) + # Ensure structural parity with remote EnLitePy responses which may include a 'logs' key + if isinstance(results, dict) and 'logs' not in results: + results['logs'] = [] + return JsonResponse({"results": results}) + except Exception as e: + return JsonResponse({"Error": f"EnLitePy execution failed: {e}"}, status=500) + else: return JsonResponse({"Error": "Must POST a JSON body for ensitepy."}, status=400) - except Exception as e: + except Exception: exc_type, exc_value, exc_traceback = sys.exc_info() err = UnexpectedError(exc_type, exc_value, exc_traceback, task='ensite') return JsonResponse({"Error": err.message}, status=500) @@ -56,7 +236,7 @@ def validate_load_builder_inputs(loads_table): for numeric_input in numeric_inputs: try: float(load[numeric_input]) - except: + except Exception: return False for month_input in month_inputs: @@ -80,16 +260,16 @@ def load_builder(request): if request.method == 'POST': post = request.body try: - # Try to import JSON, then try to import CSV + # Try to import JSON, then fall back to CSV try: loads_table = json.loads(post) - except: + except Exception: loads_table = post.decode("utf-8") finally: if not isinstance(loads_table, list): csv_reader = csv.DictReader(io.StringIO(loads_table)) loads_table = list(csv_reader) - except: + except Exception: return JsonResponse({"Error": "Invalid JSON or CSV"}) # Validation @@ -105,7 +285,7 @@ def load_builder(request): else: return JsonResponse({"Error": "Must POST a JSON based on the SolarResilient component based load builder downloadable CSV"}) - except Exception as e: + except Exception: exc_type, exc_value, exc_traceback = sys.exc_info() err = UnexpectedError(exc_type, exc_value, exc_traceback, task='load_builder') err.save_to_db() diff --git a/run_enlitepy_compare.py b/run_enlitepy_compare.py new file mode 100644 index 000000000..0377807cb --- /dev/null +++ b/run_enlitepy_compare.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +"""Run EnLitePy minimal logic against two endpoints (remote & local) with identical payload. + +Features: + * Reuses the canonical weekly payload (same as run_enlitepy_minimal.py structure) + * Posts to --remote and --local (or one if desired) + * Supports --clean / --annual / --validate identical semantics + * Normalizes JSON-string statistics, produces annual derivatives (timeseries + *_annual stats) + * Writes individual response JSONs plus a combined diff summary + * Diff focuses on structure + key presence + simple numeric sanity (lengths, counts) + +Usage examples: + # Single line + python run_enlitepy_compare.py --remote https://ensite-py312-reopt-dev-api.its.nrel.gov/stable/ensite --local http://localhost:8000/stable/ensite/ --clean --annual --validate + + # Multi-line (must escape line breaks with backslashes!) + python run_enlitepy_compare.py \\ + --remote https://ensite-py312-reopt-dev-api.its.nrel.gov/stable/ensite \\ + --local http://localhost:8000/stable/ensite/ \\ + --clean --annual --validate + + # Using env vars (no flags needed if set) + export ENLITEPY_REMOTE_URL="https://ensite-py312-reopt-dev-api.its.nrel.gov/stable/ensite" + export ENLITEPY_LOCAL_URL="http://localhost:8000/stable/ensite/" + python run_enlitepy_compare.py --clean --annual --validate + +Outputs: + remote_response.json / local_response.json + compare_summary.json (diff + summary) +""" +from __future__ import annotations + +import argparse +import json +import os +from statistics import mean +from typing import Any, Dict, List, Tuple + +try: + import requests # type: ignore +except ImportError: + requests = None # type: ignore + import urllib.request + import urllib.error + +# ---------------- Base constants (copy from minimal) ------------------------- +DEFAULT_CHARGER_DEFAULTS: Dict[str, Dict[str, Any]] = { + "L1": {"count": 0, "power_kW": 2.4, "eff": 0.80, "pci": "LD"}, + "L2": {"count": 2, "power_kW": 13.0, "eff": 0.80, "pci": "LD"}, + "DCFC_LD": {"count": 0, "power_kW": 50.0, "eff": 0.90, "pci": "LD"}, + "DCFC_MDHD": {"count": 0, "power_kW": 250.0, "eff": 0.90, "pci": "MD/HD"}, +} +DEFAULT_VEHICLE_DEFAULTS: Dict[str, Dict[str, Any]] = { + "LD": {"weekday": 5, "weekend": 2, "battery_kWh": 80, "initSOC": 0.10, "targetSOC": 0.80, "pChgMax_kW": 120} +} +ARRIVAL_WEEKDAY_PCT = [0,0,0,0,0,0,0,0,40,40,20,0] +ARRIVAL_WEEKEND_PCT = [0]*12 +SIM_DAYS = 7 +EMS_CONFIG = {"type": "basic", "pChgCap": 500_000, "pap": "FCFS+SMX"} +BASE_SIM_CONFIG = {"tStart": 0, "tEnd": 7 * 24 * 3600, "dowStart": 0} +RESULTS_CONFIG_BASE = {"timeseriesDt": 3600, "powerMetricsDt": 3600} +WEEK_TO_YEAR_SCALE = 365 / 7 +EQUIPMENT_ANNUAL_SCALARS = {"Energy output [kWh]", "Energy loss [kWh]"} +EV_COUNT_SCALARS = {"Total EVs arrived [#]", "Total unique EVs arrived [#]", "EVs no queue wait [#]", "EVs arrived but not charged [#]", "EVs that did not start/complete charge [#]"} +EV_ENERGY_SCALARS = {"Total energy charged [kWh]"} + +# ---------------- Payload build --------------------------------------------- + +def build_arrival_pmf(weekday_pct: List[float], weekend_pct: List[float], bucket_hours: int = 2) -> Dict[str, Any]: + bucket_seconds = bucket_hours * 3600 + def normalize(side: List[float]): + active = [(i,x) for i,x in enumerate(side) if x>0] + if not active: + return {"val": [0], "prob": [1.0], "dur": bucket_seconds} + total = sum(v for _,v in active) + vals = [i*bucket_seconds for i,_ in active] + probs = [v/total for _,v in active] + diff = 1 - sum(probs) + if abs(diff) > 1e-9: + probs[-1] += diff + return {"val": vals, "prob": [round(p,6) for p in probs], "dur": bucket_seconds} + return {"1-5": normalize(weekday_pct), "0,6": normalize(weekend_pct)} + +def build_payload(days: int = SIM_DAYS) -> Dict[str, Any]: + nodes: Dict[str, Any] = {"grid": {"type": "Grid", "pMax": 500_000, "pMin": 0, "eff": 0.99}} + for cat, cfg in DEFAULT_CHARGER_DEFAULTS.items(): + for i in range(1, cfg["count"] + 1): + nodes[f"EVSE_{cat}_{i}"] = { + "type": "EVSE", "pMax": int(cfg["power_kW"]*1000), "pMin": 0, "eff": cfg["eff"], "nEVPort": 1, "pci": cfg["pci"] + } + def expand(we:int, wd:int): return [we, wd, wd, wd, wd, wd, we] + weekday_weekend = {k: expand(v["weekend"], v["weekday"]) for k,v in DEFAULT_VEHICLE_DEFAULTS.items()} + ev_types: Dict[str, Any] = {} + arrival = build_arrival_pmf(ARRIVAL_WEEKDAY_PCT, ARRIVAL_WEEKEND_PCT) + for k,v in DEFAULT_VEHICLE_DEFAULTS.items(): + delta = max(0.0, min(1.0, v['targetSOC'] - v['initSOC'])) + ev_types[k] = { + "type": k, + "eCap": int(v['battery_kWh']*1000), + "pChgMax": int(v['pChgMax_kW']*1000), + "count": weekday_weekend[k], + "targetFinalSOCPMF": {"val": [round(v['targetSOC'],4)], "prob": [1.0]}, + "energyDemandPMF": {"val": [round(delta,4)], "prob": [1.0]}, + "arrivalTimePMF": arrival, + "departureTimePMF": None, + } + sim_cfg = dict(BASE_SIM_CONFIG) + sim_cfg['tEnd'] = days * 24 * 3600 + rc = {**RESULTS_CONFIG_BASE, "resultFieldOptions": {"nodes": "Grid", "portUsage": True, "queueLength": True, "nodeStats": True, "evStats": True}} + return {"simConfig": sim_cfg, "hubConfig": {"nodes": nodes, "ems": EMS_CONFIG}, "evInfo": {"stochasticModel": 1, "chgDurMax": None, "evTypes": ev_types}, "resultsConfig": rc} + +# ---------------- HTTP ------------------------------------------------------ + +def post(url: str, payload: Dict[str, Any]) -> Tuple[int, Dict[str, Any] | None, str]: + headers = {"Content-Type": "application/json", "Accept": "application/json"} + token = os.getenv("ENLITEPY_API_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + body = json.dumps(payload) + try: + if requests: + r = requests.post(url.rstrip('/'), data=body, headers=headers, timeout=(10,120)) + if r.status_code == 404: + r = requests.post(url.rstrip('/')+'/', data=body, headers=headers, timeout=(10,120)) + status, text = r.status_code, r.text + else: + data_b = body.encode() + def _one(u): + req = urllib.request.Request(u, data=data_b, headers=headers) + return urllib.request.urlopen(req, timeout=120) + try: + resp = _one(url.rstrip('/')) + except Exception: + resp = _one(url.rstrip('/')+'/') + status = resp.status + text = resp.read().decode() + except Exception as e: # noqa + return 0, None, f"EXC:{e}" + try: + js = json.loads(text) + except json.JSONDecodeError: + return status, None, text[:400] + return status, js, '' + +# ---------------- Cleaning / Annual ----------------------------------------- + +def replicate_to_8760(series: Any) -> Any: + if not isinstance(series, list) or len(series) in (0, 8760): + return series + reps = (8760 // len(series)) + 1 + return (series * reps)[:8760] + +def make_annual_equipment(weekly: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + out = [] + for row in weekly: + copy = row.copy() + for k in EQUIPMENT_ANNUAL_SCALARS: + v = copy.get(k) + if isinstance(v, (int,float)): + copy[k] = round(v * WEEK_TO_YEAR_SCALE, 2) + out.append(copy) + return out + +def make_annual_ev(weekly: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + out = [] + for row in weekly: + copy = row.copy() + for k,v in list(copy.items()): + if k in EV_COUNT_SCALARS or k in EV_ENERGY_SCALARS: + if isinstance(v,(int,float)): + copy[k] = round(v * WEEK_TO_YEAR_SCALE, 2) + for k in list(copy.keys()): + if k.endswith('[min]') and isinstance(copy[k], (int,float)): + copy[k.replace('[min]','[h]')] = round(copy[k]/60.0,2) + del copy[k] + out.append(copy) + return out + +def add_grid_util(ts: Dict[str, Any], equip_annual: List[Dict[str, Any]]): + power = ts.get('power_in_grid') if isinstance(ts, dict) else None + if not isinstance(power, list) or not power: + return + grid_row = next((r for r in equip_annual if r.get('Name') in ('grid','Grid')), None) + if not grid_row or 'Power capacity [kW]' not in grid_row: + return + cap = grid_row['Power capacity [kW]'] + if not isinstance(cap,(int,float)) or cap <= 0: + return + sample = [v for v in power[:50] if isinstance(v,(int,float))] + if not sample: + return + max_sample = max(sample) + to_kw = 1000.0 if max_sample > cap*1.1 else 1.0 + series_kw = [v/to_kw for v in power if isinstance(v,(int,float))] + if not series_kw: + return + grid_row.setdefault('Min capacity utilization [kW]', round(min(series_kw),2)) + grid_row.setdefault('Average capacity utilization [kW]', round(mean(series_kw),2)) + +def normalize_and_enrich(resp: Dict[str, Any], do_annual: bool): + results = resp.get('results') + if not isinstance(results, dict): + return + for key in ('equipment_statistics','ev_statistics'): + val = results.get(key) + if isinstance(val,str): + try: + parsed = json.loads(val) + if isinstance(parsed, list): + results[key] = parsed + except json.JSONDecodeError: + pass + if do_annual: + ts = results.get('timeseries') + if isinstance(ts, dict) and 'power_in_grid' in ts: + ann = replicate_to_8760(ts['power_in_grid']) + if isinstance(ann, list) and len(ann) == 8760: + ts['power_in_grid_annual'] = ann + equip = results.get('equipment_statistics') or [] + ev = results.get('ev_statistics') or [] + if isinstance(equip, list): + equip_ann = make_annual_equipment(equip) + results['equipment_statistics_annual'] = equip_ann + add_grid_util(results.get('timeseries',{}), equip_ann) + if isinstance(ev, list): + results['ev_statistics_annual'] = make_annual_ev(ev) + +# ---------------- Validation ------------------------------------------------- + +def validate_scaling(resp: Dict[str, Any], tol: float = 0.05) -> List[str]: + out: List[str] = [] + res = resp.get('results', {}) + ew = res.get('equipment_statistics', []) + ea = res.get('equipment_statistics_annual', []) + eq_map = {r.get('Name'): r for r in ew if isinstance(r, dict)} + for a in ea: + name = a.get('Name') + w = eq_map.get(name) + if not w: + continue + for k in EQUIPMENT_ANNUAL_SCALARS: + wv = w.get(k) + av = a.get(k) + if isinstance(wv,(int,float)) and isinstance(av,(int,float)) and wv>0: + expect = wv*WEEK_TO_YEAR_SCALE + err = abs(av - expect)/expect if expect else 0 + if err > tol: + out.append(f"Equip {name}.{k} rel error {err:.2%} > {tol:.0%}") + evw = res.get('ev_statistics', []) + eva = res.get('ev_statistics_annual', []) + if isinstance(evw,list) and isinstance(eva,list) and evw and eva: + w0 = evw[0] + e0 = eva[0] + for k in EV_COUNT_SCALARS | EV_ENERGY_SCALARS: + wv = w0.get(k) + av = e0.get(k) + if isinstance(wv,(int,float)) and isinstance(av,(int,float)) and wv>0: + expect = wv*WEEK_TO_YEAR_SCALE + err = abs(av - expect)/expect + if err > tol: + out.append(f"EV {k} rel error {err:.2%} > {tol:.0%}") + if not out: + out.append('All scaling checks within tolerance.') + return out + +# ---------------- Diff helpers ---------------------------------------------- + +def keyset(results: Dict[str, Any]) -> List[str]: + if not isinstance(results, dict): + return [] + return sorted(list(results.keys())) + +def diff_keysets(remote: Dict[str, Any], local: Dict[str, Any]) -> List[str]: + issues: List[str] = [] + rk = set(keyset(remote.get('results', {}))) + lk = set(keyset(local.get('results', {}))) + for miss in sorted(rk - lk): + issues.append(f'local missing key: {miss}') + for miss in sorted(lk - rk): + issues.append(f'remote missing key: {miss}') + return issues + +# ---------------- Main ------------------------------------------------------ + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--remote', help='Remote EnLitePy endpoint URL') + ap.add_argument('--local', help='Local /stable/ensite/ endpoint URL') + ap.add_argument('--annual', action='store_true', help='Add annual replication & *_annual stats') + ap.add_argument('--clean', action='store_true', help='Normalize stats + optional annual replication') + ap.add_argument('--validate', action='store_true', help='Run scaling validation (implies --clean --annual)') + ap.add_argument('--outfile-prefix', default='compare_run', help='Prefix for output files') + args = ap.parse_args() + + # Allow environment variable fallback if flags omitted + if not args.remote: + args.remote = os.getenv('ENLITEPY_REMOTE_URL') or os.getenv('ENLITEPY_API_URL') + if not args.local: + args.local = os.getenv('ENLITEPY_LOCAL_URL') or os.getenv('REOPT_LOCAL_ENLITEPY_URL') + + if args.validate: + args.clean = True + args.annual = True + + payload = build_payload() + print('Built payload EV types:', list(payload['evInfo']['evTypes'].keys()), 'EVSE count:', sum(1 for k in payload['hubConfig']['nodes'] if k.startswith('EVSE_'))) + + remote_resp = local_resp = None + remote_status = local_status = None + if not (args.remote or args.local): + print('Must supply at least one of --remote or --local') + return 1 + + if args.remote: + print('POST remote:', args.remote) + remote_status, remote_resp, r_err = post(args.remote, payload) + if remote_status != 200 or not remote_resp: + print(' Remote failed:', remote_status, r_err) + else: + print(' Remote OK') + if args.local: + print('POST local:', args.local) + local_status, local_resp, l_err = post(args.local, payload) + if local_status != 200 or not local_resp: + print(' Local failed:', local_status, l_err) + else: + print(' Local OK') + + if args.clean: + if remote_resp: + normalize_and_enrich(remote_resp, args.annual) + if local_resp: + normalize_and_enrich(local_resp, args.annual) + + if args.validate: + if remote_resp: + for v in validate_scaling(remote_resp): + print('REMOTE VALIDATE:', v) + if local_resp: + for v in validate_scaling(local_resp): + print('LOCAL VALIDATE:', v) + + # Write individual responses + if remote_resp: + with open(f'{args.outfile_prefix}_remote.json','w') as f: + json.dump(remote_resp, f) + print('Wrote', f'{args.outfile_prefix}_remote.json') + if local_resp: + with open(f'{args.outfile_prefix}_local.json','w') as f: + json.dump(local_resp, f) + print('Wrote', f'{args.outfile_prefix}_local.json') + + diff_issues: List[str] = [] + if remote_resp and local_resp: + diff_issues = diff_keysets(remote_resp, local_resp) + if diff_issues: + print('STRUCTURAL DIFFS:') + for d in diff_issues: + print(' -', d) + else: + print('Structures align.') + + summary = { + 'remote_status': remote_status, + 'local_status': local_status, + 'annual_requested': args.annual, + 'clean_requested': args.clean, + 'validated': args.validate, + 'remote_results_keys': keyset(remote_resp.get('results', {})) if remote_resp else None, + 'local_results_keys': keyset(local_resp.get('results', {})) if local_resp else None, + 'structural_differences': diff_issues, + } + with open(f'{args.outfile_prefix}_summary.json','w') as f: + json.dump(summary, f, indent=2) + print('Wrote', f'{args.outfile_prefix}_summary.json') + + # exit code + if remote_status and remote_status != 200: + return 2 + if local_status and local_status != 200: + return 3 + if diff_issues: + return 4 + return 0 + +if __name__ == '__main__': + raise SystemExit(main()) From 0b52d319bd8a7ffde918cbdd09fdfaf2a92be7a5 Mon Sep 17 00:00:00 2001 From: Byron Pullutasig <115118857+bpulluta@users.noreply.github.com> Date: Wed, 24 Sep 2025 19:05:33 -0600 Subject: [PATCH 16/26] add enrichment_version to response and default clean/annual compare script opts --- load_builder/views.py | 119 ++++++++++++++++++++++++++++++++++++---- run_enlitepy_compare.py | 31 +++++++---- 2 files changed, 128 insertions(+), 22 deletions(-) diff --git a/load_builder/views.py b/load_builder/views.py index 54645376f..04d7288ee 100644 --- a/load_builder/views.py +++ b/load_builder/views.py @@ -6,10 +6,6 @@ import numpy as np from django.http import JsonResponse from reo.exceptions import UnexpectedError -import os -import requests -from statistics import mean -from typing import Any, Dict, List try: from ensitepy.simextension import enlitepyapi @@ -181,28 +177,23 @@ def ensite_view(request): input_data = json.loads(request.body) except json.JSONDecodeError as e: return JsonResponse({"Error": f"Invalid JSON in request body: {e}"}, status=400) - - # Detect input format and prepare payload if is_ui_input(input_data): - # Transform UI inputs to EnLitePy payload try: enlitepy_payload = build_enlitepy_payload(input_data) except Exception as e: return JsonResponse({"Error": f"Failed to build EnLitePy payload: {e}"}, status=400) else: - # Use direct EnLitePy payload enlitepy_payload = input_data - - # Call EnLitePy try: results = enlitepyapi.run(enlitepy_payload) - # Ensure structural parity with remote EnLitePy responses which may include a 'logs' key if isinstance(results, dict) and 'logs' not in results: results['logs'] = [] + # Always normalize and annualize for client convenience + _normalize_and_enrich(results) + results.setdefault('enrichment_version', 1) return JsonResponse({"results": results}) except Exception as e: return JsonResponse({"Error": f"EnLitePy execution failed: {e}"}, status=500) - else: return JsonResponse({"Error": "Must POST a JSON body for ensitepy."}, status=400) except Exception: @@ -366,3 +357,107 @@ def translate_months(string): hour_load += load.power_total loads_kw.append(hour_load) return loads_kw + +# Annual scaling helper constants (reuse existing ones if defined) +EQUIPMENT_ANNUAL_SCALARS = {"Energy output [kWh]", "Energy loss [kWh]"} +EV_COUNT_SCALARS = {"Total EVs arrived [#]", "Total unique EVs arrived [#]", "EVs no queue wait [#]", "EVs arrived but not charged [#]", "EVs that did not start/complete charge [#]"} +EV_ENERGY_SCALARS = {"Total energy charged [kWh]"} + +def _replicate_to_8760(series): + if not isinstance(series, list) or len(series) == 0: + return series + if len(series) == 8760: + return series + reps = (8760 // len(series)) + 1 + return (series * reps)[:8760] + +def _annualize_equipment(stats_list): + if not isinstance(stats_list, list): + return [] + out = [] + for row in stats_list: + if not isinstance(row, dict): + continue + copy = row.copy() + for k in EQUIPMENT_ANNUAL_SCALARS: + v = copy.get(k) + if isinstance(v, (int, float)): + copy[k] = round(v * WEEK_TO_YEAR_SCALE, 2) + out.append(copy) + return out + +def _annualize_ev(stats_list): + if not isinstance(stats_list, list): + return [] + out = [] + for row in stats_list: + if not isinstance(row, dict): + continue + copy = row.copy() + for k, v in list(copy.items()): + if k in EV_COUNT_SCALARS or k in EV_ENERGY_SCALARS: + if isinstance(v, (int, float)): + copy[k] = round(v * WEEK_TO_YEAR_SCALE, 2) + if k.endswith('[min]') and isinstance(v, (int, float)): + copy[k.replace('[min]', '[h]')] = round(v / 60.0, 2) + del copy[k] + out.append(copy) + return out + +def _add_grid_util(timeseries_dict, equip_annual): + if not isinstance(timeseries_dict, dict): + return + power = timeseries_dict.get('power_in_grid') + if not isinstance(power, list) or not power: + return + # Locate grid equipment row + grid_row = None + for r in equip_annual: + if isinstance(r, dict) and r.get('Name') in ('grid', 'Grid'): + grid_row = r + break + if not grid_row: + return + cap = grid_row.get('Power capacity [kW]') + if not isinstance(cap, (int, float)) or cap <= 0: + return + sample = [v for v in power[:50] if isinstance(v, (int, float))] + if not sample: + return + max_sample = max(sample) + to_kw = 1000.0 if max_sample > cap * 1.1 else 1.0 + series_kw = [v / to_kw for v in power if isinstance(v, (int, float))] + if not series_kw: + return + grid_row.setdefault('Min capacity utilization [kW]', round(min(series_kw), 2)) + grid_row.setdefault('Average capacity utilization [kW]', round(sum(series_kw) / len(series_kw), 2)) + + +def _normalize_and_enrich(results_dict): + if not isinstance(results_dict, dict): + return + # Normalize stringified JSON lists + for key in ('equipment_statistics', 'ev_statistics'): + val = results_dict.get(key) + if isinstance(val, str): + try: + parsed = json.loads(val) + if isinstance(parsed, list): + results_dict[key] = parsed + except json.JSONDecodeError: + pass + equipment = results_dict.get('equipment_statistics') or [] + evstats = results_dict.get('ev_statistics') or [] + timeseries = results_dict.get('timeseries') + # Annual replication for timeseries + if isinstance(timeseries, dict) and 'power_in_grid' in timeseries: + annual_series = _replicate_to_8760(timeseries['power_in_grid']) + if isinstance(annual_series, list) and len(annual_series) == 8760: + timeseries['power_in_grid_annual'] = annual_series + equip_annual = _annualize_equipment(equipment) if equipment else [] + ev_annual = _annualize_ev(evstats) if evstats else [] + if equip_annual: + results_dict['equipment_statistics_annual'] = equip_annual + _add_grid_util(timeseries, equip_annual) + if ev_annual: + results_dict['ev_statistics_annual'] = ev_annual diff --git a/run_enlitepy_compare.py b/run_enlitepy_compare.py index 0377807cb..dd3e5cda5 100644 --- a/run_enlitepy_compare.py +++ b/run_enlitepy_compare.py @@ -286,9 +286,11 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument('--remote', help='Remote EnLitePy endpoint URL') ap.add_argument('--local', help='Local /stable/ensite/ endpoint URL') - ap.add_argument('--annual', action='store_true', help='Add annual replication & *_annual stats') - ap.add_argument('--clean', action='store_true', help='Normalize stats + optional annual replication') - ap.add_argument('--validate', action='store_true', help='Run scaling validation (implies --clean --annual)') + ap.add_argument('--annual', dest='annual', action='store_true', help='(Deprecated) Force annual enrichment (now default)') + ap.add_argument('--no-annual', dest='no_annual', action='store_true', help='Disable annual enrichment (default: enabled)') + ap.add_argument('--clean', dest='clean', action='store_true', help='(Deprecated) Force cleaning (now default)') + ap.add_argument('--no-clean', dest='no_clean', action='store_true', help='Disable cleaning/normalization (default: enabled)') + ap.add_argument('--validate', action='store_true', help='Run scaling validation (always implies cleaning & annual)') ap.add_argument('--outfile-prefix', default='compare_run', help='Prefix for output files') args = ap.parse_args() @@ -298,9 +300,16 @@ def main(): if not args.local: args.local = os.getenv('ENLITEPY_LOCAL_URL') or os.getenv('REOPT_LOCAL_ENLITEPY_URL') + # Default behavior: clean & annual ON unless explicitly disabled + clean_enabled = True + annual_enabled = True + if args.no_clean: + clean_enabled = False + if args.no_annual: + annual_enabled = False if args.validate: - args.clean = True - args.annual = True + clean_enabled = True + annual_enabled = True payload = build_payload() print('Built payload EV types:', list(payload['evInfo']['evTypes'].keys()), 'EVSE count:', sum(1 for k in payload['hubConfig']['nodes'] if k.startswith('EVSE_'))) @@ -326,11 +335,11 @@ def main(): else: print(' Local OK') - if args.clean: + if clean_enabled: if remote_resp: - normalize_and_enrich(remote_resp, args.annual) + normalize_and_enrich(remote_resp, annual_enabled) if local_resp: - normalize_and_enrich(local_resp, args.annual) + normalize_and_enrich(local_resp, annual_enabled) if args.validate: if remote_resp: @@ -353,6 +362,8 @@ def main(): diff_issues: List[str] = [] if remote_resp and local_resp: diff_issues = diff_keysets(remote_resp, local_resp) + # ignore enrichment_version differences in key diff + diff_issues = [d for d in diff_issues if 'enrichment_version' not in d] if diff_issues: print('STRUCTURAL DIFFS:') for d in diff_issues: @@ -363,8 +374,8 @@ def main(): summary = { 'remote_status': remote_status, 'local_status': local_status, - 'annual_requested': args.annual, - 'clean_requested': args.clean, + 'annual_requested': annual_enabled, + 'clean_requested': clean_enabled, 'validated': args.validate, 'remote_results_keys': keyset(remote_resp.get('results', {})) if remote_resp else None, 'local_results_keys': keyset(local_resp.get('results', {})) if local_resp else None, From 9923ba49640c9bdd93a3164c1ab0ebe225b90f0a Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 24 Sep 2025 21:52:37 -0600 Subject: [PATCH 17/26] Update EVI-EnLitePy server_version branch to latest commit --- EVI-EnLitePy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EVI-EnLitePy b/EVI-EnLitePy index 75e5e6e4c..c8a1c84b8 160000 --- a/EVI-EnLitePy +++ b/EVI-EnLitePy @@ -1 +1 @@ -Subproject commit 75e5e6e4c106e7b28c932bbc524816c199661759 +Subproject commit c8a1c84b82f726db7bcda616641214366e4cc1c2 From e7ea6ac2f6436bdcce2cb5680c3d34b764bfdb7a Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 24 Sep 2025 21:53:47 -0600 Subject: [PATCH 18/26] Keep same celery resource values for production as development and staging (higher for new python) --- .helm/values.production.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.helm/values.production.yaml b/.helm/values.production.yaml index c1c437b01..46abcafb6 100644 --- a/.helm/values.production.yaml +++ b/.helm/values.production.yaml @@ -6,10 +6,6 @@ djangoCpuLimit: "2000m" djangoMemoryRequest: "2000Mi" djangoMemoryLimit: "2000Mi" celeryReplicas: 10 -celeryMemoryRequest: "900Mi" -celeryMemoryLimit: "900Mi" -celeryCpuRequest: "100m" -celeryCpuLimit: "1000m" juliaReplicas: 5 juliaCpuRequest: "2000m" juliaCpuLimit: "4000m" From 31592c283c1f3b6ef83bce2388795675d3871130 Mon Sep 17 00:00:00 2001 From: bill-becker Date: Wed, 24 Sep 2025 21:57:03 -0600 Subject: [PATCH 19/26] Remove example input ensite.json file --- load_builder/ensite.json | 603 --------------------------------------- 1 file changed, 603 deletions(-) delete mode 100644 load_builder/ensite.json diff --git a/load_builder/ensite.json b/load_builder/ensite.json deleted file mode 100644 index 8a899692e..000000000 --- a/load_builder/ensite.json +++ /dev/null @@ -1,603 +0,0 @@ -{ - "simConfig": { - "tStart": 0, - "tEnd": 604800, - "dowStart": 0 - }, - "hubConfig": { - "nodes": { - "grid": { - "type": "Grid", - "pMax": 300000.0, - "pMin": 0, - "eff": 0.99 - }, - "evse1": { - "type": "EVSE", - "pMax": 300000.0, - "pMin": 0, - "eff": 0.98, - "nEVPort": 2, - "pci": [ - "LD", - "LD" - ] - }, - "evse2": { - "type": "EVSE", - "pMax": 300000.0, - "pMin": 0, - "eff": 0.98, - "nEVPort": 2, - "pci": "LD" - }, - "evse3": { - "type": "EVSE", - "pMax": 300000.0, - "pMin": 0, - "eff": 0.98, - "nEVPort": 2, - "pci": "LD" - }, - "evse4": { - "type": "EVSE", - "pMax": 300000.0, - "pMin": 0, - "eff": 0.98, - "nEVPort": 2, - "pci": "MD/HD" - }, - "evse5": { - "type": "EVSE", - "pMax": 300000.0, - "pMin": 0, - "eff": 0.98, - "nEVPort": 2, - "pci": "MD/HD" - }, - "building_load": { - "type": "CPL", - "time": [ - 0, - 900, - 1800, - 2700, - 3600, - 4500, - 5400, - 6300, - 7200, - 8100, - 9000, - 9900, - 10800, - 11700, - 12600, - 13500, - 14400, - 15300, - 16200, - 17100, - 18000, - 18900, - 19800, - 20700, - 21600, - 22500, - 23400, - 24300, - 25200, - 26100, - 27000, - 27900, - 28800, - 29700, - 30600, - 31500, - 32400, - 33300, - 34200, - 35100, - 36000, - 36900, - 37800, - 38700, - 39600, - 40500, - 41400, - 42300, - 43200, - 44100, - 45000, - 45900, - 46800, - 47700, - 48600, - 49500, - 50400, - 51300, - 52200, - 53100, - 54000, - 54900, - 55800, - 56700, - 57600, - 58500, - 59400, - 60300, - 61200, - 62100, - 63000, - 63900, - 64800, - 65700, - 66600, - 67500, - 68400, - 69300, - 70200, - 71100, - 72000, - 72900, - 73800, - 74700, - 75600, - 76500, - 77400, - 78300, - 79200, - 80100, - 81000, - 81900, - 82800, - 83700, - 84600, - 85500, - 86400, - 87300, - 88200, - 89100, - 90000, - 90900, - 91800, - 92700, - 93600, - 94500, - 95400, - 96300, - 97200, - 98100, - 99000, - 99900, - 100800, - 101700, - 102600, - 103500, - 104400, - 105300, - 106200, - 107100, - 108000, - 108900, - 109800, - 110700, - 111600, - 112500, - 113400, - 114300, - 115200, - 116100, - 117000, - 117900, - 118800, - 119700, - 120600, - 121500, - 122400, - 123300, - 124200, - 125100, - 126000, - 126900, - 127800, - 128700, - 129600, - 130500, - 131400, - 132300, - 133200, - 134100, - 135000, - 135900, - 136800, - 137700, - 138600, - 139500, - 140400, - 141300, - 142200, - 143100, - 144000, - 144900, - 145800, - 146700, - 147600, - 148500, - 149400, - 150300, - 151200, - 152100, - 153000, - 153900, - 154800, - 155700, - 156600, - 157500, - 158400, - 159300, - 160200, - 161100, - 162000, - 162900, - 163800, - 164700, - 165600, - 166500, - 167400, - 168300, - 169200, - 170100, - 171000, - 171900, - 172800 - ], - "power": [ - 0, - 115768.90557614488, - 111052.02340350732, - 106243.96540927405, - 101248.76581765672, - 96570.04466587743, - 93198.17823814304, - 90826.32916441964, - 87023.57942474863, - 83436.24449431658, - 81655.80725341121, - 80351.78355011741, - 78391.01470690272, - 76524.89719584149, - 75849.64007460905, - 75171.95377484719, - 74762.94970004473, - 74785.98133219888, - 74974.87833325299, - 75034.88245524601, - 75053.78539510825, - 75462.00915896866, - 76211.98052987862, - 77151.15330971242, - 78713.83055741635, - 80697.72125438195, - 83229.71074938202, - 86422.01973010179, - 91701.66953882159, - 96826.09974563181, - 100536.59047897511, - 98627.50125113038, - 101026.77928593893, - 113397.30768915478, - 120789.37878468739, - 125086.23067937214, - 138968.87451142137, - 149728.1466181706, - 154689.7620241398, - 162414.35875756864, - 172040.3208539825, - 181616.36019767198, - 188361.01006918508, - 193894.3227290636, - 199348.66172264077, - 204691.88380812074, - 211477.3931988581, - 219553.8144975878, - 227734.307890666, - 235623.86863660946, - 241252.81855464025, - 245748.68819021765, - 250113.82821523494, - 253684.50561679932, - 256906.64040314284, - 259566.53136180423, - 262138.29058731813, - 265587.8329082571, - 268022.07549065276, - 268831.3090258474, - 268046.3556785552, - 266687.2767598618, - 265407.7785675803, - 263901.6050685742, - 262238.631827651, - 259864.17945746586, - 259258.14426325096, - 260351.58518913263, - 260781.2744725022, - 260556.6113717029, - 260612.13738030978, - 260900.1793592792, - 260937.2231589168, - 260812.33700178415, - 258894.05801698912, - 256387.57735312398, - 252556.31076981314, - 247663.10940524103, - 244336.46921034344, - 241006.16187443602, - 229296.17149419116, - 218036.09911564825, - 213202.7845893712, - 207379.7079880955, - 201109.48916701777, - 195030.73111149587, - 188231.49321122604, - 181211.80601803545, - 174912.1254591292, - 168300.37541429105, - 161171.66767080297, - 159829.78547531943, - 155668.44684056749, - 147021.56004675277, - 140195.87205118494, - 132990.5537835949, - 125932.22211296459, - 119607.3938765824, - 114224.16119668349, - 108861.86410024502, - 103633.475821886, - 98855.20786692914, - 95319.3349150407, - 92669.2333608828, - 88636.37784744859, - 84625.87644647704, - 81987.95286556207, - 80257.94930263511, - 78025.3995160709, - 76333.90547973095, - 76404.27574800745, - 76149.30380447183, - 75283.44017603615, - 75060.94051033257, - 75373.68350340766, - 75378.24621716273, - 75511.34395530022, - 75733.84757209556, - 75959.57567428125, - 76754.41482547301, - 77732.24620558122, - 78601.31828930757, - 79863.87527191175, - 81645.1725729833, - 84839.87213999625, - 87497.74808954235, - 89847.10387480202, - 86621.91703097537, - 84866.33821827677, - 92744.99438738922, - 97866.32436802566, - 100442.49273944512, - 112207.93503517946, - 120191.37581769569, - 122510.3959391192, - 127966.47696577231, - 132511.31787877544, - 137214.93411374232, - 141265.0820730139, - 144706.10886189333, - 148362.4079961007, - 152096.62428724568, - 157752.47514897128, - 164580.07293775745, - 172594.16516825437, - 180988.5032815957, - 186457.95187991243, - 191574.73033201674, - 196665.25157405154, - 200116.9263952046, - 203445.69327931653, - 206353.23093329868, - 209344.86404155273, - 213477.5083782941, - 216364.45305298414, - 217472.23160771147, - 217784.5467271467, - 217966.88711359314, - 217654.3283523812, - 217435.56651799893, - 217033.88100754542, - 215240.0497811758, - 215956.1555711758, - 218573.78801257262, - 220305.6386701705, - 222489.12700566195, - 224959.58847458754, - 227719.01635537934, - 230726.10187793532, - 232996.42429362237, - 233730.3298875959, - 233519.48010681197, - 231517.3818674315, - 228752.41772804878, - 227481.0983919819, - 226052.31017154508, - 216331.94551423888, - 206790.52351717363, - 203604.6489288222, - 199183.91481633333, - 194448.6273390925, - 189280.3790565904, - 182981.8908821766, - 177064.2090301336, - 171202.26802169654, - 164947.3577182609, - 159196.5478214874, - 158350.694914731, - 156127.8238934354, - 149344.6406372949, - 142506.54723537544, - 135308.83708899393, - 128517.20039204955 - ] - }, - "BESS": { - "type": "ESS", - "eCap": 1000000.0, - "eff": 0.98, - "pMax": 300000.0, - "pMin": 0, - "socMin": 0.2, - "socMax": 0.95, - "socInit": 0.75 - } - }, - "ems": { - "type": "basic", - "pChgCap": 1800000.0, - "pap": "FCFS+SMX", - "xSM": 0.5 - } - }, - "evInfo": { - "stochasticModel": 1, - "chgDurMax": 7200, - "evTypes": { - "LD": { - "type": "LD", - "eCap": 60000.0, - "pChgMax": 300000.0, - "count": [ - 5, - 10, - 15, - 20, - 25, - 30, - 35 - ], - "targetFinalSOCPMF": { - "val": [ - 0.75, - 0.8, - 0.9 - ], - "prob": [ - 0.5, - 0.3, - 0.2 - ] - }, - "energyDemandPMF": { - "val": [ - 0.6, - 0.7, - 0.8 - ], - "prob": [ - 0.5, - 0.3, - 0.2 - ] - }, - "arrivalTimePMF": { - "0-6": { - "val": [ - 3600, - 14400, - 43200 - ], - "prob": [ - 0.5, - 0.3, - 0.2 - ], - "dur": 7200 - } - }, - "departureTimePMF": null - }, - "HD": { - "type": "HD", - "eCap": 200000.0, - "pChgMax": 600000.0, - "count": [ - 35, - 30, - 25, - 20, - 15, - 10, - 5 - ], - "targetFinalSOCPMF": { - "val": [ - 0.75, - 0.8, - 0.9 - ], - "prob": [ - 0.5, - 0.3, - 0.2 - ] - }, - "energyDemandPMF": { - "val": [ - 0.6, - 0.7, - 0.8 - ], - "prob": [ - 0.5, - 0.3, - 0.2 - ] - }, - "arrivalTimePMF": { - "1-5": { - "val": [ - 5400.0, - 16200.0, - 57600 - ], - "prob": [ - 0.5, - 0.3, - 0.2 - ], - "dur": 7200 - }, - "0,6": { - "val": [ - 0 - ], - "prob": [ - 1 - ], - "dur": 7200 - } - }, - "departureTimePMF": null - } - } - }, - "resultsConfig": { - "timeseriesDt": 3600, - "powerMetricsDt": 900, - "resultFieldOptions": { - "nodes": null, - "portUsage": true, - "queueLength": true, - "nodeStats": true, - "evStats": true - } - } -} \ No newline at end of file From 9ba73ecd129818f5867962160e096fe76b57996b Mon Sep 17 00:00:00 2001 From: Byron Pullutasig <115118857+bpulluta@users.noreply.github.com> Date: Thu, 25 Sep 2025 09:42:14 -0600 Subject: [PATCH 20/26] removed example files --- README_ENSITE_UI.md | 266 --------------------------- run_enlitepy_compare.py | 398 ---------------------------------------- 2 files changed, 664 deletions(-) delete mode 100644 README_ENSITE_UI.md delete mode 100644 run_enlitepy_compare.py diff --git a/README_ENSITE_UI.md b/README_ENSITE_UI.md deleted file mode 100644 index 2ed8c8086..000000000 --- a/README_ENSITE_UI.md +++ /dev/null @@ -1,266 +0,0 @@ -# EnSite UI → API Integration Guide - -The frontend (UI) only sends a **simple declarative JSON** describing what the user edits: charger settings, vehicle arrival counts, arrival distribution, and optional max charge duration. **It does NOT build the full EnLitePy engine payload.** The Django view (`ensite_view`) transforms this UI JSON into the full simulation payload (`simConfig`, `hubConfig`, `evInfo`, `resultsConfig`) and runs EnLitePy. - -## Endpoint - -Stable path: -``` -POST /stable/ensite/ -``` -If running locally with Django development server: -``` -http://localhost:8000/stable/ensite/ -``` -(Ensure the trailing slash is included; the server will usually redirect if omitted.) - -## Authentication - -If the API is secured with a bearer token, include: -``` -Authorization: Bearer -``` -If no auth is required in your environment, omit the header. - -## Request Headers -``` -Content-Type: application/json -Accept: application/json -Authorization: Bearer # optional -``` - -## UI Input Contract (Simple JSON the Frontend Sends) - -The UI sends ONLY a lightweight object with optional sections. Any omitted section uses server defaults. - -Top-level keys (all optional): - -| Key | Type | Description | -|-----|------|-------------| -| `chargers` | object | Per charger category overrides (counts, power, efficiency, pci) | -| `vehicles` | object | Per vehicle class overrides (weekday/weekend arrivals, SOC, power, battery size) | -| `arrival` | object | Two arrays (`weekday`, `weekend`) each length 12 (2‑hr bins, percentages) | -| `maxChargeDurationMin` | number/null | Max charge duration in minutes (null or omit = no limit) | - -Charger category keys (if present): `L1`, `L2`, `DCFC_LD`, `DCFC_MDHD`. - -Vehicle class keys (if present): `LD`, `MD`, `HD`. - -Example UI request (only what the user edits): -```json -{ - "chargers": { - "L2": { "count": 4, "power_kW": 11.5, "eff": 0.86, "pci": "LD" }, - "DCFC_LD": { "count": 1, "power_kW": 150, "eff": 0.92, "pci": "LD" } - }, - "vehicles": { - "LD": { "weekday": 40, "weekend": 15, "battery_kWh": 82, "initSOC": 0.15, "targetSOC": 0.90, "pChgMax_kW": 170 } - }, - "arrival": { - "weekday": [0,0,0,0,5,10,10,15,20,20,5,5], - "weekend": [0,0,0,0,0,10,15,25,20,15,5,0] - }, - "maxChargeDurationMin": 180 -} -``` - -That’s it—the UI does not build nodes, counts arrays, PMFs, or unit conversions. - -## Server Transformation (What Django Builds Internally) - -Given the UI JSON above, the server produces a full EnLitePy payload: -1. Expands charger `count` into individual EVSE nodes (naming: `EVSE__`). -2. Converts `power_kW` → watts (`pMax`). -3. Builds each vehicle class `count` array: `[weekend, weekday, weekday, weekday, weekday, weekday, weekend]`. -4. Computes `energyDemandPMF` from `targetSOC - initSOC` (clamped 0–1). -5. Normalizes non-zero arrival bucket percentages into probability arrays (2‑hr bins → 7200 sec `dur`). -6. Converts battery and charger powers to integer watt values (`eCap`, `pChgMax`). -7. Converts `maxChargeDurationMin` → seconds (`chgDurMax`). -8. Adds standard `simConfig`, `resultsConfig`, and `ems` defaults. - -Conceptual (abbreviated) resulting payload: -```json -{ - "simConfig": {"tStart":0,"tEnd":604800,"dowStart":0}, - "hubConfig": { - "nodes": { - "grid": {"type":"Grid","pMax":500000,"pMin":0,"eff":0.99}, - "EVSE_L2_1": {"type":"EVSE","pMax":11500,"pMin":0,"eff":0.86,"nEVPort":1,"pci":"LD"}, - "EVSE_L2_2": {"type":"EVSE","pMax":11500,"pMin":0,"eff":0.86,"nEVPort":1,"pci":"LD"}, - "EVSE_L2_3": {"type":"EVSE","pMax":11500,"pMin":0,"eff":0.86,"nEVPort":1,"pci":"LD"}, - "EVSE_L2_4": {"type":"EVSE","pMax":11500,"pMin":0,"eff":0.86,"nEVPort":1,"pci":"LD"}, - "EVSE_DCFC_LD_1": {"type":"EVSE","pMax":150000,"pMin":0,"eff":0.92,"nEVPort":1,"pci":"LD"} - }, - "ems": {"type":"basic","pChgCap":500000,"pap":"FCFS+SMX"} - }, - "evInfo": { - "stochasticModel":1, - "chgDurMax":10800, - "evTypes": { - "LD": { - "type":"LD", - "eCap":82000, - "pChgMax":170000, - "count":[15,40,40,40,40,40,15], - "targetFinalSOCPMF":{"val":[0.9],"prob":[1.0]}, - "energyDemandPMF":{"val":[0.75],"prob":[1.0]}, - "arrivalTimePMF": {"1-5": {"val":[28800,36000,43200,50400,57600,64800,72000],"prob":[...] ,"dur":7200}, "0,6": {"val":[43200,50400,57600,64800],"prob":[...],"dur":7200}}, - "departureTimePMF": null - } - } - }, - "resultsConfig": {"timeseriesDt":3600,"powerMetricsDt":3600,"resultFieldOptions":{"nodes":"Grid","portUsage":true,"queueLength":true,"nodeStats":true,"evStats":true}} -} -``` - -## Why This Design -| Benefit | Explanation | -|---------|-------------| -| Thin UI | Frontend only manages user-facing fields (no internal engine schema knowledge). | -| Backward compatibility | Engine schema changes isolated to server transformation layer. | -| Validation centralization | Single place to clamp SOC, normalize arrival probabilities, enforce array lengths. | -| Faster iteration | Add new derived metrics in server without redeploying UI. | - -## Minimal Frontend Payload Builder (Pseudocode) -```javascript -// uiState collects user edits. Only send what changed; server fills defaults. -const payload = { - chargers: uiState.chargers, // optional - vehicles: uiState.vehicles, // optional - arrival: uiState.arrival, // { weekday: [...12], weekend: [...12] } - maxChargeDurationMin: uiState.maxDurationMin // optional -}; -fetch('/stable/ensite/', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, - body: JSON.stringify(payload) -}).then(r => r.json()).then(console.log); -``` - -## Validation Expectations (Server-Side) -| Check | Rule | Failure Response | -|-------|------|------------------| -| Arrival arrays length | Exactly 12 buckets | 400 with error message | -| Arrival values | ≥ 0 | 400 error | -| SOC bounds | 0 ≤ init < target ≤ 1 | Clamped or 400 depending on severity | -| Count arrays | Derived only (UI never sends them) | n/a | -| Charger counts | ≥ 0 integer | 400 if invalid | - -## Automatic Enrichment (Default Behavior) - -Every response now automatically includes cleaned & enriched metrics: - -| Field | Description | -|-------|-------------| -| `equipment_statistics` | Original weekly equipment statistics list | -| `equipment_statistics_annual` | Annual-scaled version (select kWh metrics × 365/7) | -| `ev_statistics` | Original weekly EV stats | -| `ev_statistics_annual` | Annual-scaled EV counts & energy; durations converted to hours | -| `timeseries.power_in_grid` | Weekly (or simulation horizon) power series | -| `timeseries.power_in_grid_annual` | Replicated to 8760 steps if source shorter | -| `logs` | Always present (empty list if none) | -| `enrichment_version` | Integer version of enrichment logic (currently 1) | - -No request flags are needed—UI simply posts the minimal input JSON and receives enriched results. - -## Response (Simplified) -```json -{ - "results": { - "equipment_statistics": [...], - "equipment_statistics_annual": [...], - "ev_statistics": [...], - "ev_statistics_annual": [...], - "timeseries": {"power_in_grid": [...], "power_in_grid_annual": [...]}, - "logs": [], - "enrichment_version": 1 - } -} -``` - -## Optional Advanced Mode (Direct Payload) -If a power user posts the full engine schema (with `simConfig` etc.), the server detects it and runs directly—skipping transformation. - -## Quick UI Smoke Test -```bash -curl -s -X POST http://localhost:8000/stable/ensite/ \ - -H 'Content-Type: application/json' -H 'Accept: application/json' \ - -d '{"vehicles":{"LD":{"weekday":5,"weekend":2}}}' | jq '.results | keys' -``` - ---- -For automated parity checks or deeper validation use `run_enlitepy_compare.py`. - -## curl Example -```bash -curl -X POST \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -H "Authorization: Bearer $ENLITEPY_API_TOKEN" \ # optional - -d @payload.json \ - http://localhost:8000/stable/ensite/ -``` - -## JavaScript fetch Example -```javascript -async function runSimulation(payload) { - const resp = await fetch('http://localhost:8000/stable/ensite/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - // 'Authorization': 'Bearer ' + token, // uncomment if needed - }, - body: JSON.stringify(payload) - }); - if (!resp.ok) { - const text = await resp.text(); - throw new Error(`HTTP ${resp.status}: ${text}`); - } - const data = await resp.json(); - console.log('Simulation results:', data); - return data; -} -``` - -## Response Structure (Simplified) -Successful responses contain: -```json -{ - "status": "OK", // or similar - "results": { - "equipment_statistics": [...], - "ev_statistics": [...], - "timeseries": {"power_in_grid": [ ... ]}, - "logs": [] - } -} -``` -Some fields may be JSON strings if not normalized; downstream clients can parse if needed. - -## Common Errors -| Issue | Cause | Fix | -|-------|-------|-----| -| 400 Bad Request | Missing required keys | Ensure `simConfig`, `hubConfig`, `evInfo`, `resultsConfig` present | -| 415 Unsupported Media Type | Missing Content-Type header | Add `Content-Type: application/json` | -| 401 Unauthorized | Missing or invalid token | Provide valid Bearer token if auth enabled | -| 500 Internal Server Error | Server-side exception | Re-check payload values; look at server logs | - -## Tips -- Keep integer power values in watts if the engine expects integer (e.g. 13 kW => 13000). -- Use consistent time units (seconds for `tStart`, `tEnd`, arrival values). -- For a no-op auth environment, simply omit the Authorization header. -- Start simple (one EV type, minimal EVSE nodes) before scaling up complexity. - -## Minimal Quick Test -```bash -python - <<'PY' -import json, urllib.request -payload = {"simConfig":{"tStart":0,"tEnd":604800,"dowStart":0},"hubConfig":{"nodes":{"grid":{"type":"Grid","pMax":500000,"pMin":0,"eff":0.99}},"ems":{"type":"basic","pChgCap":500000,"pap":"FCFS+SMX"}},"evInfo":{"stochasticModel":1,"chgDurMax":None,"evTypes":{}},"resultsConfig":{"timeseriesDt":3600,"powerMetricsDt":3600,"resultFieldOptions":{"nodes":"Grid","portUsage":True,"queueLength":True,"nodeStats":True,"evStats":True}}} -req = urllib.request.Request('http://localhost:8000/stable/ensite/', data=json.dumps(payload).encode(), headers={'Content-Type':'application/json','Accept':'application/json'}) -print(json.loads(urllib.request.urlopen(req).read().decode())['results'].keys()) -PY -``` - ---- -If you need a deeper validation or remote/local parity comparison, use `run_enlitepy_compare.py` which was added for automated checks. diff --git a/run_enlitepy_compare.py b/run_enlitepy_compare.py deleted file mode 100644 index dd3e5cda5..000000000 --- a/run_enlitepy_compare.py +++ /dev/null @@ -1,398 +0,0 @@ -#!/usr/bin/env python3 -"""Run EnLitePy minimal logic against two endpoints (remote & local) with identical payload. - -Features: - * Reuses the canonical weekly payload (same as run_enlitepy_minimal.py structure) - * Posts to --remote and --local (or one if desired) - * Supports --clean / --annual / --validate identical semantics - * Normalizes JSON-string statistics, produces annual derivatives (timeseries + *_annual stats) - * Writes individual response JSONs plus a combined diff summary - * Diff focuses on structure + key presence + simple numeric sanity (lengths, counts) - -Usage examples: - # Single line - python run_enlitepy_compare.py --remote https://ensite-py312-reopt-dev-api.its.nrel.gov/stable/ensite --local http://localhost:8000/stable/ensite/ --clean --annual --validate - - # Multi-line (must escape line breaks with backslashes!) - python run_enlitepy_compare.py \\ - --remote https://ensite-py312-reopt-dev-api.its.nrel.gov/stable/ensite \\ - --local http://localhost:8000/stable/ensite/ \\ - --clean --annual --validate - - # Using env vars (no flags needed if set) - export ENLITEPY_REMOTE_URL="https://ensite-py312-reopt-dev-api.its.nrel.gov/stable/ensite" - export ENLITEPY_LOCAL_URL="http://localhost:8000/stable/ensite/" - python run_enlitepy_compare.py --clean --annual --validate - -Outputs: - remote_response.json / local_response.json - compare_summary.json (diff + summary) -""" -from __future__ import annotations - -import argparse -import json -import os -from statistics import mean -from typing import Any, Dict, List, Tuple - -try: - import requests # type: ignore -except ImportError: - requests = None # type: ignore - import urllib.request - import urllib.error - -# ---------------- Base constants (copy from minimal) ------------------------- -DEFAULT_CHARGER_DEFAULTS: Dict[str, Dict[str, Any]] = { - "L1": {"count": 0, "power_kW": 2.4, "eff": 0.80, "pci": "LD"}, - "L2": {"count": 2, "power_kW": 13.0, "eff": 0.80, "pci": "LD"}, - "DCFC_LD": {"count": 0, "power_kW": 50.0, "eff": 0.90, "pci": "LD"}, - "DCFC_MDHD": {"count": 0, "power_kW": 250.0, "eff": 0.90, "pci": "MD/HD"}, -} -DEFAULT_VEHICLE_DEFAULTS: Dict[str, Dict[str, Any]] = { - "LD": {"weekday": 5, "weekend": 2, "battery_kWh": 80, "initSOC": 0.10, "targetSOC": 0.80, "pChgMax_kW": 120} -} -ARRIVAL_WEEKDAY_PCT = [0,0,0,0,0,0,0,0,40,40,20,0] -ARRIVAL_WEEKEND_PCT = [0]*12 -SIM_DAYS = 7 -EMS_CONFIG = {"type": "basic", "pChgCap": 500_000, "pap": "FCFS+SMX"} -BASE_SIM_CONFIG = {"tStart": 0, "tEnd": 7 * 24 * 3600, "dowStart": 0} -RESULTS_CONFIG_BASE = {"timeseriesDt": 3600, "powerMetricsDt": 3600} -WEEK_TO_YEAR_SCALE = 365 / 7 -EQUIPMENT_ANNUAL_SCALARS = {"Energy output [kWh]", "Energy loss [kWh]"} -EV_COUNT_SCALARS = {"Total EVs arrived [#]", "Total unique EVs arrived [#]", "EVs no queue wait [#]", "EVs arrived but not charged [#]", "EVs that did not start/complete charge [#]"} -EV_ENERGY_SCALARS = {"Total energy charged [kWh]"} - -# ---------------- Payload build --------------------------------------------- - -def build_arrival_pmf(weekday_pct: List[float], weekend_pct: List[float], bucket_hours: int = 2) -> Dict[str, Any]: - bucket_seconds = bucket_hours * 3600 - def normalize(side: List[float]): - active = [(i,x) for i,x in enumerate(side) if x>0] - if not active: - return {"val": [0], "prob": [1.0], "dur": bucket_seconds} - total = sum(v for _,v in active) - vals = [i*bucket_seconds for i,_ in active] - probs = [v/total for _,v in active] - diff = 1 - sum(probs) - if abs(diff) > 1e-9: - probs[-1] += diff - return {"val": vals, "prob": [round(p,6) for p in probs], "dur": bucket_seconds} - return {"1-5": normalize(weekday_pct), "0,6": normalize(weekend_pct)} - -def build_payload(days: int = SIM_DAYS) -> Dict[str, Any]: - nodes: Dict[str, Any] = {"grid": {"type": "Grid", "pMax": 500_000, "pMin": 0, "eff": 0.99}} - for cat, cfg in DEFAULT_CHARGER_DEFAULTS.items(): - for i in range(1, cfg["count"] + 1): - nodes[f"EVSE_{cat}_{i}"] = { - "type": "EVSE", "pMax": int(cfg["power_kW"]*1000), "pMin": 0, "eff": cfg["eff"], "nEVPort": 1, "pci": cfg["pci"] - } - def expand(we:int, wd:int): return [we, wd, wd, wd, wd, wd, we] - weekday_weekend = {k: expand(v["weekend"], v["weekday"]) for k,v in DEFAULT_VEHICLE_DEFAULTS.items()} - ev_types: Dict[str, Any] = {} - arrival = build_arrival_pmf(ARRIVAL_WEEKDAY_PCT, ARRIVAL_WEEKEND_PCT) - for k,v in DEFAULT_VEHICLE_DEFAULTS.items(): - delta = max(0.0, min(1.0, v['targetSOC'] - v['initSOC'])) - ev_types[k] = { - "type": k, - "eCap": int(v['battery_kWh']*1000), - "pChgMax": int(v['pChgMax_kW']*1000), - "count": weekday_weekend[k], - "targetFinalSOCPMF": {"val": [round(v['targetSOC'],4)], "prob": [1.0]}, - "energyDemandPMF": {"val": [round(delta,4)], "prob": [1.0]}, - "arrivalTimePMF": arrival, - "departureTimePMF": None, - } - sim_cfg = dict(BASE_SIM_CONFIG) - sim_cfg['tEnd'] = days * 24 * 3600 - rc = {**RESULTS_CONFIG_BASE, "resultFieldOptions": {"nodes": "Grid", "portUsage": True, "queueLength": True, "nodeStats": True, "evStats": True}} - return {"simConfig": sim_cfg, "hubConfig": {"nodes": nodes, "ems": EMS_CONFIG}, "evInfo": {"stochasticModel": 1, "chgDurMax": None, "evTypes": ev_types}, "resultsConfig": rc} - -# ---------------- HTTP ------------------------------------------------------ - -def post(url: str, payload: Dict[str, Any]) -> Tuple[int, Dict[str, Any] | None, str]: - headers = {"Content-Type": "application/json", "Accept": "application/json"} - token = os.getenv("ENLITEPY_API_TOKEN") - if token: - headers["Authorization"] = f"Bearer {token}" - body = json.dumps(payload) - try: - if requests: - r = requests.post(url.rstrip('/'), data=body, headers=headers, timeout=(10,120)) - if r.status_code == 404: - r = requests.post(url.rstrip('/')+'/', data=body, headers=headers, timeout=(10,120)) - status, text = r.status_code, r.text - else: - data_b = body.encode() - def _one(u): - req = urllib.request.Request(u, data=data_b, headers=headers) - return urllib.request.urlopen(req, timeout=120) - try: - resp = _one(url.rstrip('/')) - except Exception: - resp = _one(url.rstrip('/')+'/') - status = resp.status - text = resp.read().decode() - except Exception as e: # noqa - return 0, None, f"EXC:{e}" - try: - js = json.loads(text) - except json.JSONDecodeError: - return status, None, text[:400] - return status, js, '' - -# ---------------- Cleaning / Annual ----------------------------------------- - -def replicate_to_8760(series: Any) -> Any: - if not isinstance(series, list) or len(series) in (0, 8760): - return series - reps = (8760 // len(series)) + 1 - return (series * reps)[:8760] - -def make_annual_equipment(weekly: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - out = [] - for row in weekly: - copy = row.copy() - for k in EQUIPMENT_ANNUAL_SCALARS: - v = copy.get(k) - if isinstance(v, (int,float)): - copy[k] = round(v * WEEK_TO_YEAR_SCALE, 2) - out.append(copy) - return out - -def make_annual_ev(weekly: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - out = [] - for row in weekly: - copy = row.copy() - for k,v in list(copy.items()): - if k in EV_COUNT_SCALARS or k in EV_ENERGY_SCALARS: - if isinstance(v,(int,float)): - copy[k] = round(v * WEEK_TO_YEAR_SCALE, 2) - for k in list(copy.keys()): - if k.endswith('[min]') and isinstance(copy[k], (int,float)): - copy[k.replace('[min]','[h]')] = round(copy[k]/60.0,2) - del copy[k] - out.append(copy) - return out - -def add_grid_util(ts: Dict[str, Any], equip_annual: List[Dict[str, Any]]): - power = ts.get('power_in_grid') if isinstance(ts, dict) else None - if not isinstance(power, list) or not power: - return - grid_row = next((r for r in equip_annual if r.get('Name') in ('grid','Grid')), None) - if not grid_row or 'Power capacity [kW]' not in grid_row: - return - cap = grid_row['Power capacity [kW]'] - if not isinstance(cap,(int,float)) or cap <= 0: - return - sample = [v for v in power[:50] if isinstance(v,(int,float))] - if not sample: - return - max_sample = max(sample) - to_kw = 1000.0 if max_sample > cap*1.1 else 1.0 - series_kw = [v/to_kw for v in power if isinstance(v,(int,float))] - if not series_kw: - return - grid_row.setdefault('Min capacity utilization [kW]', round(min(series_kw),2)) - grid_row.setdefault('Average capacity utilization [kW]', round(mean(series_kw),2)) - -def normalize_and_enrich(resp: Dict[str, Any], do_annual: bool): - results = resp.get('results') - if not isinstance(results, dict): - return - for key in ('equipment_statistics','ev_statistics'): - val = results.get(key) - if isinstance(val,str): - try: - parsed = json.loads(val) - if isinstance(parsed, list): - results[key] = parsed - except json.JSONDecodeError: - pass - if do_annual: - ts = results.get('timeseries') - if isinstance(ts, dict) and 'power_in_grid' in ts: - ann = replicate_to_8760(ts['power_in_grid']) - if isinstance(ann, list) and len(ann) == 8760: - ts['power_in_grid_annual'] = ann - equip = results.get('equipment_statistics') or [] - ev = results.get('ev_statistics') or [] - if isinstance(equip, list): - equip_ann = make_annual_equipment(equip) - results['equipment_statistics_annual'] = equip_ann - add_grid_util(results.get('timeseries',{}), equip_ann) - if isinstance(ev, list): - results['ev_statistics_annual'] = make_annual_ev(ev) - -# ---------------- Validation ------------------------------------------------- - -def validate_scaling(resp: Dict[str, Any], tol: float = 0.05) -> List[str]: - out: List[str] = [] - res = resp.get('results', {}) - ew = res.get('equipment_statistics', []) - ea = res.get('equipment_statistics_annual', []) - eq_map = {r.get('Name'): r for r in ew if isinstance(r, dict)} - for a in ea: - name = a.get('Name') - w = eq_map.get(name) - if not w: - continue - for k in EQUIPMENT_ANNUAL_SCALARS: - wv = w.get(k) - av = a.get(k) - if isinstance(wv,(int,float)) and isinstance(av,(int,float)) and wv>0: - expect = wv*WEEK_TO_YEAR_SCALE - err = abs(av - expect)/expect if expect else 0 - if err > tol: - out.append(f"Equip {name}.{k} rel error {err:.2%} > {tol:.0%}") - evw = res.get('ev_statistics', []) - eva = res.get('ev_statistics_annual', []) - if isinstance(evw,list) and isinstance(eva,list) and evw and eva: - w0 = evw[0] - e0 = eva[0] - for k in EV_COUNT_SCALARS | EV_ENERGY_SCALARS: - wv = w0.get(k) - av = e0.get(k) - if isinstance(wv,(int,float)) and isinstance(av,(int,float)) and wv>0: - expect = wv*WEEK_TO_YEAR_SCALE - err = abs(av - expect)/expect - if err > tol: - out.append(f"EV {k} rel error {err:.2%} > {tol:.0%}") - if not out: - out.append('All scaling checks within tolerance.') - return out - -# ---------------- Diff helpers ---------------------------------------------- - -def keyset(results: Dict[str, Any]) -> List[str]: - if not isinstance(results, dict): - return [] - return sorted(list(results.keys())) - -def diff_keysets(remote: Dict[str, Any], local: Dict[str, Any]) -> List[str]: - issues: List[str] = [] - rk = set(keyset(remote.get('results', {}))) - lk = set(keyset(local.get('results', {}))) - for miss in sorted(rk - lk): - issues.append(f'local missing key: {miss}') - for miss in sorted(lk - rk): - issues.append(f'remote missing key: {miss}') - return issues - -# ---------------- Main ------------------------------------------------------ - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument('--remote', help='Remote EnLitePy endpoint URL') - ap.add_argument('--local', help='Local /stable/ensite/ endpoint URL') - ap.add_argument('--annual', dest='annual', action='store_true', help='(Deprecated) Force annual enrichment (now default)') - ap.add_argument('--no-annual', dest='no_annual', action='store_true', help='Disable annual enrichment (default: enabled)') - ap.add_argument('--clean', dest='clean', action='store_true', help='(Deprecated) Force cleaning (now default)') - ap.add_argument('--no-clean', dest='no_clean', action='store_true', help='Disable cleaning/normalization (default: enabled)') - ap.add_argument('--validate', action='store_true', help='Run scaling validation (always implies cleaning & annual)') - ap.add_argument('--outfile-prefix', default='compare_run', help='Prefix for output files') - args = ap.parse_args() - - # Allow environment variable fallback if flags omitted - if not args.remote: - args.remote = os.getenv('ENLITEPY_REMOTE_URL') or os.getenv('ENLITEPY_API_URL') - if not args.local: - args.local = os.getenv('ENLITEPY_LOCAL_URL') or os.getenv('REOPT_LOCAL_ENLITEPY_URL') - - # Default behavior: clean & annual ON unless explicitly disabled - clean_enabled = True - annual_enabled = True - if args.no_clean: - clean_enabled = False - if args.no_annual: - annual_enabled = False - if args.validate: - clean_enabled = True - annual_enabled = True - - payload = build_payload() - print('Built payload EV types:', list(payload['evInfo']['evTypes'].keys()), 'EVSE count:', sum(1 for k in payload['hubConfig']['nodes'] if k.startswith('EVSE_'))) - - remote_resp = local_resp = None - remote_status = local_status = None - if not (args.remote or args.local): - print('Must supply at least one of --remote or --local') - return 1 - - if args.remote: - print('POST remote:', args.remote) - remote_status, remote_resp, r_err = post(args.remote, payload) - if remote_status != 200 or not remote_resp: - print(' Remote failed:', remote_status, r_err) - else: - print(' Remote OK') - if args.local: - print('POST local:', args.local) - local_status, local_resp, l_err = post(args.local, payload) - if local_status != 200 or not local_resp: - print(' Local failed:', local_status, l_err) - else: - print(' Local OK') - - if clean_enabled: - if remote_resp: - normalize_and_enrich(remote_resp, annual_enabled) - if local_resp: - normalize_and_enrich(local_resp, annual_enabled) - - if args.validate: - if remote_resp: - for v in validate_scaling(remote_resp): - print('REMOTE VALIDATE:', v) - if local_resp: - for v in validate_scaling(local_resp): - print('LOCAL VALIDATE:', v) - - # Write individual responses - if remote_resp: - with open(f'{args.outfile_prefix}_remote.json','w') as f: - json.dump(remote_resp, f) - print('Wrote', f'{args.outfile_prefix}_remote.json') - if local_resp: - with open(f'{args.outfile_prefix}_local.json','w') as f: - json.dump(local_resp, f) - print('Wrote', f'{args.outfile_prefix}_local.json') - - diff_issues: List[str] = [] - if remote_resp and local_resp: - diff_issues = diff_keysets(remote_resp, local_resp) - # ignore enrichment_version differences in key diff - diff_issues = [d for d in diff_issues if 'enrichment_version' not in d] - if diff_issues: - print('STRUCTURAL DIFFS:') - for d in diff_issues: - print(' -', d) - else: - print('Structures align.') - - summary = { - 'remote_status': remote_status, - 'local_status': local_status, - 'annual_requested': annual_enabled, - 'clean_requested': clean_enabled, - 'validated': args.validate, - 'remote_results_keys': keyset(remote_resp.get('results', {})) if remote_resp else None, - 'local_results_keys': keyset(local_resp.get('results', {})) if local_resp else None, - 'structural_differences': diff_issues, - } - with open(f'{args.outfile_prefix}_summary.json','w') as f: - json.dump(summary, f, indent=2) - print('Wrote', f'{args.outfile_prefix}_summary.json') - - # exit code - if remote_status and remote_status != 200: - return 2 - if local_status and local_status != 200: - return 3 - if diff_issues: - return 4 - return 0 - -if __name__ == '__main__': - raise SystemExit(main()) From e05bd16ba70033fb8537a67a2e20665d11f87a53 Mon Sep 17 00:00:00 2001 From: Byron Pullutasig <115118857+bpulluta@users.noreply.github.com> Date: Thu, 25 Sep 2025 09:42:45 -0600 Subject: [PATCH 21/26] Pull --- EVI-EnLitePy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EVI-EnLitePy b/EVI-EnLitePy index c8a1c84b8..75e5e6e4c 160000 --- a/EVI-EnLitePy +++ b/EVI-EnLitePy @@ -1 +1 @@ -Subproject commit c8a1c84b82f726db7bcda616641214366e4cc1c2 +Subproject commit 75e5e6e4c106e7b28c932bbc524816c199661759 From 3b689020df58f355431831eaf6af562b901749b5 Mon Sep 17 00:00:00 2001 From: Byron Pullutasig <115118857+bpulluta@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:11:06 -0600 Subject: [PATCH 22/26] document maxChargeDurationHr, returns power timeseries in kW and add max grid utilization metric --- load_builder/views.py | 62 +++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/load_builder/views.py b/load_builder/views.py index 04d7288ee..519fc3603 100644 --- a/load_builder/views.py +++ b/load_builder/views.py @@ -66,33 +66,49 @@ def normalize(side, label): def build_enlitepy_payload(ui_inputs=None): - """Build EnLitePy payload from UI inputs or use defaults.""" + """Build EnLitePy payload from UI inputs or use defaults. + + Parameters + ---------- + ui_inputs : dict | None + Optional UI schema input. If provided, may include: + - chargers + - vehicles + - arrival + - maxChargeDurationHr (float/int, HOURS) + + Notes + ----- + * Duration: `maxChargeDurationHr` (hours) -> multiplied by 3600 -> seconds (`chgDurMax`). + * Power units: UI kW values converted to W where simulation expects watts. + * PMFs: Currently deterministic single-point distributions; can be expanded later. + """ # Apply UI overrides or use defaults charger_config = ui_inputs.get("chargers", {}) if ui_inputs else {} vehicle_config = ui_inputs.get("vehicles", {}) if ui_inputs else {} arrival_config = ui_inputs.get("arrival", {}) if ui_inputs else {} - max_duration = ui_inputs.get("maxChargeDurationMin") if ui_inputs else None - + max_duration = ui_inputs.get("maxChargeDurationHr") if ui_inputs else None + # Deep merge with defaults chargers = {} for cat, defaults in DEFAULT_CHARGER_DEFAULTS.items(): chargers[cat] = {**defaults} if cat in charger_config: chargers[cat].update(charger_config[cat]) - + vehicles = {} for vtype, defaults in DEFAULT_VEHICLE_DEFAULTS.items(): vehicles[vtype] = {**defaults} if vtype in vehicle_config: vehicles[vtype].update(vehicle_config[vtype]) - + # Arrival patterns weekday_pct = arrival_config.get("weekday", [0] * 12) weekend_pct = arrival_config.get("weekend", [0] * 12) - + # Build nodes nodes = {"grid": {"type": "Grid", "pMax": 500_000, "pMin": 0, "eff": 0.99}} - + # Add EVSE nodes for cat, cfg in chargers.items(): for i in range(1, cfg["count"] + 1): @@ -104,14 +120,14 @@ def build_enlitepy_payload(ui_inputs=None): "nEVPort": 1, "pci": cfg["pci"], } - + # Build EV types def expand_counts(weekend, weekday): return [weekend, weekday, weekday, weekday, weekday, weekday, weekend] - + weekday_weekend = {k: expand_counts(v["weekend"], v["weekday"]) for k, v in vehicles.items()} arrival_pmf = build_arrival_pmf(weekday_pct, weekend_pct) - + ev_types = {} for k, v in vehicles.items(): delta = max(0.0, min(1.0, v["targetSOC"] - v["initSOC"])) @@ -125,18 +141,18 @@ def expand_counts(weekend, weekday): "arrivalTimePMF": arrival_pmf, "departureTimePMF": None, } - + sim_cfg = dict(BASE_SIM_CONFIG) sim_cfg["tEnd"] = SIM_DAYS * 24 * 3600 - - duration_max = None + + duration_max_hr = None if max_duration is not None: - duration_max = int(max_duration * 60) # Convert minutes to seconds - + duration_max_hr = int(max_duration * 3600) + return { "simConfig": sim_cfg, "hubConfig": {"nodes": nodes, "ems": EMS_CONFIG}, - "evInfo": {"stochasticModel": 1, "chgDurMax": duration_max, "evTypes": ev_types}, + "evInfo": {"stochasticModel": 1, "chgDurMax": duration_max_hr, "evTypes": ev_types}, "resultsConfig": { **RESULTS_CONFIG_BASE, "resultFieldOptions": { @@ -156,7 +172,7 @@ def is_ui_input(data): return False # Check for UI-specific keys - ui_keys = {"chargers", "vehicles", "arrival", "maxChargeDurationMin"} + ui_keys = {"chargers", "vehicles", "arrival", "maxChargeDurationHr"} if any(key in data for key in ui_keys): return True @@ -190,7 +206,8 @@ def ensite_view(request): results['logs'] = [] # Always normalize and annualize for client convenience _normalize_and_enrich(results) - results.setdefault('enrichment_version', 1) + # Bump enrichment version to 2 to reflect unit change (power_in_grid_annual now in kW) + results.setdefault('enrichment_version', 2) return JsonResponse({"results": results}) except Exception as e: return JsonResponse({"Error": f"EnLitePy execution failed: {e}"}, status=500) @@ -358,10 +375,7 @@ def translate_months(string): loads_kw.append(hour_load) return loads_kw -# Annual scaling helper constants (reuse existing ones if defined) -EQUIPMENT_ANNUAL_SCALARS = {"Energy output [kWh]", "Energy loss [kWh]"} -EV_COUNT_SCALARS = {"Total EVs arrived [#]", "Total unique EVs arrived [#]", "EVs no queue wait [#]", "EVs arrived but not charged [#]", "EVs that did not start/complete charge [#]"} -EV_ENERGY_SCALARS = {"Total energy charged [kWh]"} +# (Removed duplicate constant definitions that appeared earlier in file) def _replicate_to_8760(series): if not isinstance(series, list) or len(series) == 0: @@ -431,6 +445,7 @@ def _add_grid_util(timeseries_dict, equip_annual): return grid_row.setdefault('Min capacity utilization [kW]', round(min(series_kw), 2)) grid_row.setdefault('Average capacity utilization [kW]', round(sum(series_kw) / len(series_kw), 2)) + grid_row.setdefault('Max capacity utilization [kW]', round(max(series_kw), 2)) def _normalize_and_enrich(results_dict): @@ -453,7 +468,8 @@ def _normalize_and_enrich(results_dict): if isinstance(timeseries, dict) and 'power_in_grid' in timeseries: annual_series = _replicate_to_8760(timeseries['power_in_grid']) if isinstance(annual_series, list) and len(annual_series) == 8760: - timeseries['power_in_grid_annual'] = annual_series + # Convert from W (simulation native) to kW for annual series exposure + timeseries['power_in_grid_annual'] = [x / 1000 for x in annual_series] equip_annual = _annualize_equipment(equipment) if equipment else [] ev_annual = _annualize_ev(evstats) if evstats else [] if equip_annual: From 98287d9721bd88022eeb85dcdcc26abe51137345 Mon Sep 17 00:00:00 2001 From: Byron Pullutasig <115118857+bpulluta@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:11:40 -0600 Subject: [PATCH 23/26] document maxChargeDurationHr, returns power timeseries in kW and add max grid utilization --- load_builder/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/load_builder/views.py b/load_builder/views.py index 519fc3603..3243e6169 100644 --- a/load_builder/views.py +++ b/load_builder/views.py @@ -207,7 +207,7 @@ def ensite_view(request): # Always normalize and annualize for client convenience _normalize_and_enrich(results) # Bump enrichment version to 2 to reflect unit change (power_in_grid_annual now in kW) - results.setdefault('enrichment_version', 2) + results.setdefault('version', 2) return JsonResponse({"results": results}) except Exception as e: return JsonResponse({"Error": f"EnLitePy execution failed: {e}"}, status=500) From 000cf758e36902b4fab191cc27f0375d0c6ac9e9 Mon Sep 17 00:00:00 2001 From: "Rathod, Bhavesh" Date: Tue, 30 Sep 2025 08:56:57 -0400 Subject: [PATCH 24/26] clean $ sign in migration file --- ...ts_max_initial_capital_costs_before_incentives_and_more.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reoptjl/migrations/0085_financialinputs_max_initial_capital_costs_before_incentives_and_more.py b/reoptjl/migrations/0085_financialinputs_max_initial_capital_costs_before_incentives_and_more.py index 5a02b6559..9e3a0101d 100644 --- a/reoptjl/migrations/0085_financialinputs_max_initial_capital_costs_before_incentives_and_more.py +++ b/reoptjl/migrations/0085_financialinputs_max_initial_capital_costs_before_incentives_and_more.py @@ -14,11 +14,11 @@ class Migration(migrations.Migration): migrations.AddField( model_name='financialinputs', name='max_initial_capital_costs_before_incentives', - field=models.FloatField(blank=True, help_text='Maximum up-front capital cost for all technologies, excluding replacement costs and incentives [\$].', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000000000000.0)]), + field=models.FloatField(blank=True, help_text='Maximum up-front capital cost for all technologies, excluding replacement costs and incentives [\\$].', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000000000000.0)]), ), migrations.AddField( model_name='financialinputs', name='min_initial_capital_costs_before_incentives', - field=models.FloatField(blank=True, help_text='Minimum up-front capital cost for all technologies, excluding replacement costs and incentives [\$].', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000000000000.0)]), + field=models.FloatField(blank=True, help_text='Minimum up-front capital cost for all technologies, excluding replacement costs and incentives [\\$].', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000000000000.0)]), ), ] From 319e44c4e4ebd3fae131d651a63a48d47e315d92 Mon Sep 17 00:00:00 2001 From: "Rathod, Bhavesh" Date: Tue, 30 Sep 2025 09:35:24 -0400 Subject: [PATCH 25/26] Resolve migration issues replicating the working migrations from `develop` and creating migration file for any missing model inputs or outputs --- ...> 0105_alter_chpoutputs_initial_capital_costs_and_more.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename reoptjl/migrations/{0093_alter_chpoutputs_initial_capital_costs_and_more.py => 0105_alter_chpoutputs_initial_capital_costs_and_more.py} (96%) diff --git a/reoptjl/migrations/0093_alter_chpoutputs_initial_capital_costs_and_more.py b/reoptjl/migrations/0105_alter_chpoutputs_initial_capital_costs_and_more.py similarity index 96% rename from reoptjl/migrations/0093_alter_chpoutputs_initial_capital_costs_and_more.py rename to reoptjl/migrations/0105_alter_chpoutputs_initial_capital_costs_and_more.py index e5b38f0b0..7674344bc 100644 --- a/reoptjl/migrations/0093_alter_chpoutputs_initial_capital_costs_and_more.py +++ b/reoptjl/migrations/0105_alter_chpoutputs_initial_capital_costs_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2 on 2025-09-10 16:45 +# Generated by Django 4.2.24 on 2025-09-30 13:31 import django.core.validators from django.db import migrations, models @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ('reoptjl', '0092_merge_20250613_0525'), + ('reoptjl', '0104_cstinputs_can_waste_heat'), ] operations = [ From 72323edd2607eb74aa5352a149ab45ac38fc40f7 Mon Sep 17 00:00:00 2001 From: Byron Pullutasig <115118857+bpulluta@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:08:12 -0600 Subject: [PATCH 26/26] dynamic EMS charging capacity (sum of chargers) with override --- load_builder/views.py | 110 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 7 deletions(-) diff --git a/load_builder/views.py b/load_builder/views.py index 3243e6169..d412a3012 100644 --- a/load_builder/views.py +++ b/load_builder/views.py @@ -13,6 +13,13 @@ enlitepyapi = None # EnLitePy Configuration Constants +# These constants define default simulation-level parameters exposed to the UI. +# Units & Conventions: +# * UI-facing aggregate power capacities (e.g., EMS/site charge cap) are in kW. +# * Internal model power is Watts -> we convert via (kW * 1000) at ingress. +# * Charging duration inputs are in hours; converted to integer seconds. +# * Guardrails (min/max) are deliberately generous to catch obvious typos +# (e.g., 0.1 kW or 1e9 kW) while not constraining legitimate large sites. DEFAULT_CHARGER_DEFAULTS = { "L1": {"count": 0, "power_kW": 2.4, "eff": 0.80, "pci": "LD"}, "L2": {"count": 0, "power_kW": 13.0, "eff": 0.80, "pci": "LD"}, @@ -25,9 +32,13 @@ "MD": {"weekday": 0, "weekend": 0, "battery_kWh": 200, "initSOC": 0.20, "targetSOC": 0.80, "pChgMax_kW": 300}, "HD": {"weekday": 0, "weekend": 0, "battery_kWh": 500, "initSOC": 0.30, "targetSOC": 0.85, "pChgMax_kW": 600}, } +EMS_DEFAULT_PCHG_CAP_KW = 1000 # Legacy fallback (1 MW) if no chargers defined +MAX_CHG_DURATION_DEFAULT_HR = None # If set (e.g., 6), becomes default max charge duration (hours) unless UI overrides SIM_DAYS = 7 -EMS_CONFIG = {"type": "basic", "pChgCap": 500_000, "pap": "FCFS+SMX"} +EMS_MIN_PCHG_CAP_KW = 10 # lower bound guardrail (practically non-zero site) +EMS_MAX_PCHG_CAP_KW = 20000 # upper bound guardrail (20 MW campus / depot scale) +EMS_CONFIG = {"type": "basic", "pChgCap": EMS_DEFAULT_PCHG_CAP_KW * 1000, "pap": "FCFS+SMX"} # stored in Watts BASE_SIM_CONFIG = {"tStart": 0, "tEnd": 7 * 24 * 3600, "dowStart": 0} RESULTS_CONFIG_BASE = {"timeseriesDt": 3600, "powerMetricsDt": 3600} WEEK_TO_YEAR_SCALE = 365 / 7 @@ -65,6 +76,52 @@ def normalize(side, label): return {"1-5": normalize(weekday_pct, "Weekday"), "0,6": normalize(weekend_pct, "Weekend")} +def _validate_and_apply_ems_override(ems_override, base_cfg): + """Return new EMS config with validated overrides applied. + + Inputs: + ems_override: dict possibly containing one or both of: + * pChgCap_kW (float/int) -> preferred UI field + * pChgCap (int Watts) -> legacy / fallback + base_cfg: existing EMS config dict (must include 'pChgCap' in Watts) + + Logic: + * Precedence: pChgCap_kW overrides pChgCap if both present. + * Conversion: kW -> W, rounding to nearest integer. + * Validation: Enforces positive and within configured guardrails. + + Returns: + New dict copy containing possibly updated 'pChgCap'. + + Raises: + ValueError with a concise reason for any invalid override. + """ + cfg = dict(base_cfg) + if not isinstance(ems_override, dict): + return cfg + pchgcap_kw = ems_override.get("pChgCap_kW") + pchgcap_w = ems_override.get("pChgCap") if pchgcap_kw is None else None + try: + if pchgcap_kw is not None: + val_kw = float(pchgcap_kw) + if val_kw <= 0: + raise ValueError("pChgCap_kW must be > 0") + if not (EMS_MIN_PCHG_CAP_KW <= val_kw <= EMS_MAX_PCHG_CAP_KW): + raise ValueError(f"pChgCap_kW must be within [{EMS_MIN_PCHG_CAP_KW}, {EMS_MAX_PCHG_CAP_KW}] kW") + cfg["pChgCap"] = int(round(val_kw * 1000)) + elif pchgcap_w is not None: + val_w = float(pchgcap_w) + if val_w <= 0: + raise ValueError("pChgCap must be > 0") + val_kw = val_w / 1000.0 + if not (EMS_MIN_PCHG_CAP_KW <= val_kw <= EMS_MAX_PCHG_CAP_KW): + raise ValueError(f"pChgCap equivalent must be within [{EMS_MIN_PCHG_CAP_KW}, {EMS_MAX_PCHG_CAP_KW}] kW") + cfg["pChgCap"] = int(round(val_w)) + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid EMS capacity override: {exc}") + return cfg + + def build_enlitepy_payload(ui_inputs=None): """Build EnLitePy payload from UI inputs or use defaults. @@ -75,19 +132,32 @@ def build_enlitepy_payload(ui_inputs=None): - chargers - vehicles - arrival + - ems: { pChgCap_kW: |None OR pChgCap: } + If both provided, pChgCap_kW takes precedence. The resulting value + is stored internally in Watts under 'pChgCap'. - maxChargeDurationHr (float/int, HOURS) Notes ----- * Duration: `maxChargeDurationHr` (hours) -> multiplied by 3600 -> seconds (`chgDurMax`). - * Power units: UI kW values converted to W where simulation expects watts. + * Power units: UI kW values converted to W where simulation expects watts. + * EMS Capacity Default: If the UI does NOT supply an EMS override, the default + aggregate site charge capacity is computed as the SUM of all configured + charger rated powers (count * power_kW for each charger). If that sum is 0 + (no chargers), we fall back to EMS_DEFAULT_PCHG_CAP_KW (legacy 1 MW). UI can + still explicitly override via ems.pChgCap_kW or ems.pChgCap. + * Max Charge Duration: If `maxChargeDurationHr` omitted and `MAX_CHG_DURATION_DEFAULT_HR` + is set, that value is applied. Only a basic >0 validation is enforced. * PMFs: Currently deterministic single-point distributions; can be expanded later. """ # Apply UI overrides or use defaults charger_config = ui_inputs.get("chargers", {}) if ui_inputs else {} vehicle_config = ui_inputs.get("vehicles", {}) if ui_inputs else {} arrival_config = ui_inputs.get("arrival", {}) if ui_inputs else {} + ems_override = ui_inputs.get("ems", {}) if ui_inputs else {} max_duration = ui_inputs.get("maxChargeDurationHr") if ui_inputs else None + if max_duration is None and MAX_CHG_DURATION_DEFAULT_HR is not None: + max_duration = MAX_CHG_DURATION_DEFAULT_HR # Deep merge with defaults chargers = {} @@ -106,8 +176,28 @@ def build_enlitepy_payload(ui_inputs=None): weekday_pct = arrival_config.get("weekday", [0] * 12) weekend_pct = arrival_config.get("weekend", [0] * 12) - # Build nodes - nodes = {"grid": {"type": "Grid", "pMax": 500_000, "pMin": 0, "eff": 0.99}} + # Start with EMS defaults and apply overrides + ems_cfg = _validate_and_apply_ems_override(ems_override, EMS_CONFIG) + + # Dynamic capacity if no explicit override provided (i.e., value unchanged from base) + if ems_cfg.get("pChgCap") == EMS_CONFIG["pChgCap"] and not ems_override: + total_kw = 0.0 + for cat, cfg in chargers.items(): + count = cfg.get("count", 0) or 0 + power_kw = cfg.get("power_kW", 0) or 0 + try: + total_kw += float(count) * float(power_kw) + except (TypeError, ValueError): + pass + if total_kw > 0: + ems_cfg["pChgCap"] = int(round(total_kw * 1000)) + else: + # retain legacy fallback already set + pass + + # Build nodes with grid capacity synchronized to EMS pChgCap + grid_pmax = ems_cfg.get("pChgCap", EMS_DEFAULT_PCHG_CAP_KW * 1000) + nodes = {"grid": {"type": "Grid", "pMax": grid_pmax, "pMin": 0, "eff": 0.99}} # Add EVSE nodes for cat, cfg in chargers.items(): @@ -147,11 +237,17 @@ def expand_counts(weekend, weekday): duration_max_hr = None if max_duration is not None: - duration_max_hr = int(max_duration * 3600) + try: + md = float(max_duration) + if md <= 0: + raise ValueError("maxChargeDurationHr must be > 0") + duration_max_hr = int(round(md * 3600)) + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid maxChargeDurationHr: {exc}") return { "simConfig": sim_cfg, - "hubConfig": {"nodes": nodes, "ems": EMS_CONFIG}, + "hubConfig": {"nodes": nodes, "ems": ems_cfg}, "evInfo": {"stochasticModel": 1, "chgDurMax": duration_max_hr, "evTypes": ev_types}, "resultsConfig": { **RESULTS_CONFIG_BASE, @@ -172,7 +268,7 @@ def is_ui_input(data): return False # Check for UI-specific keys - ui_keys = {"chargers", "vehicles", "arrival", "maxChargeDurationHr"} + ui_keys = {"chargers", "vehicles", "arrival", "maxChargeDurationHr", "ems"} if any(key in data for key in ui_keys): return True