From c9a2246d01656dc79251006c51ffa08ae2064362 Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Tue, 27 Oct 2026 14:47:28 +0100 Subject: [PATCH 1/3] Add tracking_algorithm to PVSystem --- solarfarmer/models/pvsystem/plant_defaults.py | 1 - solarfarmer/models/pvsystem/pvsystem.py | 36 +++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/solarfarmer/models/pvsystem/plant_defaults.py b/solarfarmer/models/pvsystem/plant_defaults.py index 16e56fa..08f51cf 100644 --- a/solarfarmer/models/pvsystem/plant_defaults.py +++ b/solarfarmer/models/pvsystem/plant_defaults.py @@ -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 diff --git a/solarfarmer/models/pvsystem/pvsystem.py b/solarfarmer/models/pvsystem/pvsystem.py index 7465b7f..a72e51d 100644 --- a/solarfarmer/models/pvsystem/pvsystem.py +++ b/solarfarmer/models/pvsystem/pvsystem.py @@ -16,6 +16,7 @@ from ..energy_calculation_results import CalculationResults from ..enums import ( DiffuseModel, + TrackerAlgorithm, TransformerLossModelTypes, ) from ..inverter import Inverter @@ -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, @@ -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.""" @@ -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 @@ -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 @@ -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". @@ -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}°") @@ -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} From f3013822b7ad36736c91aec235b28f8c14a07b47 Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Tue, 27 Oct 2026 14:47:36 +0100 Subject: [PATCH 2/3] Add unit tests --- tests/test_pvsystem.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_pvsystem.py b/tests/test_pvsystem.py index bde7af2..b330ad9 100644 --- a/tests/test_pvsystem.py +++ b/tests/test_pvsystem.py @@ -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.""" From a3263a7c9c4a0bb67ab08d2649cb4ea1640375da Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Tue, 27 Oct 2026 14:47:47 +0100 Subject: [PATCH 3/3] Update docs for PVsystem workflow --- docs/getting-started/workflow-2-pvplant-builder.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/getting-started/workflow-2-pvplant-builder.md b/docs/getting-started/workflow-2-pvplant-builder.md index d25d72d..0fc390a 100644 --- a/docs/getting-started/workflow-2-pvplant-builder.md +++ b/docs/getting-started/workflow-2-pvplant-builder.md @@ -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" |