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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `PVSystem` enum inputs (`mounting`, `module_orientation`, `inverter_type`) accept case-insensitive strings,
at construction and on assignment.
- `PVSystem.with_defaults_applied()` returns a copy with all unset derived inputs resolved.
- `PVSystem.terrain_azimuth` and `PVSystem.terrain_slope` set the terrain orientation of all layouts
(default `0.0`). They are ignored for flush-mount fixed-tilt systems.

### Changed

Expand All @@ -21,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `PVSystem.payload` is now an output-only snapshot (JSON string) and is no longer read back; it is omitted
from `to_file()` output.
- Building a payload no longer overwrites the plant's `dc_capacity_MW`, `ac_capacity_MW` or `gcr`.
- `Layout.terrain_azimuth` is now validated to [-360, 360] and `Layout.terrain_slope` to [-90, 90].

### Fixed

Expand Down
4 changes: 4 additions & 0 deletions docs/getting-started/workflow-2-pvplant-builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ plant.azimuth = 180.0 # South-facing (0=North, 90=East, 180=South, 270
plant.gcr = 0.4 # Ground coverage ratio (spacing between rows)
plant.flush_mount = False # Flush-mounted or rack-mounted

# Terrain (optional; ignored for flush-mounted fixed-tilt systems)
plant.terrain_azimuth = 0.0 # Direction the ground slopes down, degrees clockwise from North
plant.terrain_slope = 0.0 # Slope of the ground in degrees (0 = flat)

