Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
- Removed a few usages of `shape_by_conn` due to issues with OpenMDAO v3.43.0 release on some computers [PR 632](https://github.com/NatLabRockies/H2Integrate/pull/632)
- Made generating an XDSM diagram from connections in a model optional and added documentation on model visualization. [PR 629](https://github.com/NatLabRockies/H2Integrate/pull/629)
- Added a storage performance baseclass model `StoragePerformanceBase` and updated the other storage performance models to inherit it [PR 624](https://github.com/NatLabRockies/H2Integrate/pull/624)
- Modified the calc tilt angle function for pysam solar to support latitudes in the southern hemisphere [PR 646](https://github.com/NatLabRockies/H2Integrate/pull/646)

## 0.7.1 [March 13, 2026]

Expand Down
16 changes: 10 additions & 6 deletions h2integrate/converters/solar/solar_pysam.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,20 +206,24 @@ def calc_tilt_angle(self, latitude):
# Return user-specified tilt
return self.design_config.tilt

# Use absolute value of latitude for tilt calculations
# to support southern hemisphere (negative) latitudes
abs_latitude = abs(latitude)

# If tilt angle function is 'lat', use the latitude as the tilt
if self.design_config.tilt_angle_func == "lat":
return latitude
return abs_latitude

# If tilt angle function is 'lat-func', use empirical formulas based on latitude
if self.design_config.tilt_angle_func == "lat-func":
if latitude <= 25:
if abs_latitude <= 25:
# For latitudes <= 25, use 0.87 * latitude
return latitude * 0.87
if 25 < latitude <= 50:
return abs_latitude * 0.87
if 25 < abs_latitude <= 50:
# For latitudes between 25 and 50, use 0.76 * latitude + 3.1
return (latitude * 0.76) + 3.1
return (abs_latitude * 0.76) + 3.1
# For latitudes > 50, use latitude directly
return latitude
return abs_latitude

def format_resource_data(self, solar_resource_data):
"""Format solar resource data into the format required for the
Expand Down
102 changes: 102 additions & 0 deletions h2integrate/converters/solar/test/test_pysam_solar.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from unittest.mock import MagicMock

import numpy as np
import pytest
import openmdao.api as om
Expand All @@ -7,6 +9,106 @@
from h2integrate.resource.solar.nlr_developer_goes_api_models import GOESAggregatedSolarAPI


@pytest.mark.unit
class TestCalcTiltAngle:
"""Unit tests for PYSAMSolarPlantPerformanceModel.calc_tilt_angle
with various latitudes including southern hemisphere (negative) values.
"""

def _make_model(self, tilt_angle_func, tilt=None, create_model_from="default"):
"""Create a lightweight mock of PYSAMSolarPlantPerformanceModel
with the minimum attributes needed by calc_tilt_angle."""
model = MagicMock(spec=PYSAMSolarPlantPerformanceModel)
model.design_config = MagicMock()
model.design_config.tilt_angle_func = tilt_angle_func
model.design_config.tilt = tilt
model.design_config.create_model_from = create_model_from
model.design_config.pysam_options = {}
model.system_model = MagicMock()
model.system_model.value.return_value = 20.0 # default tilt from PySAM model
return model

# --- tilt_angle_func = "lat" ---
@pytest.mark.parametrize(
"latitude, expected_tilt",
[
(30.0, 30.0),
(-30.0, 30.0),
(0.0, 0.0),
(45.0, 45.0),
(-45.0, 45.0),
(90.0, 90.0),
(-90.0, 90.0),
],
)
def test_lat_mode(self, latitude, expected_tilt):
model = self._make_model(tilt_angle_func="lat")
result = PYSAMSolarPlantPerformanceModel.calc_tilt_angle(model, latitude)
assert result == pytest.approx(expected_tilt)

# --- tilt_angle_func = "lat-func" ---
@pytest.mark.parametrize(
"latitude, expected_tilt",
[
# |lat| <= 25: tilt = 0.87 * |lat|
(10.0, 10.0 * 0.87),
(-10.0, 10.0 * 0.87),
(25.0, 25.0 * 0.87),
(-25.0, 25.0 * 0.87),
(0.0, 0.0),
# 25 < |lat| <= 50: tilt = 0.76 * |lat| + 3.1
(30.0, 30.0 * 0.76 + 3.1),
(-30.0, 30.0 * 0.76 + 3.1),
(50.0, 50.0 * 0.76 + 3.1),
(-50.0, 50.0 * 0.76 + 3.1),
# |lat| > 50: tilt = |lat|
(60.0, 60.0),
(-60.0, 60.0),
(80.0, 80.0),
(-80.0, 80.0),
],
)
def test_lat_func_mode(self, latitude, expected_tilt):
model = self._make_model(tilt_angle_func="lat-func")
result = PYSAMSolarPlantPerformanceModel.calc_tilt_angle(model, latitude)
assert result == pytest.approx(expected_tilt)

def test_lat_func_symmetric(self):
"""Verify that positive and negative latitudes produce identical tilt angles."""
model = self._make_model(tilt_angle_func="lat-func")
for lat in [5, 15, 25, 30, 40, 50, 55, 70, 85]:
pos = PYSAMSolarPlantPerformanceModel.calc_tilt_angle(model, lat)
neg = PYSAMSolarPlantPerformanceModel.calc_tilt_angle(model, -lat)
assert pos == pytest.approx(neg), f"Mismatch at latitude {lat}: {pos} != {neg}"

# --- tilt_angle_func = "none" ---
def test_none_mode_default_with_user_tilt(self):
model = self._make_model(tilt_angle_func="none", tilt=15.0, create_model_from="default")
result = PYSAMSolarPlantPerformanceModel.calc_tilt_angle(model, -33.0)
assert result == pytest.approx(15.0)

def test_none_mode_default_without_user_tilt(self):
model = self._make_model(tilt_angle_func="none", tilt=None, create_model_from="default")
result = PYSAMSolarPlantPerformanceModel.calc_tilt_angle(model, -33.0)
assert result == pytest.approx(20.0) # from system_model.value("tilt")

def test_none_mode_new_with_user_tilt(self):
model = self._make_model(tilt_angle_func="none", tilt=10.0, create_model_from="new")
result = PYSAMSolarPlantPerformanceModel.calc_tilt_angle(model, -33.0)
assert result == pytest.approx(10.0)

def test_none_mode_new_without_user_tilt(self):
model = self._make_model(tilt_angle_func="none", tilt=None, create_model_from="new")
model.design_config.pysam_options = {"SystemDesign": {"tilt": 22.0}}
result = PYSAMSolarPlantPerformanceModel.calc_tilt_angle(model, -33.0)
assert result == pytest.approx(22.0)

def test_none_mode_new_no_tilt_anywhere(self):
model = self._make_model(tilt_angle_func="none", tilt=None, create_model_from="new")
result = PYSAMSolarPlantPerformanceModel.calc_tilt_angle(model, -33.0)
assert result == pytest.approx(0) # default fallback


@fixture
def basic_pysam_options():
pysam_options = {
Expand Down
Loading