Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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)


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
71 changes: 46 additions & 25 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 @@ -654,44 +655,64 @@ 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_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:
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)
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:
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:
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"]
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":
inputs["time_steps_per_hour"] = int(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 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 or industrial]_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"] = int(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]
if "cooling_doe_ref_name" in data:
inputs["cooling_doe_ref_name"] = data["cooling_doe_ref_name"]
if "cooling_pct_share" in data:
inputs["cooling_pct_share"] = data["cooling_pct_share"]
elif load_type in ["heating", "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")
Expand Down