# Module orientation
plant.module_orientation = "Portrait" # or "Landscape" (sf.OrientationType)
plant.modules_across = 1 # Number of modules in height direction
Expand Down
9 changes: 5 additions & 4 deletions solarfarmer/models/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ class Layout(SolarFarmerBaseModel):
number_of_strings_in_isolated_row : int
Number of strings with no near-shading neighbors
terrain_azimuth : float
Terrain slope azimuth in degrees
Terrain slope azimuth in degrees, range [-360, 360]
terrain_slope : float
Terrain slope angle in degrees
Terrain slope angle in degrees, range [-90, 90]. A negative slope is
equivalent to the opposite ``terrain_azimuth``. 90 is a vertical surface
module_quality_factor : float or None
Module quality factor override, negative implies loss, range [-0.4, 0.1]
"""
Expand All @@ -89,8 +90,8 @@ class Layout(SolarFarmerBaseModel):
number_of_strings_in_right_row: int = 0
number_of_strings_in_left_row: int = 0
number_of_strings_in_isolated_row: int = 0
terrain_azimuth: float = 0.0
terrain_slope: float = 0.0
terrain_azimuth: float = Field(0.0, ge=-360, le=360)
terrain_slope: float = Field(0.0, ge=-90, le=90)
module_quality_factor: float | None = Field(None, ge=-0.4, le=0.1)

@model_validator(mode="after")
Expand Down
23 changes: 21 additions & 2 deletions solarfarmer/models/pvsystem/pvsystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,21 @@ class PVSystem:
Defines the symmetric rotation bounds: ``[-tracker_max_rotation_angle, +tracker_max_rotation_angle]``.
azimuth : float
Array azimuth in degrees (default 180, i.e., south-facing).
terrain_azimuth : float
Direction in which the terrain slopes down, in degrees clockwise from North,
range [-360, 360] (default 0.0). For example, an azimuth of 0 with a positive
``terrain_slope`` is ground that descends towards the North (a north-facing slope).
For single-axis trackers, the angle between this direction and the tracker azimuth
(``azimuth``) determines how much of the slope tilts the tracker axis.
Ignored when ``flush_mount=True`` on fixed-tilt systems, where the terrain follows
the array plane (``azimuth`` and ``tilt``).
terrain_slope : float
Slope of the terrain in degrees, in the direction of ``terrain_azimuth``, range
[-90, 90] (default 0.0, i.e., flat ground). A negative slope is equivalent to the
opposite ``terrain_azimuth``.
For single-axis trackers, this is the slope that is resolved along the tracker axis
to give the axis tilt; the part across the axis is not applied.
Ignored when ``flush_mount=True`` on fixed-tilt systems.
mounting : str
Mounting configuration: 'Fixed' for fixed-tilt or 'Tracker' for single-axis trackers.
Strings are case-insensitive (e.g., ``"tracker"``) and are stored as
Expand Down Expand Up @@ -315,6 +330,8 @@ class PVSystem:
transformer_stages: int = 1
plant_unavailability: float = 0.0
grid_unavailability: float = 0.0
terrain_azimuth: float = TERRAIN_AZIMUTH
terrain_slope: float = TERRAIN_SLOPE

# Effects and settings
mounting_height: float | None = None # None = derive from mounting at build time
Expand Down Expand Up @@ -831,6 +848,8 @@ def describe(self, verbose=False) -> None:
else:
print(f"Tilt: {self.tilt}°")
print(f"Azimuth: {self.azimuth}°")
print(f"Terrain Azimuth: {self.terrain_azimuth}°")
print(f"Terrain Slope: {self.terrain_slope}°")

# Mounting & Orientation
print("\n--- MOUNTING & ORIENTATION ---")
Expand Down Expand Up @@ -1719,8 +1738,8 @@ def calculate_layout_objects(
terrain_slope = plant.tilt
number_strings_isolated = number_of_strings
else:
terrain_azimuth = TERRAIN_AZIMUTH
terrain_slope = TERRAIN_SLOPE
terrain_azimuth = plant.terrain_azimuth
terrain_slope = plant.terrain_slope
number_strings_isolated = STRINGS_IN_ISOLATED_ROW

layout = Layout(
Expand Down
75 changes: 75 additions & 0 deletions tests/test_construct_plant.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from pathlib import Path

import pytest
from pydantic import ValidationError

from solarfarmer import PVSystem
from solarfarmer.models import PVPlant
Expand Down Expand Up @@ -124,6 +125,80 @@ def total_inverters(pv_plant):
assert total_inverters(small_plant) < total_inverters(large_plant)


class TestTerrainProperties:
"""terrain_azimuth / terrain_slope flow into the layouts, defaulting to 0.0."""

@staticmethod
def _layouts(plant):
pv_plant, _ = design_plant(plant.with_defaults_applied())
layouts = [
layout for t in pv_plant.transformers for inv in t.inverters for layout in inv.layouts
]
assert layouts
return layouts

def test_defaults_are_zero(self, plant):
assert plant.terrain_azimuth == 0.0
assert plant.terrain_slope == 0.0

@pytest.mark.parametrize(
("mounting", "flush_mount"),
[("Fixed", False), ("Tracker", False), ("Tracker", True)],
)
def test_user_values_applied_to_all_layouts(self, plant, mounting, flush_mount):
plant.mounting = mounting
plant.flush_mount = flush_mount
plant.terrain_azimuth = 90.0
plant.terrain_slope = 5.0
layouts = self._layouts(plant)
assert all(lo.terrain_azimuth == pytest.approx(90.0) for lo in layouts)
assert all(lo.terrain_slope == pytest.approx(5.0) for lo in layouts)

@pytest.mark.parametrize(("azimuth", "slope"), [(-360.0, -90.0), (360.0, 90.0), (-90.0, -5.0)])
def test_values_within_bounds_are_accepted(self, plant, azimuth, slope):
plant.terrain_azimuth = azimuth
plant.terrain_slope = slope
layouts = self._layouts(plant)
assert all(lo.terrain_azimuth == pytest.approx(azimuth) for lo in layouts)
assert all(lo.terrain_slope == pytest.approx(slope) for lo in layouts)

@pytest.mark.parametrize(
("field", "value"),
[
("terrain_azimuth", -360.1),
("terrain_azimuth", 360.1),
("terrain_slope", -90.1),
("terrain_slope", 90.1),
],
)
def test_values_outside_bounds_are_rejected(self, plant, field, value):
setattr(plant, field, value)
with pytest.raises(ValidationError, match=field):
design_plant(plant.with_defaults_applied())

def test_vertical_flush_mount_is_allowed(self, plant):
plant.flush_mount = True
plant.tilt = 90.0
assert all(lo.terrain_slope == pytest.approx(90.0) for lo in self._layouts(plant))

def test_flush_mount_ignores_terrain_properties(self, plant):
plant.flush_mount = True
plant.tilt = 15.0
plant.terrain_azimuth = 90.0
plant.terrain_slope = 5.0
layouts = self._layouts(plant)
assert all(lo.terrain_azimuth == pytest.approx(plant.azimuth) for lo in layouts)
assert all(lo.terrain_slope == pytest.approx(15.0) for lo in layouts)

def test_values_survive_file_round_trip(self, plant, tmp_path):
plant.terrain_azimuth = 45.0
plant.terrain_slope = 3.0
plant.to_file(tmp_path / "plant.json")
loaded = PVSystem.from_file(tmp_path / "plant.json")
assert loaded.terrain_azimuth == 45.0
assert loaded.terrain_slope == 3.0


class TestGenerateMountingSpecs:
"""Regression tests for generate_mounting_specs()."""

Expand Down
42 changes: 42 additions & 0 deletions tests/test_models/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,48 @@ def test_azimuth_out_of_range(self) -> None:
string_length=1,
)

@pytest.mark.parametrize(
("field", "value"),
[
("terrain_azimuth", -360.1),
("terrain_azimuth", 360.1),
("terrain_slope", -90.1),
("terrain_slope", 90.1),
],
)
def test_terrain_out_of_range(self, field: str, value: float) -> None:
with pytest.raises(ValidationError, match=field):
Layout(
layout_count=1,
module_specification_id="m",
mounting_type_id="mt",
is_trackers=False,
azimuth=180,
pitch=5,
total_number_of_strings=1,
string_length=1,
**{field: value},
)

@pytest.mark.parametrize(
("azimuth", "slope"), [(-360.0, -90.0), (360.0, 90.0), (-90.0, -5.0), (0.0, 0.0)]
)
def test_terrain_limits_are_inclusive(self, azimuth: float, slope: float) -> None:
layout = Layout(
layout_count=1,
module_specification_id="m",
mounting_type_id="mt",
is_trackers=False,
azimuth=180,
pitch=5,
total_number_of_strings=1,
string_length=1,
terrain_azimuth=azimuth,
terrain_slope=slope,
)
assert layout.terrain_azimuth == azimuth
assert layout.terrain_slope == slope

def test_dc_ohmic_resistance_and_loss_both_set_raises(self) -> None:
with pytest.raises(ValidationError, match="dc_ohmic_connector_resistance"):
Layout(
Expand Down
Loading