From fd4eab8aa7e408c0f97759bd9d8ec7c10cb9ad43 Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Fri, 16 Oct 2026 12:40:48 +0200 Subject: [PATCH 1/6] Add terrain_azimutch and terrain_slope to PVSystem --- solarfarmer/models/pvsystem/pvsystem.py | 32 ++++++++++++++++++-- tests/test_construct_plant.py | 40 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/solarfarmer/models/pvsystem/pvsystem.py b/solarfarmer/models/pvsystem/pvsystem.py index 7b9826e..8a0abd6 100644 --- a/solarfarmer/models/pvsystem/pvsystem.py +++ b/solarfarmer/models/pvsystem/pvsystem.py @@ -151,7 +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). - mounting : str + terrain_azimuth : float | None + Direction in which the terrain slopes down, in degrees clockwise from North + (default 0.0 when not set). 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 this is the axis tilt, the angle between the X and Z axes: + a negative angle is a north-facing slope and a positive angle is a south-facing slope. + Ignored when ``flush_mount=True`` on fixed-tilt systems, where the terrain follows + the array plane (``azimuth`` and ``tilt``). + terrain_slope : float | None + Slope of the terrain in degrees, in the direction of ``terrain_azimuth`` + (default 0.0 when not set, i.e., flat ground). + For single-axis trackers this is the side slope, the angle between the Y and Z axes: + a negative angle is an east-facing slope and a positive angle is a west-facing slope. + 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 ``solarfarmer.MountingType`` enum values. @@ -315,6 +329,8 @@ class PVSystem: transformer_stages: int = 1 plant_unavailability: float = 0.0 grid_unavailability: float = 0.0 + terrain_azimuth: float | None = None # If None, TERRAIN_AZIMUTH default is used + terrain_slope: float | None = None # If None, TERRAIN_SLOPE default is used # Effects and settings mounting_height: float | None = None # None = derive from mounting at build time @@ -831,6 +847,12 @@ def describe(self, verbose=False) -> None: else: print(f"Tilt: {self.tilt}°") print(f"Azimuth: {self.azimuth}°") + print( + f"Terrain Azimuth: {TERRAIN_AZIMUTH if self.terrain_azimuth is None else self.terrain_azimuth}°" + ) + print( + f"Terrain Slope: {TERRAIN_SLOPE if self.terrain_slope is None else self.terrain_slope}°" + ) # Mounting & Orientation print("\n--- MOUNTING & ORIENTATION ---") @@ -1719,8 +1741,12 @@ 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 if plant.terrain_azimuth is not None else TERRAIN_AZIMUTH + ) + terrain_slope = ( + plant.terrain_slope if plant.terrain_slope is not None else TERRAIN_SLOPE + ) number_strings_isolated = STRINGS_IN_ISOLATED_ROW layout = Layout( diff --git a/tests/test_construct_plant.py b/tests/test_construct_plant.py index 09a85e7..4492735 100644 --- a/tests/test_construct_plant.py +++ b/tests/test_construct_plant.py @@ -124,6 +124,46 @@ 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(pv_plant): + return [ + layout for t in pv_plant.transformers for inv in t.inverters for layout in inv.layouts + ] + + def test_defaults_are_zero_when_not_set(self, plant): + assert plant.terrain_azimuth is None + assert plant.terrain_slope is None + layouts = self._layouts(design_plant(plant)[0]) + assert layouts + assert all(lo.terrain_azimuth == 0.0 and lo.terrain_slope == 0.0 for lo in layouts) + + def test_user_values_applied_to_all_layouts(self, plant): + plant.terrain_azimuth = 90.0 + plant.terrain_slope = 5.0 + layouts = self._layouts(design_plant(plant)[0]) + assert layouts + 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) + + def test_zero_is_respected_as_explicit_value(self, plant): + plant.terrain_azimuth = 0.0 + plant.terrain_slope = 0.0 + layouts = self._layouts(design_plant(plant)[0]) + assert all(lo.terrain_azimuth == 0.0 and lo.terrain_slope == 0.0 for lo in layouts) + + 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(design_plant(plant)[0]) + 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) + + class TestGenerateMountingSpecs: """Regression tests for generate_mounting_specs().""" From 5dd2345729a2e3e107951b34755b261f7ce1a62b Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Mon, 19 Oct 2026 10:46:20 -0700 Subject: [PATCH 2/6] Use plain float defaults for PVSystem terrain inputs and fix tests Align terrain_azimuth/terrain_slope with other plain-default inputs, call design_plant() on a defaults-resolved plant, add tracker and file round-trip tests, and add a changelog entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 ++ solarfarmer/models/pvsystem/pvsystem.py | 36 +++++++-------------- tests/test_construct_plant.py | 42 ++++++++++++++----------- 3 files changed, 38 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e1328e..b898395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/solarfarmer/models/pvsystem/pvsystem.py b/solarfarmer/models/pvsystem/pvsystem.py index 8a0abd6..a84b95b 100644 --- a/solarfarmer/models/pvsystem/pvsystem.py +++ b/solarfarmer/models/pvsystem/pvsystem.py @@ -151,21 +151,17 @@ 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 | None + terrain_azimuth : float Direction in which the terrain slopes down, in degrees clockwise from North - (default 0.0 when not set). 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 this is the axis tilt, the angle between the X and Z axes: - a negative angle is a north-facing slope and a positive angle is a south-facing slope. + (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). Ignored when ``flush_mount=True`` on fixed-tilt systems, where the terrain follows the array plane (``azimuth`` and ``tilt``). - terrain_slope : float | None + terrain_slope : float Slope of the terrain in degrees, in the direction of ``terrain_azimuth`` - (default 0.0 when not set, i.e., flat ground). - For single-axis trackers this is the side slope, the angle between the Y and Z axes: - a negative angle is an east-facing slope and a positive angle is a west-facing slope. + (default 0.0, i.e., flat ground). Ignored when ``flush_mount=True`` on fixed-tilt systems. - mounting: str + 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 ``solarfarmer.MountingType`` enum values. @@ -329,8 +325,8 @@ class PVSystem: transformer_stages: int = 1 plant_unavailability: float = 0.0 grid_unavailability: float = 0.0 - terrain_azimuth: float | None = None # If None, TERRAIN_AZIMUTH default is used - terrain_slope: float | None = None # If None, TERRAIN_SLOPE default is used + 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 @@ -847,12 +843,8 @@ def describe(self, verbose=False) -> None: else: print(f"Tilt: {self.tilt}°") print(f"Azimuth: {self.azimuth}°") - print( - f"Terrain Azimuth: {TERRAIN_AZIMUTH if self.terrain_azimuth is None else self.terrain_azimuth}°" - ) - print( - f"Terrain Slope: {TERRAIN_SLOPE if self.terrain_slope is None else self.terrain_slope}°" - ) + print(f"Terrain Azimuth: {self.terrain_azimuth}°") + print(f"Terrain Slope: {self.terrain_slope}°") # Mounting & Orientation print("\n--- MOUNTING & ORIENTATION ---") @@ -1741,12 +1733,8 @@ def calculate_layout_objects( terrain_slope = plant.tilt number_strings_isolated = number_of_strings else: - terrain_azimuth = ( - plant.terrain_azimuth if plant.terrain_azimuth is not None else TERRAIN_AZIMUTH - ) - terrain_slope = ( - plant.terrain_slope if plant.terrain_slope is not None else TERRAIN_SLOPE - ) + terrain_azimuth = plant.terrain_azimuth + terrain_slope = plant.terrain_slope number_strings_isolated = STRINGS_IN_ISOLATED_ROW layout = Layout( diff --git a/tests/test_construct_plant.py b/tests/test_construct_plant.py index 4492735..8b6a8b6 100644 --- a/tests/test_construct_plant.py +++ b/tests/test_construct_plant.py @@ -128,41 +128,47 @@ class TestTerrainProperties: """terrain_azimuth / terrain_slope flow into the layouts, defaulting to 0.0.""" @staticmethod - def _layouts(pv_plant): - return [ + 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 ] - - def test_defaults_are_zero_when_not_set(self, plant): - assert plant.terrain_azimuth is None - assert plant.terrain_slope is None - layouts = self._layouts(design_plant(plant)[0]) assert layouts - assert all(lo.terrain_azimuth == 0.0 and lo.terrain_slope == 0.0 for lo in layouts) + return layouts + + def test_defaults_are_zero(self, plant): + assert plant.terrain_azimuth == 0.0 + assert plant.terrain_slope == 0.0 + assert all( + lo.terrain_azimuth == 0.0 and lo.terrain_slope == 0.0 for lo in self._layouts(plant) + ) - def test_user_values_applied_to_all_layouts(self, plant): + @pytest.mark.parametrize("mounting", ["Fixed", "Tracker"]) + def test_user_values_applied_to_all_layouts(self, plant, mounting): + plant.mounting = mounting plant.terrain_azimuth = 90.0 plant.terrain_slope = 5.0 - layouts = self._layouts(design_plant(plant)[0]) - assert layouts + 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) - def test_zero_is_respected_as_explicit_value(self, plant): - plant.terrain_azimuth = 0.0 - plant.terrain_slope = 0.0 - layouts = self._layouts(design_plant(plant)[0]) - assert all(lo.terrain_azimuth == 0.0 and lo.terrain_slope == 0.0 for lo in layouts) - 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(design_plant(plant)[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().""" From 40e26e0aaae7ddfd04594f8baff87f473526ed5f Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Mon, 19 Oct 2026 11:45:40 -0700 Subject: [PATCH 3/6] Document single-axis tracker behaviour of PVSystem terrain inputs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- solarfarmer/models/pvsystem/pvsystem.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/solarfarmer/models/pvsystem/pvsystem.py b/solarfarmer/models/pvsystem/pvsystem.py index a84b95b..3906bab 100644 --- a/solarfarmer/models/pvsystem/pvsystem.py +++ b/solarfarmer/models/pvsystem/pvsystem.py @@ -155,11 +155,15 @@ class PVSystem: Direction in which the terrain slopes down, in degrees clockwise from North (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`` (default 0.0, i.e., flat ground). + 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. From aed96688dfd4f8a3d866cf76cde5e6291aabc475 Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Mon, 19 Oct 2026 11:54:07 -0700 Subject: [PATCH 4/6] Document PVSystem terrain inputs in the plant builder guide Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/getting-started/workflow-2-pvplant-builder.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/getting-started/workflow-2-pvplant-builder.md b/docs/getting-started/workflow-2-pvplant-builder.md index 8a7932f..d25d72d 100644 --- a/docs/getting-started/workflow-2-pvplant-builder.md +++ b/docs/getting-started/workflow-2-pvplant-builder.md @@ -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 From ee321b7601566aa0dba718d7e391872d1b689e2d Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Mon, 19 Oct 2026 13:12:59 -0700 Subject: [PATCH 5/6] Validate terrain azimuth and slope bounds and tighten terrain tests Bound Layout.terrain_azimuth to [-360, 360] (as Layout.azimuth) and Layout.terrain_slope to (-90, 90), since the engine takes tan(slope). Cover tracker with flush_mount, drop a redundant default assertion, and add parametrized in/out-of-bounds tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + solarfarmer/models/layout.py | 9 ++++--- solarfarmer/models/pvsystem/pvsystem.py | 9 ++++--- tests/test_construct_plant.py | 34 +++++++++++++++++++++---- 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b898395..64eb3a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,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), exclusive. ### Fixed diff --git a/solarfarmer/models/layout.py b/solarfarmer/models/layout.py index 8846050..7227800 100644 --- a/solarfarmer/models/layout.py +++ b/solarfarmer/models/layout.py @@ -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) exclusive. A negative slope is + equivalent to the opposite ``terrain_azimuth`` module_quality_factor : float or None Module quality factor override, negative implies loss, range [-0.4, 0.1] """ @@ -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, gt=-90, lt=90) module_quality_factor: float | None = Field(None, ge=-0.4, le=0.1) @model_validator(mode="after") diff --git a/solarfarmer/models/pvsystem/pvsystem.py b/solarfarmer/models/pvsystem/pvsystem.py index 3906bab..c965c3d 100644 --- a/solarfarmer/models/pvsystem/pvsystem.py +++ b/solarfarmer/models/pvsystem/pvsystem.py @@ -152,16 +152,17 @@ class PVSystem: 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 - (default 0.0). For example, an azimuth of 0 with a positive ``terrain_slope`` is + 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`` - (default 0.0, i.e., flat ground). + Slope of the terrain in degrees, in the direction of ``terrain_azimuth``, between + -90 and 90 exclusive (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. diff --git a/tests/test_construct_plant.py b/tests/test_construct_plant.py index 8b6a8b6..2c66f27 100644 --- a/tests/test_construct_plant.py +++ b/tests/test_construct_plant.py @@ -2,6 +2,7 @@ from pathlib import Path import pytest +from pydantic import ValidationError from solarfarmer import PVSystem from solarfarmer.models import PVPlant @@ -139,19 +140,42 @@ def _layouts(plant): def test_defaults_are_zero(self, plant): assert plant.terrain_azimuth == 0.0 assert plant.terrain_slope == 0.0 - assert all( - lo.terrain_azimuth == 0.0 and lo.terrain_slope == 0.0 for lo in self._layouts(plant) - ) - @pytest.mark.parametrize("mounting", ["Fixed", "Tracker"]) - def test_user_values_applied_to_all_layouts(self, plant, mounting): + @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, -89.9), (360.0, 89.9), (-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.0), + ("terrain_slope", 90.0), + ], + ) + 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_flush_mount_ignores_terrain_properties(self, plant): plant.flush_mount = True plant.tilt = 15.0 From 5eaa7918f91102e3ddcc29175759f0d6e615c20e Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Mon, 19 Oct 2026 13:28:30 -0700 Subject: [PATCH 6/6] Allow terrain_slope up to +/-90 so vertical flush-mount plants build Flush-mount plants carry the array tilt in Layout.terrain_slope, and tilt may be 90. Make the slope bound inclusive, add direct Layout bounds tests and a vertical flush-mount regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- solarfarmer/models/layout.py | 6 ++-- solarfarmer/models/pvsystem/pvsystem.py | 10 +++--- tests/test_construct_plant.py | 11 +++++-- tests/test_models/test_validation.py | 42 +++++++++++++++++++++++++ 5 files changed, 59 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64eb3a9..759db4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +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), exclusive. +- `Layout.terrain_azimuth` is now validated to [-360, 360] and `Layout.terrain_slope` to [-90, 90]. ### Fixed diff --git a/solarfarmer/models/layout.py b/solarfarmer/models/layout.py index 7227800..b7780d4 100644 --- a/solarfarmer/models/layout.py +++ b/solarfarmer/models/layout.py @@ -64,8 +64,8 @@ class Layout(SolarFarmerBaseModel): terrain_azimuth : float Terrain slope azimuth in degrees, range [-360, 360] terrain_slope : float - Terrain slope angle in degrees, range (-90, 90) exclusive. A negative slope is - equivalent to the opposite ``terrain_azimuth`` + 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] """ @@ -91,7 +91,7 @@ class Layout(SolarFarmerBaseModel): number_of_strings_in_left_row: int = 0 number_of_strings_in_isolated_row: int = 0 terrain_azimuth: float = Field(0.0, ge=-360, le=360) - terrain_slope: float = Field(0.0, gt=-90, lt=90) + 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") diff --git a/solarfarmer/models/pvsystem/pvsystem.py b/solarfarmer/models/pvsystem/pvsystem.py index c965c3d..e603e3d 100644 --- a/solarfarmer/models/pvsystem/pvsystem.py +++ b/solarfarmer/models/pvsystem/pvsystem.py @@ -153,16 +153,16 @@ class PVSystem: 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). + 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``, between - -90 and 90 exclusive (default 0.0, i.e., flat ground). A negative slope is - equivalent to the opposite ``terrain_azimuth``. + 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. diff --git a/tests/test_construct_plant.py b/tests/test_construct_plant.py index 2c66f27..8a710b7 100644 --- a/tests/test_construct_plant.py +++ b/tests/test_construct_plant.py @@ -154,7 +154,7 @@ def test_user_values_applied_to_all_layouts(self, plant, mounting, flush_mount): 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, -89.9), (360.0, 89.9), (-90.0, -5.0)]) + @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 @@ -167,8 +167,8 @@ def test_values_within_bounds_are_accepted(self, plant, azimuth, slope): [ ("terrain_azimuth", -360.1), ("terrain_azimuth", 360.1), - ("terrain_slope", -90.0), - ("terrain_slope", 90.0), + ("terrain_slope", -90.1), + ("terrain_slope", 90.1), ], ) def test_values_outside_bounds_are_rejected(self, plant, field, value): @@ -176,6 +176,11 @@ def test_values_outside_bounds_are_rejected(self, 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 diff --git a/tests/test_models/test_validation.py b/tests/test_models/test_validation.py index 5edd1b9..5fd276e 100644 --- a/tests/test_models/test_validation.py +++ b/tests/test_models/test_validation.py @@ -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(