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 docs/getting-started/workflow-2-pvplant-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ plant_loaded.run_energy_calculation(project_id="modified_design", api_key=api_ke
| `gcr` | Ground coverage ratio (area occupied / total area) | 0.4 = 40% |
| `tilt` | Array angle from horizontal (for fixed-tilt mounting only) | 25° |
| `tracker_max_rotation_angle` | Maximum rotation angle for tracker systems | 60° |
| `tracking_algorithm` | Tracker rotation algorithm: `StandardBacktracking`, `SlopeAwareBacktracking` or `SunTracking` (case-insensitive) | `StandardBacktracking` |
| `azimuth` | Direction array faces (0=N, 90=E, 180=S, 270=W) | 180 = South |
| `mounting` | Fixed-tilt or single-axis tracker systems | "Fixed" or "Tracker" |
| `inverter_type` | Central (single large) or String (multiple small) | "Central" |
Expand Down
1 change: 0 additions & 1 deletion solarfarmer/models/pvsystem/plant_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,4 @@ class OrientationType(_CaseInsensitiveStrEnum):
TERRAIN_AZIMUTH = 0.0
TERRAIN_SLOPE = 0.0
FRAME_BOTTOM_WIDTH = 0.0
IS_BACKTRACKING = True
MAX_ROTATION_ANGLE = 60.0 # Default maximum tracker rotation angle in degrees
36 changes: 33 additions & 3 deletions solarfarmer/models/pvsystem/pvsystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from ..energy_calculation_results import CalculationResults
from ..enums import (
DiffuseModel,
TrackerAlgorithm,
TransformerLossModelTypes,
)
from ..inverter import Inverter
Expand All @@ -35,7 +36,6 @@
FRAME_BOTTOM_WIDTH,
HEIGHT_FROM_GROUND_FIXED,
HEIGHT_FROM_GROUND_TRACKER,
IS_BACKTRACKING,
MAX_ROTATION_ANGLE,
MODULE_MISMATCH_FACTOR,
NUMBER_OF_MODULES_HIGH,
Expand Down Expand Up @@ -72,6 +72,29 @@
"inverter_type": InverterType,
}

# CustomRotations is excluded: PVSystem has no way to supply a rotation table.
_PVSYSTEM_TRACKING_ALGORITHMS: tuple[TrackerAlgorithm, ...] = (
TrackerAlgorithm.SLOPE_AWARE_BACKTRACKING,
TrackerAlgorithm.STANDARD_BACKTRACKING,
TrackerAlgorithm.SUN_TRACKING,
)


def _coerce_tracking_algorithm(value: Any) -> TrackerAlgorithm:
"""Convert ``value`` (case-insensitive string or enum) to a supported TrackerAlgorithm."""
if value is None:
return TrackerAlgorithm.STANDARD_BACKTRACKING
if isinstance(value, str):
folded = value.strip().casefold()
for member in _PVSYSTEM_TRACKING_ALGORITHMS:
if member.value.casefold() == folded:
return member
raise ValueError(
f"{value!r} is not a valid TrackerAlgorithm for 'tracking_algorithm'. "
f"Must be one of: {', '.join(m.value for m in _PVSYSTEM_TRACKING_ALGORITHMS)} "
"(case-insensitive)."
)


def _default_derived_values(plant: PVSystem) -> dict[str, float]:
"""Defaults for the derived inputs, computed from the plant's current inputs."""
Expand Down Expand Up @@ -143,6 +166,10 @@ class PVSystem:
Maximum tracker rotation angle in degrees (default is 60°).
Only applicable when ``mounting='Tracker'``.
Defines the symmetric rotation bounds: ``[-tracker_max_rotation_angle, +tracker_max_rotation_angle]``.
tracking_algorithm : TrackerAlgorithm or str
Tracker rotation algorithm (case-insensitive). One of ``'StandardBacktracking'``
(default), ``'SlopeAwareBacktracking'`` or ``'SunTracking'``.
Only applicable when ``mounting='Tracker'``.
azimuth : float
Array azimuth in degrees (default 180, i.e., south-facing).
terrain_azimuth : float
Expand Down Expand Up @@ -310,6 +337,7 @@ class PVSystem:
tracker_max_rotation_angle: float | None = (
None # For trackers only; default is MAX_ROTATION_ANGLE (60°)
)
tracking_algorithm: TrackerAlgorithm | str | None = TrackerAlgorithm.STANDARD_BACKTRACKING
azimuth: float = 180.0
mounting: MountingType | str = MountingType.FIXED
flush_mount: bool = False
Expand Down Expand Up @@ -650,6 +678,8 @@ def ond_file_map(self) -> dict[str, Path]:
return dict(self._ond_files)

