From e1b9b7a260df8d2ab2de3361bf6e5c076417443d Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Tue, 19 May 2026 21:59:01 +0200 Subject: [PATCH 1/4] Add pydantic models for 3D API data model --- solarfarmer/__init__.py | 24 ++++++++++ solarfarmer/models/__init__.py | 24 ++++++++++ solarfarmer/models/indexed_object3d.py | 36 +++++++++++++++ solarfarmer/models/mini_simple_terrain_dto.py | 35 ++++++++++++++ solarfarmer/models/pv_plant.py | 31 +++++++++++++ solarfarmer/models/quad_double.py | 26 +++++++++++ solarfarmer/models/rack.py | 35 ++++++++++++++ solarfarmer/models/racks.py | 16 +++++++ solarfarmer/models/shading_objects.py | 19 ++++++++ solarfarmer/models/simple_terrain.py | 19 ++++++++ solarfarmer/models/terrain_row_dto.py | 20 ++++++++ .../terrain_row_start_end_columns_dto.py | 20 ++++++++ solarfarmer/models/tracker.py | 46 +++++++++++++++++++ solarfarmer/models/trackers.py | 16 +++++++ solarfarmer/models/vector3double.py | 19 ++++++++ 15 files changed, 386 insertions(+) create mode 100644 solarfarmer/models/indexed_object3d.py create mode 100644 solarfarmer/models/mini_simple_terrain_dto.py create mode 100644 solarfarmer/models/quad_double.py create mode 100644 solarfarmer/models/rack.py create mode 100644 solarfarmer/models/racks.py create mode 100644 solarfarmer/models/shading_objects.py create mode 100644 solarfarmer/models/simple_terrain.py create mode 100644 solarfarmer/models/terrain_row_dto.py create mode 100644 solarfarmer/models/terrain_row_start_end_columns_dto.py create mode 100644 solarfarmer/models/tracker.py create mode 100644 solarfarmer/models/trackers.py create mode 100644 solarfarmer/models/vector3double.py diff --git a/solarfarmer/__init__.py b/solarfarmer/__init__.py index e748d72..b243fe6 100644 --- a/solarfarmer/__init__.py +++ b/solarfarmer/__init__.py @@ -35,12 +35,14 @@ EnergyCalculationOptions, HorizonType, IAMModelTypeForOverride, + IndexedObject3D, Inverter, InverterOverPowerShutdownMode, InverterType, Layout, Location, MeteoFileFormat, + MiniSimpleTerrainDto, MissingMetDataMethod, ModelChainResponse, MonthlyAlbedo, @@ -52,11 +54,21 @@ PanFileSupplements, PVPlant, PVSystem, + QuadDouble, + Rack, + Racks, + ShadingObjects, + SimpleTerrain, + TerrainRowDto, + TerrainRowStartEndColumnsDto, + Tracker, + Trackers, TrackerSystem, Transformer, TransformerLossModelTypes, TransformerSpecification, ValidationMessage, + Vector3Double, ) from .weather import ( TSV_COLUMNS, @@ -99,12 +111,14 @@ "EnergyCalculationOptions", "HorizonType", "IAMModelTypeForOverride", + "IndexedObject3D", "Inverter", "InverterOverPowerShutdownMode", "InverterType", "Layout", "Location", "MeteoFileFormat", + "MiniSimpleTerrainDto", "MissingMetDataMethod", "ModelChainResponse", "MonthlyAlbedo", @@ -116,16 +130,26 @@ "PanFileSupplements", "PVPlant", "PVSystem", + "QuadDouble", + "Rack", + "Racks", "run_energy_calculation", "service", + "ShadingObjects", + "SimpleTerrain", "SolarFarmerAPIError", + "TerrainRowDto", + "TerrainRowStartEndColumnsDto", "terminate_calculation", + "Tracker", "TrackerSystem", + "Trackers", "TSV_COLUMNS", "Transformer", "TransformerLossModelTypes", "TransformerSpecification", "ValidationMessage", + "Vector3Double", "from_dataframe", "from_pvlib", "from_solcast", diff --git a/solarfarmer/models/__init__.py b/solarfarmer/models/__init__.py index f45ce6e..d73d489 100644 --- a/solarfarmer/models/__init__.py +++ b/solarfarmer/models/__init__.py @@ -16,9 +16,11 @@ OrderColumnsPvSystFormatTimeSeries, TransformerLossModelTypes, ) +from .indexed_object3d import IndexedObject3D from .inverter import Inverter from .layout import Layout from .location import Location +from .mini_simple_terrain_dto import MiniSimpleTerrainDto from .model_chain_response import ModelChainResponse from .monthly_albedo import MonthlyAlbedo from .mounting_type_specification import MountingTypeSpecification @@ -28,9 +30,19 @@ from .pvsystem.plant_defaults import InverterType, MountingType, OrientationType from .pvsystem.pvsystem import PVSystem from .pvsystem.validation import ValidationMessage +from .quad_double import QuadDouble +from .rack import Rack +from .racks import Racks +from .shading_objects import ShadingObjects +from .simple_terrain import SimpleTerrain +from .terrain_row_dto import TerrainRowDto +from .terrain_row_start_end_columns_dto import TerrainRowStartEndColumnsDto +from .tracker import Tracker from .tracker_system import TrackerSystem +from .trackers import Trackers from .transformer import Transformer from .transformer_specification import TransformerSpecification +from .vector3double import Vector3Double __all__ = [ "AuxiliaryLosses", @@ -41,12 +53,14 @@ "EnergyCalculationOptions", "HorizonType", "IAMModelTypeForOverride", + "IndexedObject3D", "Inverter", "InverterOverPowerShutdownMode", "InverterType", "Layout", "Location", "MeteoFileFormat", + "MiniSimpleTerrainDto", "MissingMetDataMethod", "ModelChainResponse", "MonthlyAlbedo", @@ -58,10 +72,20 @@ "PanFileSupplements", "PVPlant", "PVSystem", + "QuadDouble", + "Rack", + "Racks", + "ShadingObjects", + "SimpleTerrain", "SolarFarmerBaseModel", + "TerrainRowDto", + "TerrainRowStartEndColumnsDto", + "Tracker", "TrackerSystem", + "Trackers", "Transformer", "TransformerLossModelTypes", "TransformerSpecification", "ValidationMessage", + "Vector3Double", ] diff --git a/solarfarmer/models/indexed_object3d.py b/solarfarmer/models/indexed_object3d.py new file mode 100644 index 0000000..c9b73ab --- /dev/null +++ b/solarfarmer/models/indexed_object3d.py @@ -0,0 +1,36 @@ +from pydantic import Field + +from ._base import SolarFarmerBaseModel +from .vector3double import Vector3Double + + +class IndexedObject3D(SolarFarmerBaseModel): + """A 3D object defined by an indexed mesh of vertices and face indices. + + Represents a shading obstacle or building in the 3D scene. The mesh is + described by a set of 3D vertices plus face connectivity expressed as + lists of vertex indices — either quadrilateral faces (``quad_indices``) + or triangular faces (``triangle_indices``). + + Attributes + ---------- + is_building : bool + Whether the object should be treated as a building (affects shading + and irradiance modelling assumptions) + name : str + Descriptive name for this object in the 3D scene + quad_indices : list[list[int]] + Face connectivity for quadrilateral faces. Each inner list contains + four vertex indices referencing entries in ``vertices`` + triangle_indices : list[list[int]] + Face connectivity for triangular faces. Each inner list contains + three vertex indices referencing entries in ``vertices`` + vertices : list[Vector3Double] + 3D vertex positions shared by both quad and triangle faces + """ + + is_building: bool + name: str + quad_indices: list[list[int]] = Field(default_factory=list) + triangle_indices: list[list[int]] = Field(default_factory=list) + vertices: list[Vector3Double] = Field(default_factory=list) diff --git a/solarfarmer/models/mini_simple_terrain_dto.py b/solarfarmer/models/mini_simple_terrain_dto.py new file mode 100644 index 0000000..2d0c004 --- /dev/null +++ b/solarfarmer/models/mini_simple_terrain_dto.py @@ -0,0 +1,35 @@ +from pydantic import Field + +from ._base import SolarFarmerBaseModel +from .terrain_row_dto import TerrainRowDto +from .vector3double import Vector3Double + + +class MiniSimpleTerrainDto(SolarFarmerBaseModel): + """A single terrain tile in a simple terrain representation. + + Describes one rectangular patch of terrain as a regular grid of + vertices. The grid dimensions are given by ``num_vertices_across`` + (columns) and ``num_vertices_down`` (rows); the actual 3D positions + are stored in ``vertices`` in row-major order. + + The ``terrain_rows`` list mirrors the row structure and records which + column ranges within each row are active (contain valid data). + + Attributes + ---------- + num_vertices_across : int + Number of vertices along the horizontal (column) axis of the grid + num_vertices_down : int + Number of vertices along the vertical (row) axis of the grid + terrain_rows : list[TerrainRowDto] + Per-row active column ranges, one entry per row of the grid + vertices : list[Vector3Double] + 3D vertex positions in row-major order (``num_vertices_down`` × + ``num_vertices_across`` entries) + """ + + num_vertices_across: int + num_vertices_down: int + terrain_rows: list[TerrainRowDto] = Field(default_factory=list) + vertices: list[Vector3Double] = Field(default_factory=list) diff --git a/solarfarmer/models/pv_plant.py b/solarfarmer/models/pv_plant.py index 2a1ca8a..cee9d6d 100644 --- a/solarfarmer/models/pv_plant.py +++ b/solarfarmer/models/pv_plant.py @@ -1,10 +1,16 @@ from __future__ import annotations +from typing import Any + from pydantic import model_validator from ._base import SolarFarmerBaseModel from .auxiliary_losses import AuxiliaryLosses +from .indexed_object3d import IndexedObject3D from .mounting_type_specification import MountingTypeSpecification +from .rack import Rack +from .simple_terrain import SimpleTerrain +from .tracker import Tracker from .tracker_system import TrackerSystem from .transformer import Transformer from .transformer_specification import TransformerSpecification @@ -33,6 +39,24 @@ class PVPlant(SolarFarmerBaseModel): HTTP 400 error. The key must match ``Transformer.transformer_spec_id``. auxiliary_losses : AuxiliaryLosses or None Plant-level auxiliary losses + racks : list[Rack] or None + Fixed-tilt rack objects for 3D calculations. Not required for 2D + shading_objects : list[IndexedObject3D] or None + 3D shading obstacles (buildings, terrain features, etc.). Used for + 3D calculations; ignored for 2D + simple_terrain : SimpleTerrain or None + Ground surface terrain model for 3D calculations. Ignored for 2D + trackers : list[Tracker] or None + Single-axis tracker objects for 3D calculations. Not required for 2D + inverter_specifications : dict[str, Any] or None + Inverter specifications keyed by spec ID. Not required when the + inverter spec is populated from OND files + module_specifications : dict[str, Any] or None + Module specifications keyed by spec ID. Not required when the + module spec is populated from PAN files + optimizer_specifications : dict[str, Any] or None + Power optimizer specifications keyed by spec ID. Not required when + the optimizer spec is populated from DCO files """ transformers: list[Transformer] @@ -41,6 +65,13 @@ class PVPlant(SolarFarmerBaseModel): tracker_systems: dict[str, TrackerSystem] | None = None transformer_specifications: dict[str, TransformerSpecification] | None = None auxiliary_losses: AuxiliaryLosses | None = None + racks: list[Rack] | None = None + shading_objects: list[IndexedObject3D] | None = None + simple_terrain: SimpleTerrain | None = None + trackers: list[Tracker] | None = None + inverter_specifications: dict[str, Any] | None = None + module_specifications: dict[str, Any] | None = None + optimizer_specifications: dict[str, Any] | None = None @model_validator(mode="after") def _check_transformer_spec_references(self) -> PVPlant: diff --git a/solarfarmer/models/quad_double.py b/solarfarmer/models/quad_double.py new file mode 100644 index 0000000..b5f8721 --- /dev/null +++ b/solarfarmer/models/quad_double.py @@ -0,0 +1,26 @@ +from ._base import SolarFarmerBaseModel +from .vector3double import Vector3Double + + +class QuadDouble(SolarFarmerBaseModel): + """A quadrilateral in 3D space defined by four corner vertices. + + Represents the footprint or boundary of a rack or similar planar object, + where the four points define the corners of a quadrilateral face. + + Attributes + ---------- + p1 : Vector3Double + First corner vertex of the quadrilateral + p2 : Vector3Double + Second corner vertex of the quadrilateral + p3 : Vector3Double + Third corner vertex of the quadrilateral + p4 : Vector3Double + Fourth corner vertex of the quadrilateral + """ + + p1: Vector3Double + p2: Vector3Double + p3: Vector3Double + p4: Vector3Double diff --git a/solarfarmer/models/rack.py b/solarfarmer/models/rack.py new file mode 100644 index 0000000..8951729 --- /dev/null +++ b/solarfarmer/models/rack.py @@ -0,0 +1,35 @@ +from pydantic import Field + +from ._base import SolarFarmerBaseModel +from .quad_double import QuadDouble + + +class Rack(SolarFarmerBaseModel): + """A fixed-tilt rack in a 3D plant layout. + + Represents a single physical rack structure placed in the 3D scene, + identified by an integer ID and referencing a mounting type. The rack + geometry is described by a quadrilateral (``quad``) in world coordinates. + + Attributes + ---------- + id : int + Unique integer identifier for this rack within the layout + mounting_type_id : str + Reference to a mounting type specification. Must match a key in + ``PVPlant.mounting_type_specifications`` + pitch_to_back : float or None + Row-to-row pitch to the adjacent rack behind this one, in metres. + ``None`` if this rack has no neighbour behind it (e.g. last row) + pitch_to_front : float or None + Row-to-row pitch to the adjacent rack in front of this one, in metres. + ``None`` if this rack has no neighbour in front of it (e.g. first row) + quad : QuadDouble + 3D quadrilateral defining the four corner vertices of the rack surface + """ + + id: int + mounting_type_id: str = Field(..., alias="mountingTypeID", min_length=1) + pitch_to_back: float | None = None + pitch_to_front: float | None = None + quad: QuadDouble diff --git a/solarfarmer/models/racks.py b/solarfarmer/models/racks.py new file mode 100644 index 0000000..7241f74 --- /dev/null +++ b/solarfarmer/models/racks.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from ._base import SolarFarmerBaseModel +from .rack import Rack + + +class Racks(SolarFarmerBaseModel): + """A collection of fixed-tilt racks in a 3D plant layout. + + Attributes + ---------- + racks : list[Rack] + The individual rack objects making up this collection + """ + + racks: list[Rack] = Field(default_factory=list) diff --git a/solarfarmer/models/shading_objects.py b/solarfarmer/models/shading_objects.py new file mode 100644 index 0000000..1f93d97 --- /dev/null +++ b/solarfarmer/models/shading_objects.py @@ -0,0 +1,19 @@ +from pydantic import Field + +from ._base import SolarFarmerBaseModel +from .indexed_object3d import IndexedObject3D + + +class ShadingObjects(SolarFarmerBaseModel): + """A collection of 3D shading obstacles in the plant scene. + + Holds all near-shading objects (buildings, terrain features, etc.) + that cast shadows onto the PV plant. + + Attributes + ---------- + objects : list[IndexedObject3D] + The individual 3D shading objects in this collection + """ + + objects: list[IndexedObject3D] = Field(default_factory=list) diff --git a/solarfarmer/models/simple_terrain.py b/solarfarmer/models/simple_terrain.py new file mode 100644 index 0000000..c41ed81 --- /dev/null +++ b/solarfarmer/models/simple_terrain.py @@ -0,0 +1,19 @@ +from pydantic import Field + +from ._base import SolarFarmerBaseModel +from .mini_simple_terrain_dto import MiniSimpleTerrainDto + + +class SimpleTerrain(SolarFarmerBaseModel): + """A simple terrain representation composed of one or more terrain tiles. + + Aggregates multiple :class:`MiniSimpleTerrainDto` tiles that together + describe the ground surface beneath and around a 3D PV plant layout. + + Attributes + ---------- + mini_simple_terrains : list[MiniSimpleTerrainDto] + Individual terrain tiles that make up the full terrain surface + """ + + mini_simple_terrains: list[MiniSimpleTerrainDto] = Field(default_factory=list) diff --git a/solarfarmer/models/terrain_row_dto.py b/solarfarmer/models/terrain_row_dto.py new file mode 100644 index 0000000..85c371b --- /dev/null +++ b/solarfarmer/models/terrain_row_dto.py @@ -0,0 +1,20 @@ +from pydantic import Field + +from ._base import SolarFarmerBaseModel +from .terrain_row_start_end_columns_dto import TerrainRowStartEndColumnsDto + + +class TerrainRowDto(SolarFarmerBaseModel): + """A single row in a simple terrain grid, with its active column ranges. + + Each row of the terrain grid may contain one or more contiguous spans + of active (valid) columns; each span is described by a + :class:`TerrainRowStartEndColumnsDto`. + + Attributes + ---------- + start_end_columns : list[TerrainRowStartEndColumnsDto] + Active column ranges within this terrain row + """ + + start_end_columns: list[TerrainRowStartEndColumnsDto] = Field(default_factory=list) diff --git a/solarfarmer/models/terrain_row_start_end_columns_dto.py b/solarfarmer/models/terrain_row_start_end_columns_dto.py new file mode 100644 index 0000000..37fb49a --- /dev/null +++ b/solarfarmer/models/terrain_row_start_end_columns_dto.py @@ -0,0 +1,20 @@ +from ._base import SolarFarmerBaseModel + + +class TerrainRowStartEndColumnsDto(SolarFarmerBaseModel): + """Column index range for a single active segment within a terrain row. + + Defines a contiguous horizontal span of terrain grid cells that are + active (i.e. contain valid height data) within one row of a simple + terrain grid. + + Attributes + ---------- + start_column_index : int + Zero-based index of the first active column in the row + end_column_index : int + Zero-based index of the last active column in the row (inclusive) + """ + + start_column_index: int + end_column_index: int diff --git a/solarfarmer/models/tracker.py b/solarfarmer/models/tracker.py new file mode 100644 index 0000000..8bae944 --- /dev/null +++ b/solarfarmer/models/tracker.py @@ -0,0 +1,46 @@ +from pydantic import Field + +from ._base import SolarFarmerBaseModel +from .vector3double import Vector3Double + + +class Tracker(SolarFarmerBaseModel): + """A single-axis tracker in a 3D plant layout. + + Represents one physical tracker structure placed in the 3D scene. + The tracker axis is defined by its north and south end-points; the + mounting type and tracker system determine the rotation behaviour. + + Attributes + ---------- + id : int + Unique integer identifier for this tracker within the layout + mounting_type_id : str + Reference to a mounting type specification. Must match a key in + ``PVPlant.mounting_type_specifications`` + north_point : Vector3Double + 3D coordinates of the northern end of the tracker axis + pitch_to_left : float or None + Pitch to the adjacent tracker to the left of this one, in metres. + ``None`` if this tracker has no left neighbour + pitch_to_right : float or None + Pitch to the adjacent tracker to the right of this one, in metres. + ``None`` if this tracker has no right neighbour + south_point : Vector3Double + 3D coordinates of the southern end of the tracker axis + tracker_rotation_id : str + Reference to the tracker rotation specification that governs how + this tracker rotates throughout the day + tracker_system_id : str + Reference to a tracker system specification. Must match a key in + ``PVPlant.tracker_systems`` + """ + + id: int + mounting_type_id: str = Field(..., alias="mountingTypeID", min_length=1) + north_point: Vector3Double + pitch_to_left: float | None = None + pitch_to_right: float | None = None + south_point: Vector3Double + tracker_rotation_id: str = Field(..., alias="trackerRotationID", min_length=1) + tracker_system_id: str = Field(..., alias="trackerSystemID", min_length=1) diff --git a/solarfarmer/models/trackers.py b/solarfarmer/models/trackers.py new file mode 100644 index 0000000..2d558c8 --- /dev/null +++ b/solarfarmer/models/trackers.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from ._base import SolarFarmerBaseModel +from .tracker import Tracker + + +class Trackers(SolarFarmerBaseModel): + """A collection of single-axis trackers in a 3D plant layout. + + Attributes + ---------- + trackers : list[Tracker] + The individual tracker objects making up this collection + """ + + trackers: list[Tracker] = Field(default_factory=list) diff --git a/solarfarmer/models/vector3double.py b/solarfarmer/models/vector3double.py new file mode 100644 index 0000000..3d8c2a1 --- /dev/null +++ b/solarfarmer/models/vector3double.py @@ -0,0 +1,19 @@ +from ._base import SolarFarmerBaseModel + + +class Vector3Double(SolarFarmerBaseModel): + """A 3D point or vector with double-precision floating-point coordinates. + + Attributes + ---------- + x : float + X coordinate in metres + y : float + Y coordinate in metres + z : float + Z coordinate in metres + """ + + x: float + y: float + z: float From b097d0f0b2f7d2c0ad1af33d0c00f57aeb330fc0 Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Tue, 19 May 2026 22:11:28 +0200 Subject: [PATCH 2/4] Add composition tests for 3D racks and trackers --- tests/test_models/test_composition.py | 219 ++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/tests/test_models/test_composition.py b/tests/test_models/test_composition.py index 4993625..bfaa830 100644 --- a/tests/test_models/test_composition.py +++ b/tests/test_models/test_composition.py @@ -6,19 +6,30 @@ EnergyCalculationInputs, EnergyCalculationInputsWithFiles, EnergyCalculationOptions, + IndexedObject3D, Inverter, Layout, Location, MeteoFileFormat, + MiniSimpleTerrainDto, MonthlyAlbedo, MountingTypeSpecification, OndFileSupplements, PanFileSupplements, PVPlant, + QuadDouble, + Rack, + ShadingObjects, + SimpleTerrain, + TerrainRowDto, + TerrainRowStartEndColumnsDto, + Tracker, TrackerSystem, + Trackers, Transformer, TransformerLossModelTypes, TransformerSpecification, + Vector3Double, ) @@ -203,3 +214,211 @@ def test_tracker_plant(self) -> None: d = plant.model_dump(by_alias=True, exclude_none=True) assert "trackerSystems" in d assert d["trackerSystems"]["ts1"]["rotationMinDeg"] == -60.0 + + +class TestWithTrackers3d: + """Test composition of a 3D tracker plant using Tracker and related models.""" + + def test_tracker_objects_serialized(self) -> None: + """PVPlant.trackers serializes Tracker objects with correct camelCase keys.""" + tracker = Tracker( + id=0, + mounting_type_id="mount_t", + north_point=Vector3Double(x=0.0, y=10.0, z=0.5), + south_point=Vector3Double(x=0.0, y=0.0, z=0.0), + pitch_to_right=7.5, + tracker_rotation_id="rot1", + tracker_system_id="ts1", + ) + + mounting = MountingTypeSpecification( + is_tracker=True, + number_of_modules_high=1, + modules_are_landscape=True, + rack_height=1.5, + y_spacing_between_modules=0.03, + frame_bottom_width=0.0, + constant_heat_transfer_coefficient=29.0, + convective_heat_transfer_coefficient=0.0, + monthly_soiling_loss=[0.02] * 12, + height_of_tracker_center_from_ground=1.5, + ) + + plant = PVPlant( + transformers=[Transformer(inverters=[Inverter(inverter_spec_id="inv1", inverter_count=1)])], + mounting_type_specifications={"mount_t": mounting}, + tracker_systems={"ts1": TrackerSystem(system_plane_azimuth=0.0, system_plane_tilt=0.0, rotation_min_deg=-60.0, rotation_max_deg=60.0)}, + trackers=[tracker], + ) + + d = plant.model_dump(by_alias=True, exclude_none=True) + assert "trackers" in d + t = d["trackers"][0] + assert t["id"] == 0 + assert t["mountingTypeID"] == "mount_t" + assert t["trackerRotationID"] == "rot1" + assert t["trackerSystemID"] == "ts1" + assert t["northPoint"] == {"x": 0.0, "y": 10.0, "z": 0.5} + assert t["pitchToRight"] == 7.5 + assert "pitchToLeft" not in t # None fields excluded + + def test_trackers_collection_roundtrip(self) -> None: + """Trackers collection round-trips through JSON.""" + trackers = Trackers( + trackers=[ + Tracker( + id=0, + mounting_type_id="m1", + north_point=Vector3Double(x=1.0, y=5.0, z=0.0), + south_point=Vector3Double(x=1.0, y=0.0, z=0.0), + tracker_rotation_id="r1", + tracker_system_id="ts1", + ), + Tracker( + id=1, + mounting_type_id="m1", + north_point=Vector3Double(x=9.0, y=5.0, z=0.0), + south_point=Vector3Double(x=9.0, y=0.0, z=0.0), + tracker_rotation_id="r1", + tracker_system_id="ts1", + ), + ] + ) + + json_str = trackers.model_dump_json(by_alias=True, exclude_none=True) + rebuilt = Trackers.model_validate_json(json_str) + assert len(rebuilt.trackers) == 2 + assert rebuilt.trackers[1].id == 1 + + +class TestWithRacks3d: + """Test composition of a 3D fixed-tilt plant using Rack and related models.""" + + def test_rack_objects_serialized(self) -> None: + """PVPlant.racks serializes Rack objects with correct camelCase keys.""" + quad = QuadDouble( + p1=Vector3Double(x=0.0, y=0.0, z=0.0), + p2=Vector3Double(x=4.0, y=0.0, z=0.0), + p3=Vector3Double(x=4.0, y=2.0, z=1.0), + p4=Vector3Double(x=0.0, y=2.0, z=1.0), + ) + rack = Rack(id=0, mounting_type_id="mount_r", pitch_to_front=5.0, quad=quad) + + mounting = MountingTypeSpecification( + is_tracker=False, + number_of_modules_high=2, + modules_are_landscape=False, + rack_height=0.7, + y_spacing_between_modules=0.02, + frame_bottom_width=0.0, + constant_heat_transfer_coefficient=29.0, + convective_heat_transfer_coefficient=0.0, + monthly_soiling_loss=[0.02] * 12, + tilt=25.0, + height_of_lowest_edge_from_ground=0.5, + ) + + shading_obj = IndexedObject3D( + is_building=True, + name="warehouse", + vertices=[ + Vector3Double(x=20.0, y=0.0, z=0.0), + Vector3Double(x=30.0, y=0.0, z=0.0), + Vector3Double(x=30.0, y=10.0, z=0.0), + Vector3Double(x=20.0, y=10.0, z=0.0), + Vector3Double(x=20.0, y=0.0, z=8.0), + Vector3Double(x=30.0, y=0.0, z=8.0), + Vector3Double(x=30.0, y=10.0, z=8.0), + Vector3Double(x=20.0, y=10.0, z=8.0), + ], + quad_indices=[[0, 1, 2, 3], [4, 5, 6, 7]], + ) + + terrain = SimpleTerrain( + mini_simple_terrains=[ + MiniSimpleTerrainDto( + num_vertices_across=2, + num_vertices_down=2, + vertices=[ + Vector3Double(x=0.0, y=0.0, z=0.0), + Vector3Double(x=10.0, y=0.0, z=0.0), + Vector3Double(x=0.0, y=10.0, z=0.5), + Vector3Double(x=10.0, y=10.0, z=0.5), + ], + terrain_rows=[ + TerrainRowDto(start_end_columns=[TerrainRowStartEndColumnsDto(start_column_index=0, end_column_index=1)]), + TerrainRowDto(start_end_columns=[TerrainRowStartEndColumnsDto(start_column_index=0, end_column_index=1)]), + ], + ) + ] + ) + + plant = PVPlant( + transformers=[Transformer(inverters=[Inverter(inverter_spec_id="inv1", inverter_count=1)])], + mounting_type_specifications={"mount_r": mounting}, + racks=[rack], + shading_objects=[shading_obj], + simple_terrain=terrain, + ) + + d = plant.model_dump(by_alias=True, exclude_none=True) + + # Racks + assert "racks" in d + r = d["racks"][0] + assert r["id"] == 0 + assert r["mountingTypeID"] == "mount_r" + assert r["pitchToFront"] == 5.0 + assert "pitchToBack" not in r # None excluded + assert r["quad"]["p1"] == {"x": 0.0, "y": 0.0, "z": 0.0} + + # Shading objects + assert "shadingObjects" in d + s = d["shadingObjects"][0] + assert s["isBuilding"] is True + assert s["name"] == "warehouse" + assert len(s["vertices"]) == 8 + assert len(s["quadIndices"]) == 2 + + # Terrain + assert "simpleTerrain" in d + tile = d["simpleTerrain"]["miniSimpleTerrains"][0] + assert tile["numVerticesAcross"] == 2 + assert tile["numVerticesDown"] == 2 + assert len(tile["vertices"]) == 4 + assert tile["terrainRows"][0]["startEndColumns"][0]["startColumnIndex"] == 0 + + def test_racks_collection_roundtrip(self) -> None: + """Racks collection round-trips through JSON.""" + from solarfarmer.models import Racks + + racks = Racks( + racks=[ + Rack( + id=0, + mounting_type_id="m1", + quad=QuadDouble( + p1=Vector3Double(x=0.0, y=0.0, z=0.0), + p2=Vector3Double(x=4.0, y=0.0, z=0.0), + p3=Vector3Double(x=4.0, y=2.0, z=1.0), + p4=Vector3Double(x=0.0, y=2.0, z=1.0), + ), + ), + Rack( + id=1, + mounting_type_id="m1", + quad=QuadDouble( + p1=Vector3Double(x=0.0, y=6.0, z=0.0), + p2=Vector3Double(x=4.0, y=6.0, z=0.0), + p3=Vector3Double(x=4.0, y=8.0, z=1.0), + p4=Vector3Double(x=0.0, y=8.0, z=1.0), + ), + ), + ] + ) + + json_str = racks.model_dump_json(by_alias=True, exclude_none=True) + rebuilt = Racks.model_validate_json(json_str) + assert len(rebuilt.racks) == 2 + assert rebuilt.racks[1].id == 1 + assert rebuilt.racks[0].quad.p3.z == 1.0 From 75b28e6eab06a8b3b84ed56d4d09d23fa983920c Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Wed, 20 May 2026 15:10:09 +0200 Subject: [PATCH 3/4] Fix lint error --- tests/test_models/test_composition.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_models/test_composition.py b/tests/test_models/test_composition.py index bfaa830..7c0fbc5 100644 --- a/tests/test_models/test_composition.py +++ b/tests/test_models/test_composition.py @@ -19,13 +19,12 @@ PVPlant, QuadDouble, Rack, - ShadingObjects, SimpleTerrain, TerrainRowDto, TerrainRowStartEndColumnsDto, Tracker, - TrackerSystem, Trackers, + TrackerSystem, Transformer, TransformerLossModelTypes, TransformerSpecification, From dda5185b32a350ac5d891d73a41c4f3aa0e3eea7 Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Wed, 20 May 2026 15:13:31 +0200 Subject: [PATCH 4/4] Fix format --- tests/test_models/test_composition.py | 33 +++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/tests/test_models/test_composition.py b/tests/test_models/test_composition.py index 7c0fbc5..704d532 100644 --- a/tests/test_models/test_composition.py +++ b/tests/test_models/test_composition.py @@ -244,9 +244,18 @@ def test_tracker_objects_serialized(self) -> None: ) plant = PVPlant( - transformers=[Transformer(inverters=[Inverter(inverter_spec_id="inv1", inverter_count=1)])], + transformers=[ + Transformer(inverters=[Inverter(inverter_spec_id="inv1", inverter_count=1)]) + ], mounting_type_specifications={"mount_t": mounting}, - tracker_systems={"ts1": TrackerSystem(system_plane_azimuth=0.0, system_plane_tilt=0.0, rotation_min_deg=-60.0, rotation_max_deg=60.0)}, + tracker_systems={ + "ts1": TrackerSystem( + system_plane_azimuth=0.0, + system_plane_tilt=0.0, + rotation_min_deg=-60.0, + rotation_max_deg=60.0, + ) + }, trackers=[tracker], ) @@ -345,15 +354,29 @@ def test_rack_objects_serialized(self) -> None: Vector3Double(x=10.0, y=10.0, z=0.5), ], terrain_rows=[ - TerrainRowDto(start_end_columns=[TerrainRowStartEndColumnsDto(start_column_index=0, end_column_index=1)]), - TerrainRowDto(start_end_columns=[TerrainRowStartEndColumnsDto(start_column_index=0, end_column_index=1)]), + TerrainRowDto( + start_end_columns=[ + TerrainRowStartEndColumnsDto( + start_column_index=0, end_column_index=1 + ) + ] + ), + TerrainRowDto( + start_end_columns=[ + TerrainRowStartEndColumnsDto( + start_column_index=0, end_column_index=1 + ) + ] + ), ], ) ] ) plant = PVPlant( - transformers=[Transformer(inverters=[Inverter(inverter_spec_id="inv1", inverter_count=1)])], + transformers=[ + Transformer(inverters=[Inverter(inverter_spec_id="inv1", inverter_count=1)]) + ], mounting_type_specifications={"mount_r": mounting}, racks=[rack], shading_objects=[shading_obj],