From 60c3c8802adddd46b84351ab75ac0d2f495140ff Mon Sep 17 00:00:00 2001 From: wbecker Date: Fri, 19 Dec 2025 10:08:37 -0700 Subject: [PATCH 1/5] Make load_type optional (default=electric) for GET request --- reoptjl/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/reoptjl/views.py b/reoptjl/views.py index e4984a43e..b4340462e 100644 --- a/reoptjl/views.py +++ b/reoptjl/views.py @@ -597,12 +597,13 @@ def simulated_load(request): inputs["latitude"] = float(request.GET['latitude']) # need float to convert unicode inputs["longitude"] = float(request.GET['longitude']) # Optional load_type - will default to "electric" - inputs["load_type"] = request.GET.get('load_type') + if 'load_type' in request.GET: + inputs["load_type"] = request.GET["load_type"] # Optional year parameter to shift the CRB profile from 2017 (also 2023) to the input year if 'year' in request.GET: inputs["year"] = int(request.GET['year']) - if inputs["load_type"] == 'process_heat': + if inputs.get("load_type") == 'process_heat': expected_reference_name = 'industrial_reference_name' else: expected_reference_name = 'doe_reference_name' From 4aec4b14edcad61c5d32ed5990505c945886c072 Mon Sep 17 00:00:00 2001 From: wbecker Date: Fri, 19 Dec 2025 10:16:53 -0700 Subject: [PATCH 2/5] Refactor POST requests to /simulated_load for improved I/O and validation Optional load_type (default=electric) Optional year with doe_reference_name --- reoptjl/views.py | 54 +++++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/reoptjl/views.py b/reoptjl/views.py index b4340462e..cdde1041f 100644 --- a/reoptjl/views.py +++ b/reoptjl/views.py @@ -655,25 +655,22 @@ def simulated_load(request): if request.method == "POST": data = json.loads(request.body) - required_post_fields = ["load_type", "year"] either_required = ["normalize_and_scale_load_profile_input", "doe_reference_name"] - optional = ["percent_share"] either_check = 0 for either in either_required: - if data.get(either) is not None: + if either in data: inputs[either] = data[either] either_check += 1 if either_check == 0: return JsonResponse({"Error": "Missing either of normalize_and_scale_load_profile_input or doe_reference_name."}, status=400) elif either_check == 2: return JsonResponse({"Error": "Both normalize_and_scale_load_profile_input and doe_reference_name were input; only input one of these."}, status=400) - for field in required_post_fields: - # TODO make year optional for doe_reference_name input - inputs[field] = data[field] - for opt in optional: - if data.get(opt) is not None: - inputs[opt] = data[opt] - if data.get("normalize_and_scale_load_profile_input") is not None: + + # If normalize_and_scale_load_profile_input is true, year and load_profile are required + if data.get("normalize_and_scale_load_profile_input") is True: + if "year" not in data: + return JsonResponse({"Error": "year is required when normalize_and_scale_load_profile_input is true."}, status=400) + inputs["year"] = data["year"] if "load_profile" not in data: return JsonResponse({"Error": "load_profile is required when normalize_and_scale_load_profile_input is provided."}, status=400) inputs["load_profile"] = data["load_profile"] @@ -681,18 +678,37 @@ def simulated_load(request): if "time_steps_per_hour" not in data: return JsonResponse({"Error": "time_steps_per_hour is required when load_profile length is not 8760."}, status=400) inputs["time_steps_per_hour"] = data["time_steps_per_hour"] - if inputs["load_type"] == "electric": + + # If doe_reference_name is provided, latitude and longitude are required, year is optional + if "doe_reference_name" in data: + if "latitude" not in data or "longitude" not in data: + return JsonResponse({"Error": "latitude and longitude are required when doe_reference_name is provided."}, status=400) + inputs["latitude"] = float(data["latitude"]) + inputs["longitude"] = float(data["longitude"]) + # year is optional for doe_reference_name, as it will default to 2017 + if "year" in data: + inputs["year"] = data["year"] + if "percent_share" in data: + inputs["percent_share"] = data["percent_share"] + + # Optional load_type determines required energy input options (default is "electric") + load_type = data.get("load_type") + if load_type is None: + load_type = "electric" # default load_type for simulate_load() + else: + inputs["load_type"] = data["load_type"] + if load_type == "electric": for energy in ["annual_kwh", "monthly_totals_kwh", "monthly_peaks_kw"]: - if data.get(energy) is not None: - inputs[energy] = data.get(energy) - elif inputs["load_type"] in ["space_heating", "domestic_hot_water", "process_heat"]: + if energy in data: + inputs[energy] = data[energy] + elif load_type in ["space_heating", "domestic_hot_water", "process_heat"]: for energy in ["annual_mmbtu", "monthly_mmbtu"]: - if data.get(energy) is not None: - inputs[energy] = data.get(energy) - elif inputs["load_type"] == "cooling": + if energy in data: + inputs[energy] = data[energy] + elif load_type == "cooling": for energy in ["annual_tonhour", "monthly_tonhour"]: - if data.get(energy) is not None: - inputs[energy] = data.get(energy) + if energy in data: + inputs[energy] = data[energy] # json.dump(inputs, open("sim_load_post.json", "w")) julia_host = os.environ.get('JULIA_HOST', "julia") From b910a3d5f60e1730a67dd166f0d88a0c848f462e Mon Sep 17 00:00:00 2001 From: wbecker Date: Fri, 19 Dec 2025 10:17:26 -0700 Subject: [PATCH 3/5] Add tests for POST requests to /simulated_load --- reoptjl/test/test_http_endpoints.py | 122 ++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/reoptjl/test/test_http_endpoints.py b/reoptjl/test/test_http_endpoints.py index 7848e8839..165cb23a6 100644 --- a/reoptjl/test/test_http_endpoints.py +++ b/reoptjl/test/test_http_endpoints.py @@ -149,6 +149,128 @@ def test_simulated_load(self): v2_response = json.loads(resp.content) assert("Error" in v2_response.keys()) + def test_simulated_load_post(self): + + # Test 1: POST with normalize_and_scale_load_profile_input and load_profile + load_profile = [100.0] * 8760 # Simple 8760 hourly load profile + monthly_totals = [450000.0, 420000.0, 480000.0, 510000.0, 550000.0, 600000.0, + 620000.0, 610000.0, 570000.0, 520000.0, 470000.0, 440000.0] # 12 monthly totals in kWh to scale to + inputs = { + "normalize_and_scale_load_profile_input": True, + "load_profile": load_profile, + "year": 2021, + "monthly_totals_kwh": monthly_totals + } + resp = self.api_client.post(f'/stable/simulated_load', format='json', data=inputs) + self.assertHttpOK(resp) + post_response = json.loads(resp.content) + self.assertAlmostEqual(post_response["annual_kwh"], sum(monthly_totals), delta=10.0) + + # Test 2: POST with doe_reference_name and monthly_totals_kwh, monthly_peaks_kw + monthly_peaks = [900.0, 850.0, 950.0, 1000.0, 1100.0, 1200.0, + 1300.0, 1250.0, 1150.0, 1050.0, 950.0, 900.0] # 12 monthly peaks in kW (+/- 30%) + inputs = { + "doe_reference_name": "Hospital", + "latitude": 36.12, + "longitude": -115.5, + "load_type": "electric", + "monthly_totals_kwh": monthly_totals, + "monthly_peaks_kw": monthly_peaks, + "year": 2021 + } + resp = self.api_client.post(f'/stable/simulated_load', format='json', data=inputs) + self.assertHttpOK(resp) + post_response = json.loads(resp.content) + self.assertIn("loads_kw", post_response.keys()) + self.assertEqual(len(post_response["loads_kw"]), 8760) + self.assertIn("annual_kwh", post_response.keys()) + + # Test 3: POST with doe_reference_name only (no monthly data and no load_type, defaults to electric) + # and check consistency with GET request + inputs = { + "doe_reference_name": "LargeOffice", + "latitude": 40.7128, + "longitude": -74.0060, + "annual_kwh": 1000000.0 + } + resp = self.api_client.post(f'/stable/simulated_load', format='json', data=inputs) + self.assertHttpOK(resp) + post_response = json.loads(resp.content) + self.assertAlmostEqual(post_response["annual_kwh"], 1000000.0, delta=1.0) + + get_resp = self.api_client.get(f'/stable/simulated_load', data=inputs) + self.assertHttpOK(get_resp) + get_response = json.loads(get_resp.content) + self.assertEqual(post_response["loads_kw"][:3], get_response["loads_kw"][:3]) + + # Test 4: POST with blended/hybrid buildings using arrays for doe_reference_name and percent_share + inputs = { + "doe_reference_name": ["LargeOffice", "FlatLoad"], + "percent_share": [0.60, 0.40], + "latitude": 36.12, + "longitude": -115.5, + "load_type": "electric", + "annual_kwh": 1.5e7, + "year": 2021 + } + resp = self.api_client.post(f'/stable/simulated_load', format='json', data=inputs) + self.assertHttpOK(resp) + post_response = json.loads(resp.content) + self.assertIn("loads_kw", post_response.keys()) + self.assertEqual(len(post_response["loads_kw"]), 8760) + self.assertAlmostEqual(post_response["annual_kwh"], 1.5e7, delta=1.0) + + # Test 5: Validation - Missing both normalize_and_scale_load_profile_input and doe_reference_name + inputs = { + "latitude": 36.12, + "longitude": -115.5, + "year": 2021, + "monthly_totals_kwh": monthly_totals + } + resp = self.api_client.post(f'/stable/simulated_load', format='json', data=inputs) + self.assertHttpBadRequest(resp) + post_response = json.loads(resp.content) + self.assertIn("Error", post_response.keys()) + self.assertIn("Missing either of", post_response["Error"]) + + # Test 6: Validation - normalize_and_scale_load_profile_input without year + inputs = { + "normalize_and_scale_load_profile_input": True, + "load_profile": load_profile + } + resp = self.api_client.post(f'/stable/simulated_load', format='json', data=inputs) + self.assertHttpBadRequest(resp) + post_response = json.loads(resp.content) + self.assertIn("Error", post_response.keys()) + self.assertIn("year is required", post_response["Error"]) + + # Test 7: Validation - doe_reference_name without latitude + inputs = { + "doe_reference_name": "Hospital", + "longitude": -115.5, + "year": 2021 + } + resp = self.api_client.post(f'/stable/simulated_load', format='json', data=inputs) + self.assertHttpBadRequest(resp) + post_response = json.loads(resp.content) + self.assertIn("Error", post_response.keys()) + self.assertIn("latitude and longitude are required", post_response["Error"]) + + # Test 8: POST with non-8760 load_profile and time_steps_per_hour + load_profile_15min = [100.0] * 35040 # 8760 * 4 for 15-minute intervals + inputs = { + "normalize_and_scale_load_profile_input": True, + "load_profile": load_profile_15min, + "monthly_totals_kwh": monthly_totals, + "year": 2021, + "time_steps_per_hour": 4 + } + resp = self.api_client.post(f'/stable/simulated_load', format='json', data=inputs) + self.assertHttpOK(resp) + post_response = json.loads(resp.content) + self.assertAlmostEqual(post_response["annual_kwh"], sum(monthly_totals), delta=10.0) + + def test_avert_emissions_profile_endpoint(self): # Call to the django view endpoint dev/avert_emissions_profile which calls the http.jl endpoint #case 1: location in CONUS (Seattle, WA) From 12530c53ac5986fa58876949a44332720a8214fb Mon Sep 17 00:00:00 2001 From: wbecker Date: Fri, 19 Dec 2025 10:28:11 -0700 Subject: [PATCH 4/5] Update CHANGELOG.md with updates to /simulated_load --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af7c02ee0..3b357f9bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,17 @@ Classify the change according to the following categories: ##### Removed ### Patches +## v3.17.5 +### Minor Updates +##### Added +- Tests in `test_http_endpoints.py` to test expected `/simulated_load` POST request functionality +##### Changed +- Makes `load_type` optional for both GET and POST requests to `/simulated_load`, with a default `load_type=electric`, consistent with the `simulated_load()` function in REopt.jl +- Makes the `year` input not required for inputs with `doe_reference_name` for POST request to `/simulated_load`, consistent with GET request and `simulated_load()` function in REopt.jl +##### Fixed +- POST requests to `/simulated_load` with `doe_reference_name` input to pass along the lat/long inputs (was not passing those inputs through) + + ## v3.17.4 ### Minor Updates ##### Fixed From 6c2c58be95fea8de02d755e9e77fb9687fed745f Mon Sep 17 00:00:00 2001 From: wbecker Date: Wed, 24 Dec 2025 10:00:38 -0700 Subject: [PATCH 5/5] Add industrial_reference_name as valid input option for POST --- reoptjl/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/reoptjl/views.py b/reoptjl/views.py index cdde1041f..8255ece1b 100644 --- a/reoptjl/views.py +++ b/reoptjl/views.py @@ -655,16 +655,16 @@ def simulated_load(request): if request.method == "POST": data = json.loads(request.body) - either_required = ["normalize_and_scale_load_profile_input", "doe_reference_name"] + either_required = ["normalize_and_scale_load_profile_input", "doe_reference_name", "industrial_reference_name"] either_check = 0 for either in either_required: if either in data: inputs[either] = data[either] either_check += 1 if either_check == 0: - return JsonResponse({"Error": "Missing either of normalize_and_scale_load_profile_input or doe_reference_name."}, status=400) + return JsonResponse({"Error": "Missing either of normalize_and_scale_load_profile_input or [doe or industrial]_reference_name."}, status=400) elif either_check == 2: - return JsonResponse({"Error": "Both normalize_and_scale_load_profile_input and doe_reference_name were input; only input one of these."}, status=400) + return JsonResponse({"Error": "Both normalize_and_scale_load_profile_input and [doe or industrial]_reference_name were input; only input one of these."}, status=400) # If normalize_and_scale_load_profile_input is true, year and load_profile are required if data.get("normalize_and_scale_load_profile_input") is True: @@ -680,7 +680,7 @@ def simulated_load(request): inputs["time_steps_per_hour"] = data["time_steps_per_hour"] # If doe_reference_name is provided, latitude and longitude are required, year is optional - if "doe_reference_name" in data: + if "doe_reference_name" in data or "industrial_reference_name" in data: if "latitude" not in data or "longitude" not in data: return JsonResponse({"Error": "latitude and longitude are required when doe_reference_name is provided."}, status=400) inputs["latitude"] = float(data["latitude"])