Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion julia_src/http.jl
Original file line number Diff line number Diff line change
Expand Up @@ -821,7 +821,7 @@ HTTP.register!(ROUTER, "GET", "/avert_emissions_profile", avert_emissions_profil
HTTP.register!(ROUTER, "GET", "/cambium_profile", cambium_profile)
HTTP.register!(ROUTER, "GET", "/easiur_costs", easiur_costs)
HTTP.register!(ROUTER, "GET", "/sector_defaults", sector_defaults)
HTTP.register!(ROUTER, "GET", "/simulated_load", simulated_load)
HTTP.register!(ROUTER, "POST", "/simulated_load", simulated_load)
HTTP.register!(ROUTER, "GET", "/absorption_chiller_defaults", absorption_chiller_defaults)
HTTP.register!(ROUTER, "GET", "/ghp_efficiency_thermal_factors", ghp_efficiency_thermal_factors)
HTTP.register!(ROUTER, "GET", "/ground_conductivity", ground_conductivity)
Expand Down
122 changes: 122 additions & 0 deletions reoptjl/test/test_http_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Comment thread
Bill-Becker marked this conversation as resolved.
Comment thread
Bill-Becker marked this conversation as resolved.

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)
Expand Down
62 changes: 25 additions & 37 deletions reoptjl/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -651,51 +652,38 @@ def simulated_load(request):
return JsonResponse({"Error. Monthly data does not contain 12 valid entries"})
elif request.GET.get(key) is not None:
inputs[key] = float(request.GET.get(key))

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"]
elif request.method == "POST":
inputs = json.loads(request.body)
either_required = ["normalize_and_scale_load_profile_input", "doe_reference_name", "industrial_reference_name"]
either_check = 0
for either in either_required:
if data.get(either) is not None:
inputs[either] = data[either]
if either in inputs:
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)
Comment thread
Bill-Becker marked this conversation as resolved.
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 "load_profile" not in data:
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)
Comment thread
Bill-Becker marked this conversation as resolved.
Comment thread
Bill-Becker marked this conversation as resolved.

# If normalize_and_scale_load_profile_input is true, year and load_profile are required
if inputs.get("normalize_and_scale_load_profile_input") is True:
if "year" not in inputs:
return JsonResponse({"Error": "year is required when normalize_and_scale_load_profile_input is true."}, status=400)
if "load_profile" not in inputs:
return JsonResponse({"Error": "load_profile is required when normalize_and_scale_load_profile_input is provided."}, status=400)
inputs["load_profile"] = data["load_profile"]
if len(inputs["load_profile"]) != 8760:
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":
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"]:
for energy in ["annual_mmbtu", "monthly_mmbtu"]:
if data.get(energy) is not None:
inputs[energy] = data.get(energy)
elif inputs["load_type"] == "cooling":
for energy in ["annual_tonhour", "monthly_tonhour"]:
if data.get(energy) is not None:
inputs[energy] = data.get(energy)
if "time_steps_per_hour" not in inputs:
return JsonResponse({"Error": "time_steps_per_hour is required when load_profile length is not 8760 (hourly)."}, status=400)

# If doe_reference_name is provided, latitude and longitude are required, year is optional
if "doe_reference_name" in inputs or "industrial_reference_name" in inputs:
if "latitude" not in inputs or "longitude" not in inputs:
return JsonResponse({"Error": "latitude and longitude are required when [doe or industrial]_reference_name is provided."}, status=400)
else:
return JsonResponse({"Error": "Only GET and POST methods are supported for this endpoint"}, status=405)

# json.dump(inputs, open("sim_load_post.json", "w"))
julia_host = os.environ.get('JULIA_HOST', "julia")
http_jl_response = requests.get("http://" + julia_host + ":8081/simulated_load/", json=inputs)
http_jl_response = requests.post("http://" + julia_host + ":8081/simulated_load/", json=inputs)
response_data = http_jl_response.json()
# Round all scalar/monthly outputs to 2 decimal places
load_profile_key = next((k for k in response_data if "loads_" in k), None)
Expand Down