def __setattr__(self, name: str, value: Any) -> None:
if name == "tracking_algorithm":
value = _coerce_tracking_algorithm(value)
enum_cls = _ENUM_FIELDS.get(name)
if enum_cls is not None and not isinstance(value, enum_cls):
# inverter_type=None means "use the default".
Expand Down Expand Up @@ -833,6 +863,7 @@ def describe(self, verbose=False) -> None:
else MAX_ROTATION_ANGLE
)
print(f"Max Rotation Angle (Tracker): {effective_max_rotation}°")
print(f"Tracking Algorithm: {self.tracking_algorithm}")
else:
print(f"Tilt: {self.tilt}°")
print(f"Azimuth: {self.azimuth}°")
Expand Down Expand Up @@ -1854,8 +1885,7 @@ def generate_tracker_systems(
east_west_gcr=plant.gcr,
rotation_min_deg=-max_rotation,
rotation_max_deg=max_rotation,
is_backtracking=IS_BACKTRACKING,
use_slope_aware_backtracking=False,
tracker_algorithm=plant.tracking_algorithm,
)
key = tracker_system_id if tracker_system_id is not None else "Layout Region 1"
return {key: tracker_system}
Expand Down
47 changes: 47 additions & 0 deletions tests/test_pvsystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,53 @@ def test_tracker_max_rotation_angle_default_maps_to_60_degrees(self, bern_2d_rac
assert tracker_system["rotationMaxDeg"] == 60.0
assert tracker_system["rotationMinDeg"] == -60.0

def test_tracking_algorithm_defaults_to_standard_backtracking(self):
assert PVSystem().tracking_algorithm == "StandardBacktracking"
assert PVSystem(tracking_algorithm=None).tracking_algorithm == "StandardBacktracking"

@pytest.mark.parametrize(
"value, expected",
[
("slopeawarebacktracking", "SlopeAwareBacktracking"),
("STANDARDBACKTRACKING", "StandardBacktracking"),
(" SunTracking ", "SunTracking"),
],
)
def test_tracking_algorithm_is_case_insensitive(self, value, expected):
plant = PVSystem(tracking_algorithm=value)
assert plant.tracking_algorithm == expected
plant.tracking_algorithm = value.lower()
assert plant.tracking_algorithm == expected

@pytest.mark.parametrize("value", ["CustomRotations", "Backtracking", 1])
def test_tracking_algorithm_rejects_invalid_values(self, value):
with pytest.raises(ValueError, match="tracking_algorithm"):
PVSystem(tracking_algorithm=value)

@pytest.mark.parametrize(
"value", [None, "SlopeAwareBacktracking", "StandardBacktracking", "SunTracking"]
)
def test_tracking_algorithm_maps_to_tracker_payload(self, bern_2d_racks_inputs, value):
plant = PVSystem(
latitude=45.0, longitude=10.0, mounting="Tracker", tracking_algorithm=value
)
plant.pan_files = {
"CanadianSolar_CS6U-330M_APP": f"{bern_2d_racks_inputs}/CanadianSolar_CS6U-330M_APP.PAN"
}
plant.ond_files = {"Sungrow_SG125HV_APP": f"{bern_2d_racks_inputs}/Sungrow_SG125HV_APP.OND"}

payload = json.loads(construct_plant(plant))
tracker_system = next(iter(payload["pvPlant"]["trackerSystems"].values()))

assert tracker_system["trackerAlgorithm"] == (value or "StandardBacktracking")
assert "isBacktracking" not in tracker_system
assert "useSlopeAwareBacktracking" not in tracker_system

def test_tracking_algorithm_survives_file_roundtrip(self, tmp_path):
path = tmp_path / "plant.json"
PVSystem(tracking_algorithm="suntracking").to_file(path)
assert PVSystem.from_file(path).tracking_algorithm == "SunTracking"


class TestPVSystemPropertyDefaults:
"""Test cases for verifying property defaults."""
Expand Down
Loading