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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions solarfarmer/models/pvsystem/plant_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 26 additions & 6 deletions solarfarmer/models/pvsystem/pvsystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
34 changes: 33 additions & 1 deletion tests/test_pvsystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
Loading