diff --git a/CHANGELOG.md b/CHANGELOG.md index 7172e75..c81669f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `PVSystem.max_rotation_angle` — dedicated field for the maximum tracker rotation angle in degrees (default 60°). Only applies when `mounting='Tracker'`. Replaces the previous (confusing) use of `PVSystem.tilt` for this purpose. +- Pydantic models for the 3D API data model: `Rack`, `Racks`, `Tracker` (3D), `Trackers`, `InverterInput`, `ModuleString`, `SimpleTerrain`, `ShadingObjects`, and supporting geometry types (`QuadDouble`, `Vector3Double`, `IndexedObject3D`, `ModuleIndexRange`, `MiniSimpleTerrainDto`, `TerrainRowDto`, `TerrainRowStartEndColumnsDto`). These enable full composition of 3D plant layouts, including power optimizer support via `InverterInput`. +- `PVSystem.recalculate_modeling_correction_factor` field to control whether the modeling correction factor is recalculated during energy yield assessment (default `True`). +- `from_solcast` added to the Weather Utilities section of the API reference documentation. + +### Changed + +- `PVSystem.tilt` is now fixed-tilt only. For tracker systems, set `max_rotation_angle` instead. Previously, `tilt` was silently used as the tracker rotation bound, which caused confusion. + ## [0.5.0] - 2026-06-29 ### Changed diff --git a/solarfarmer/models/pvsystem/plant_defaults.py b/solarfarmer/models/pvsystem/plant_defaults.py index 83a7867..39b147f 100644 --- a/solarfarmer/models/pvsystem/plant_defaults.py +++ b/solarfarmer/models/pvsystem/plant_defaults.py @@ -89,3 +89,4 @@ class OrientationType(str, Enum): 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 6306ca7..94db26b 100644 --- a/solarfarmer/models/pvsystem/pvsystem.py +++ b/solarfarmer/models/pvsystem/pvsystem.py @@ -34,6 +34,7 @@ HEIGHT_FROM_GROUND_FIXED, HEIGHT_FROM_GROUND_TRACKER, IS_BACKTRACKING, + MAX_ROTATION_ANGLE, MODULE_MISMATCH_FACTOR, NUMBER_OF_MODULES_HIGH, OHMIC_DICT, @@ -109,8 +110,13 @@ class PVSystem: pitch : float Array pitch in meters (default 10.0). Define either gcr or pitch, not both. tilt : float - Array tilt in degrees. For fixed-tilt mounting, the default is the latitude rounded to degrees. - For trackers, this is the used as the maximum rotation angle (60 degrees if not specified). + Array tilt in degrees. Applicable for fixed-tilt mounting only. + The default is the latitude rounded to degrees. + For tracker systems, use ``max_rotation_angle`` instead. + max_rotation_angle : float + Maximum tracker rotation angle in degrees (default is 60°). + Only applicable when ``mounting='Tracker'``. + Defines the symmetric rotation bounds: ``[-max_rotation_angle, +max_rotation_angle]``. azimuth : float Array azimuth in degrees (default 180, i.e., south-facing). mounting: str @@ -253,7 +259,10 @@ class PVSystem: grid_limit_MW: float = 10.0 gcr: float = 0.5 pitch: float | None = None # If None, calculated from gcr and module dimensions - tilt: float | None = None # Default is latitude rounded to degrees + tilt: float | None = None # For fixed-tilt only; default is latitude rounded to degrees + max_rotation_angle: float | None = ( + None # For trackers only; default is MAX_ROTATION_ANGLE (60°) + ) azimuth: float = 180.0 mounting: MountingType = MountingType.FIXED flush_mount: bool = False @@ -787,7 +796,15 @@ def describe(self, verbose=False) -> None: print("\n--- ARRAY CONFIGURATION ---") print(f"GCR (Ground Coverage Ratio): {self.gcr}") print(f"Pitch: {self.pitch} m") - print(f"Tilt: {self.tilt}°") + if self.mounting == MountingType.TRACKER: + effective_max_rotation = ( + self.max_rotation_angle + if self.max_rotation_angle is not None + else MAX_ROTATION_ANGLE + ) + print(f"Max Rotation Angle (Tracker): {effective_max_rotation}°") + else: + print(f"Tilt: {self.tilt}°") print(f"Azimuth: {self.azimuth}°") # Mounting & Orientation @@ -1499,13 +1516,16 @@ def generate_tracker_systems( is_tracker = plant.mounting == MountingType.TRACKER if is_tracker: + max_rotation = ( + plant.max_rotation_angle if plant.max_rotation_angle is not None else MAX_ROTATION_ANGLE + ) tracker_system = TrackerSystem( system_plane_azimuth=0.0, system_plane_tilt=0.0, tracker_azimuth=plant.azimuth, east_west_gcr=plant.gcr, - rotation_min_deg=-plant.tilt if plant.tilt is not None else -60.0, - rotation_max_deg=plant.tilt if plant.tilt is not None else 60.0, + rotation_min_deg=-max_rotation, + rotation_max_deg=max_rotation, is_backtracking=IS_BACKTRACKING, use_slope_aware_backtracking=False, ) diff --git a/tests/test_pvsystem.py b/tests/test_pvsystem.py index c8c8060..f7f1230 100644 --- a/tests/test_pvsystem.py +++ b/tests/test_pvsystem.py @@ -238,11 +238,12 @@ def test_pvsystem_fixed_tilt_vs_tracker(self): latitude=45.0, longitude=10.0, mounting="Tracker", - tilt=60.0, # Max rotation angle for trackers + max_rotation_angle=55.0, ) assert fixed_plant.mounting == "Fixed" assert tracker_plant.mounting == "Tracker" + assert tracker_plant.max_rotation_angle == 55.0 def test_pvsystem_small_vs_large_plant_payload_capacity(self, bern_2d_racks_inputs): """A larger dc_capacity_MW results in a larger acCapacityW in the serialized payload.""" @@ -290,6 +291,37 @@ def test_pvsystem_hemisphere_latitude_in_payload(self, bern_2d_racks_inputs): payload = json.loads(construct_plant(plant)) assert payload["location"]["latitude"] == lat + def test_max_rotation_angle_maps_to_tracker_payload(self, bern_2d_racks_inputs): + """max_rotation_angle is correctly mapped to rotation bounds in the tracker payload.""" + angle = 45.0 + plant = PVSystem( + latitude=45.0, longitude=10.0, mounting="Tracker", max_rotation_angle=angle + ) + 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["rotationMaxDeg"] == angle + assert tracker_system["rotationMinDeg"] == -angle + + def test_max_rotation_angle_default_maps_to_60_degrees(self, bern_2d_racks_inputs): + """When max_rotation_angle is not set, the tracker payload defaults to ±60°.""" + plant = PVSystem(latitude=45.0, longitude=10.0, mounting="Tracker") + 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["rotationMaxDeg"] == 60.0 + assert tracker_system["rotationMinDeg"] == -60.0 + class TestPVSystemPropertyDefaults: """Test cases for verifying property defaults."""