diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e1328e..759db4c 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 @@ -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 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 diff --git a/solarfarmer/models/layout.py b/solarfarmer/models/layout.py index 8846050..b7780d4 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]. 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] """ @@ -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") diff --git a/solarfarmer/models/pvsystem/pvsystem.py b/solarfarmer/models/pvsystem/pvsystem.py index 7b9826e..e603e3d 100644 --- a/solarfarmer/models/pvsystem/pvsystem.py +++ b/solarfarmer/models/pvsystem/pvsystem.py @@ -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 @@ -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 @@ -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 ---") @@ -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( diff --git a/tests/test_construct_plant.py b/tests/test_construct_plant.py index 09a85e7..8a710b7 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 @@ -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().""" 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(