diff --git a/solarfarmer/__init__.py b/solarfarmer/__init__.py index e748d72..c1a786c 100644 --- a/solarfarmer/__init__.py +++ b/solarfarmer/__init__.py @@ -35,14 +35,19 @@ EnergyCalculationOptions, HorizonType, IAMModelTypeForOverride, + IndexedObject3D, Inverter, + InverterInput, InverterOverPowerShutdownMode, InverterType, Layout, Location, MeteoFileFormat, + MiniSimpleTerrainDto, MissingMetDataMethod, ModelChainResponse, + ModuleIndexRange, + ModuleString, MonthlyAlbedo, MountingType, MountingTypeSpecification, @@ -50,13 +55,24 @@ OrderColumnsPvSystFormatTimeSeries, OrientationType, PanFileSupplements, + PowerOptimizerOperationType, PVPlant, PVSystem, + QuadDouble, + Rack, + Racks, + ShadingObjects, + SimpleTerrain, + TerrainRowDto, + TerrainRowStartEndColumnsDto, + Tracker, + Trackers, TrackerSystem, Transformer, TransformerLossModelTypes, TransformerSpecification, ValidationMessage, + Vector3Double, ) from .weather import ( TSV_COLUMNS, @@ -99,14 +115,19 @@ "EnergyCalculationOptions", "HorizonType", "IAMModelTypeForOverride", + "IndexedObject3D", "Inverter", + "InverterInput", "InverterOverPowerShutdownMode", "InverterType", "Layout", "Location", "MeteoFileFormat", + "MiniSimpleTerrainDto", "MissingMetDataMethod", "ModelChainResponse", + "ModuleIndexRange", + "ModuleString", "MonthlyAlbedo", "MountingType", "MountingTypeSpecification", @@ -114,18 +135,29 @@ "OrderColumnsPvSystFormatTimeSeries", "OrientationType", "PanFileSupplements", + "PowerOptimizerOperationType", "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..87c15ff 100644 --- a/solarfarmer/models/__init__.py +++ b/solarfarmer/models/__init__.py @@ -14,12 +14,18 @@ MeteoFileFormat, MissingMetDataMethod, OrderColumnsPvSystFormatTimeSeries, + PowerOptimizerOperationType, TransformerLossModelTypes, ) +from .indexed_object3d import IndexedObject3D from .inverter import Inverter +from .inverter_input import InverterInput from .layout import Layout from .location import Location +from .mini_simple_terrain_dto import MiniSimpleTerrainDto from .model_chain_response import ModelChainResponse +from .module_index_range import ModuleIndexRange +from .module_string import ModuleString from .monthly_albedo import MonthlyAlbedo from .mounting_type_specification import MountingTypeSpecification from .ond_supplements import OndFileSupplements @@ -28,9 +34,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 +57,17 @@ "EnergyCalculationOptions", "HorizonType", "IAMModelTypeForOverride", + "IndexedObject3D", "Inverter", + "InverterInput", "InverterOverPowerShutdownMode", "InverterType", + "ModuleIndexRange", + "ModuleString", "Layout", "Location", "MeteoFileFormat", + "MiniSimpleTerrainDto", "MissingMetDataMethod", "ModelChainResponse", "MonthlyAlbedo", @@ -56,12 +77,23 @@ "OrderColumnsPvSystFormatTimeSeries", "OrientationType", "PanFileSupplements", + "PowerOptimizerOperationType", "PVPlant", "PVSystem", + "QuadDouble", + "Rack", + "Racks", + "ShadingObjects", + "SimpleTerrain", "SolarFarmerBaseModel", + "TerrainRowDto", + "TerrainRowStartEndColumnsDto", + "Tracker", "TrackerSystem", + "Trackers", "Transformer", "TransformerLossModelTypes", "TransformerSpecification", "ValidationMessage", + "Vector3Double", ] diff --git a/solarfarmer/models/enums.py b/solarfarmer/models/enums.py index 0f22de9..d02deef 100644 --- a/solarfarmer/models/enums.py +++ b/solarfarmer/models/enums.py @@ -51,6 +51,21 @@ class InverterOverPowerShutdownMode(str, Enum): USE_VOLTAGE_DERATE_PROFILE = "UseVoltageDerateProfile" +class PowerOptimizerOperationType(str, Enum): + """Connection configuration for DC power optimizers (3D calculations only). + + Defines how many modules share a single power optimizer and how they + are connected at the optimizer input. + """ + + ONE_PER_MODULE = "OnePerModule" + """One optimizer per module.""" + ONE_PER_TWO_MODULES_IN_PARALLEL = "OnePerTwoModulesInParallel" + """One optimizer shared by two modules connected in parallel.""" + ONE_PER_TWO_MODULES_IN_SERIES = "OnePerTwoModulesInSeries" + """One optimizer shared by two modules connected in series.""" + + class IAMModelTypeForOverride(str, Enum): """IAM model types available for overriding the PAN file default.""" 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/inverter.py b/solarfarmer/models/inverter.py index 3ba5c60..e06f056 100644 --- a/solarfarmer/models/inverter.py +++ b/solarfarmer/models/inverter.py @@ -1,10 +1,9 @@ from __future__ import annotations -from typing import Any - from pydantic import Field from ._base import SolarFarmerBaseModel +from .inverter_input import InverterInput from .layout import Layout @@ -22,8 +21,9 @@ class Inverter(SolarFarmerBaseModel): Number of identical inverters, >= 1 layouts : list[Layout] or None Layouts connected to this inverter (2D calculations) - inverter_inputs : list[Any] or None - Inverter inputs for 3D calculations + inverter_inputs : list[InverterInput] or None + Inverter MPPT inputs for 3D calculations. Each entry defines the + module strings and DC losses for one input ac_wiring_ohmic_loss : float AC wiring ohmic loss as a fraction, range [0, 1] name : str or None @@ -33,6 +33,6 @@ class Inverter(SolarFarmerBaseModel): inverter_spec_id: str = Field(..., alias="inverterSpecID", min_length=1) inverter_count: int = Field(..., ge=1) layouts: list[Layout] | None = None - inverter_inputs: list[Any] | None = None + inverter_inputs: list[InverterInput] | None = None ac_wiring_ohmic_loss: float = Field(0.0, ge=0, le=1) name: str | None = None diff --git a/solarfarmer/models/inverter_input.py b/solarfarmer/models/inverter_input.py new file mode 100644 index 0000000..714f333 --- /dev/null +++ b/solarfarmer/models/inverter_input.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from pydantic import Field, model_validator + +from ._base import SolarFarmerBaseModel +from .enums import PowerOptimizerOperationType +from .module_string import ModuleString + + +class InverterInput(SolarFarmerBaseModel): + """An inverter input instance. Used for 3D calculations only. + + Defines the set of module strings connected to a single MPPT input of an + inverter, together with the DC wiring losses and optional power optimizer + configuration. + + Attributes + ---------- + module_specification_id : str + Reference to the PV module specification used by all strings on this + input. Must match a key in ``PVPlant.module_specifications`` (or the + stem of a PAN file uploaded alongside the payload) + module_strings : list[ModuleString] + All module strings attached to this inverter input + dc_ohmic_connector_loss : float + DC cabling ohmic loss as a fraction of DC output power at STC, range [0, 1]. + module_mismatch_loss : float + Simple fractional loss to account for module mismatch, range [0, 0.1]. + A value of ``0.01`` represents a 1 % loss + dc_ohmic_connector_resistance : float or None + DC cabling resistance in ohms (Ω). Alternative to + ``dc_ohmic_connector_loss``: when non-null this value is used directly + and ``dc_ohmic_connector_loss`` is ignored + module_quality_factor : float or None + Fractional adjustment for other modelling corrections, range [-0.4, 0.1]. + A positive value implies a gain; negative implies a loss + optimizer_specification_id : str or None + Reference to the DC power optimizer specification. Must match a key in + ``PVPlant.optimizer_specifications`` when power optimizers are present + optimizers_per_module : PowerOptimizerOperationType or None + Number and connection configuration of power optimizers per module. + Required when ``optimizer_specification_id`` is provided + fixed_voltage_from_inverter : float or None + Fixed DC bus voltage (V) assumed at the inverter output when power + optimizers are in place + """ + + module_specification_id: str = Field(..., alias="moduleSpecificationID", min_length=1) + module_strings: list[ModuleString] = Field(default_factory=list) + dc_ohmic_connector_loss: float = Field(0.0, ge=0, le=1) + module_mismatch_loss: float = Field(0.0, ge=0, le=0.1) + dc_ohmic_connector_resistance: float | None = Field(None, ge=0) + module_quality_factor: float | None = Field(None, ge=-0.4, le=0.1) + optimizer_specification_id: str | None = Field(None, alias="optimizerSpecificationID") + optimizers_per_module: PowerOptimizerOperationType | None = None + fixed_voltage_from_inverter: float | None = Field(None, ge=0) + + @model_validator(mode="after") + def _check_invariants(self) -> InverterInput: + has_optimizer_id = self.optimizer_specification_id is not None + has_optimizer_type = self.optimizers_per_module is not None + if has_optimizer_id != has_optimizer_type: + raise ValueError( + "optimizer_specification_id and optimizers_per_module must be provided together" + ) + if self.dc_ohmic_connector_resistance is not None and self.dc_ohmic_connector_loss != 0.0: + raise ValueError( + "Provide either dc_ohmic_connector_resistance or dc_ohmic_connector_loss, not both. " + "When dc_ohmic_connector_resistance is set, dc_ohmic_connector_loss is ignored." + ) + return self 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/module_index_range.py b/solarfarmer/models/module_index_range.py new file mode 100644 index 0000000..de3de16 --- /dev/null +++ b/solarfarmer/models/module_index_range.py @@ -0,0 +1,32 @@ +from pydantic import Field + +from ._base import SolarFarmerBaseModel + + +class ModuleIndexRange(SolarFarmerBaseModel): + """A range of indices denoting where modules are placed on a rack or tracker. + + Used in 3D calculations to map a contiguous sequence of modules on a + mounting structure to a string. Multiple ranges may be chained in order + to build up a full string across more than one rack. + + Attributes + ---------- + mounting_id : int + The rack or tracker ID this module index range refers to. Must match + an ``id`` in ``PVPlant.racks`` or ``PVPlant.trackers`` + start_x : int + First index (inclusive) of this range of modules along the length of + a row on the mount. May be larger than ``end_x`` when the string runs + in the reverse direction along the rack + end_x : int + Last index (inclusive) of this range of modules along the length of + a row on the mount. May be smaller than ``start_x`` + y : int + Row index on the mount where the range is located (0 = bottom edge) + """ + + mounting_id: int = Field(..., alias="mountingID") + start_x: int + end_x: int + y: int diff --git a/solarfarmer/models/module_string.py b/solarfarmer/models/module_string.py new file mode 100644 index 0000000..dca3f28 --- /dev/null +++ b/solarfarmer/models/module_string.py @@ -0,0 +1,24 @@ +from pydantic import Field + +from ._base import SolarFarmerBaseModel +from .module_index_range import ModuleIndexRange + + +class ModuleString(SolarFarmerBaseModel): + """A string of modules defined as an ordered list of module index ranges. + + Used in 3D calculations to describe the physical arrangement of one + series-connected string across one or more racks or trackers. Each + :class:`ModuleIndexRange` entry defines a contiguous segment of modules; + multiple ranges are taken in order to form the complete string. + + Attributes + ---------- + module_index_ranges : list[ModuleIndexRange] + Ordered list of index ranges that together define all modules in + this string. Ranges are traversed in sequence: the string begins + at the first index of the first range and ends at the last index + of the final range + """ + + module_index_ranges: list[ModuleIndexRange] = 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 diff --git a/tests/test_models/test_composition.py b/tests/test_models/test_composition.py index 4993625..ce6ae42 100644 --- a/tests/test_models/test_composition.py +++ b/tests/test_models/test_composition.py @@ -6,19 +6,33 @@ EnergyCalculationInputs, EnergyCalculationInputsWithFiles, EnergyCalculationOptions, + IndexedObject3D, Inverter, + InverterInput, Layout, Location, MeteoFileFormat, + MiniSimpleTerrainDto, + ModuleIndexRange, + ModuleString, MonthlyAlbedo, MountingTypeSpecification, OndFileSupplements, PanFileSupplements, + PowerOptimizerOperationType, PVPlant, + QuadDouble, + Rack, + SimpleTerrain, + TerrainRowDto, + TerrainRowStartEndColumnsDto, + Tracker, + Trackers, TrackerSystem, Transformer, TransformerLossModelTypes, TransformerSpecification, + Vector3Double, ) @@ -203,3 +217,484 @@ 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 + + +class TestWith3DInverterInputs: + """Test 3D plant composition using InverterInput, ModuleString, and ModuleIndexRange.""" + + def _build_3d_plant(self) -> PVPlant: + """Build a minimal 3D fixed-tilt plant using InverterInput wiring. + + Inspired by a real 3-inverter, 19-rack site but reduced to 3 racks and + 2 strings per MPPT input for clarity. + """ + # String A spans the full first rack and part of the second + string_a = ModuleString( + module_index_ranges=[ + ModuleIndexRange(mounting_id=100, start_x=13, end_x=0, y=0), + ModuleIndexRange(mounting_id=101, start_x=0, end_x=9, y=0), + ] + ) + # String B picks up from the middle of the second rack through the third + string_b = ModuleString( + module_index_ranges=[ + ModuleIndexRange(mounting_id=101, start_x=10, end_x=13, y=0), + ModuleIndexRange(mounting_id=102, start_x=0, end_x=13, y=0), + ] + ) + + inv_input = InverterInput( + module_specification_id="MyModule_400W", + module_strings=[string_a, string_b], + dc_ohmic_connector_loss=0.0166, + module_mismatch_loss=0.0, + optimizer_specification_id="MyOptimizer_S440", + optimizers_per_module=PowerOptimizerOperationType.ONE_PER_TWO_MODULES_IN_SERIES, + fixed_voltage_from_inverter=850.0, + ) + + inverter = Inverter( + inverter_spec_id="MyInverter_110kW", + inverter_count=1, + inverter_inputs=[inv_input], + ac_wiring_ohmic_loss=0.0174, + ) + + transformer = Transformer( + name="Transformer 1", + transformer_count=1, + inverters=[inverter], + transformer_spec_id="TSpec_0", + ) + + mounting_spec = MountingTypeSpecification( + is_tracker=False, + number_of_modules_high=1, + number_of_modules_long=14, + modules_are_landscape=True, + rack_height=1.134, + y_spacing_between_modules=1.134, + x_spacing_between_modules=2.462, + frame_bottom_width=0.0, + frame_top_width=0.0, + frame_end_width=0.0, + constant_heat_transfer_coefficient=20.0, + convective_heat_transfer_coefficient=1.0, + monthly_soiling_loss=[ + 0.09, + 0.07, + 0.03, + 0.0, + 0.0, + 0.0, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.06, + ], + tilt=10.0, + height_of_lowest_edge_from_ground=0.111, + transmission_factor=0.0, + bifacial_shade_loss_factor=0.02, + bifacial_mismatch_loss_factor=0.005, + ) + + transformer_spec = TransformerSpecification( + model_type=TransformerLossModelTypes.SIMPLE_LOSS_FACTOR, + loss_factor=0.0, + ) + + quad = QuadDouble( + p1=Vector3Double(x=0.0, y=0.0, z=10.0), + p2=Vector3Double(x=30.0, y=0.0, z=10.0), + p3=Vector3Double(x=30.0, y=2.0, z=10.2), + p4=Vector3Double(x=0.0, y=2.0, z=10.2), + ) + rack_a = Rack(id=100, mounting_type_id="FT_Rack_14", pitch_to_front=1.55, quad=quad) + rack_b = Rack( + id=101, + mounting_type_id="FT_Rack_14", + pitch_to_front=1.55, + pitch_to_back=1.55, + quad=quad, + ) + rack_c = Rack(id=102, mounting_type_id="FT_Rack_14", pitch_to_back=1.55, quad=quad) + + return PVPlant( + transformers=[transformer], + mounting_type_specifications={"FT_Rack_14": mounting_spec}, + transformer_specifications={"TSpec_0": transformer_spec}, + racks=[rack_a, rack_b, rack_c], + ) + + def test_build_3d_plant(self) -> None: + """3D plant is constructed with the correct nested structure.""" + plant = self._build_3d_plant() + inv_input = plant.transformers[0].inverters[0].inverter_inputs[0] + assert inv_input.module_specification_id == "MyModule_400W" + assert len(inv_input.module_strings) == 2 + assert len(inv_input.module_strings[0].module_index_ranges) == 2 + assert inv_input.module_strings[0].module_index_ranges[0].mounting_id == 100 + assert inv_input.optimizers_per_module == "OnePerTwoModulesInSeries" + assert plant.racks is not None + assert len(plant.racks) == 3 + + def test_inverter_input_serializes_with_correct_aliases(self) -> None: + """InverterInput fields use camelCase aliases and not layouts.""" + plant = self._build_3d_plant() + d = plant.model_dump(by_alias=True, exclude_none=True) + + inverter = d["transformers"][0]["inverters"][0] + assert "inverterInputs" in inverter + assert "layouts" not in inverter # 3D uses inverterInputs, not layouts + + inv_input = inverter["inverterInputs"][0] + assert inv_input["moduleSpecificationID"] == "MyModule_400W" + assert inv_input["optimizerSpecificationID"] == "MyOptimizer_S440" + assert inv_input["optimizersPerModule"] == "OnePerTwoModulesInSeries" + assert inv_input["fixedVoltageFromInverter"] == 850.0 + assert inv_input["dcOhmicConnectorLoss"] == 0.0166 + assert "moduleStrings" in inv_input + + def test_module_index_range_uses_mounting_id_alias(self) -> None: + """ModuleIndexRange serializes mounting_id as mountingID (explicit alias).""" + plant = self._build_3d_plant() + d = plant.model_dump(by_alias=True, exclude_none=True) + + range_0 = d["transformers"][0]["inverters"][0]["inverterInputs"][0]["moduleStrings"][0][ + "moduleIndexRanges" + ][0] + assert "mountingID" in range_0 + assert "mountingId" not in range_0 # must not fall back to to_camel default + assert range_0["mountingID"] == 100 + assert range_0["startX"] == 13 + assert range_0["endX"] == 0 + assert range_0["y"] == 0 + + def test_optimizer_enum_all_variants_serialize(self) -> None: + """All three PowerOptimizerOperationType values serialize to their API string.""" + cases = [ + (PowerOptimizerOperationType.ONE_PER_MODULE, "OnePerModule"), + ( + PowerOptimizerOperationType.ONE_PER_TWO_MODULES_IN_PARALLEL, + "OnePerTwoModulesInParallel", + ), + (PowerOptimizerOperationType.ONE_PER_TWO_MODULES_IN_SERIES, "OnePerTwoModulesInSeries"), + ] + for variant, expected in cases: + inp = InverterInput( + module_specification_id="mod", + dc_ohmic_connector_loss=0.0, + module_mismatch_loss=0.0, + optimizer_specification_id="opt_spec", + optimizers_per_module=variant, + ) + d = inp.model_dump(by_alias=True, exclude_none=True) + assert d["optimizersPerModule"] == expected + + def test_inverter_input_without_optimizer_omits_optional_fields(self) -> None: + """Optional optimizer fields are absent from the payload when not set.""" + inp = InverterInput( + module_specification_id="MyModule_400W", + module_strings=[ + ModuleString( + module_index_ranges=[ + ModuleIndexRange(mounting_id=5, start_x=0, end_x=13, y=0), + ] + ) + ], + dc_ohmic_connector_loss=0.007, + module_mismatch_loss=0.005, + ) + d = inp.model_dump(by_alias=True, exclude_none=True) + assert "optimizerSpecificationID" not in d + assert "optimizersPerModule" not in d + assert "fixedVoltageFromInverter" not in d + assert d["moduleSpecificationID"] == "MyModule_400W" + assert d["dcOhmicConnectorLoss"] == 0.007 + + def test_json_round_trip_3d_plant(self) -> None: + """Full 3D PVPlant survives a JSON round-trip with all nested 3D models intact.""" + plant = self._build_3d_plant() + json_str = plant.model_dump_json(by_alias=True, exclude_none=True) + rebuilt = PVPlant.model_validate_json(json_str) + + inv_input = rebuilt.transformers[0].inverters[0].inverter_inputs[0] + assert inv_input.module_specification_id == "MyModule_400W" + assert inv_input.optimizers_per_module == "OnePerTwoModulesInSeries" + assert inv_input.fixed_voltage_from_inverter == 850.0 + + # String B starts mid-rack on rack 101 + range_b0 = inv_input.module_strings[1].module_index_ranges[0] + assert range_b0.mounting_id == 101 + assert range_b0.start_x == 10 + + # rack_c has no pitch_to_front + d = rebuilt.model_dump(by_alias=True, exclude_none=True) + assert "pitchToFront" not in d["racks"][2] + assert d["racks"][2]["pitchToBack"] == 1.55 + + def test_multiple_inverter_inputs_per_inverter(self) -> None: + """An inverter can hold multiple InverterInput instances (one per MPPT).""" + make_input = lambda rack_id, spec: InverterInput( # noqa: E731 + module_specification_id=spec, + module_strings=[ + ModuleString( + module_index_ranges=[ + ModuleIndexRange(mounting_id=rack_id, start_x=0, end_x=13, y=0) + ] + ) + ], + dc_ohmic_connector_loss=0.01, + module_mismatch_loss=0.0, + ) + + inverter = Inverter( + inverter_spec_id="DualMPPT_Inv", + inverter_count=1, + inverter_inputs=[ + make_input(200, "ModuleA_380W"), + make_input(201, "ModuleB_400W"), + ], + ) + + d = inverter.model_dump(by_alias=True, exclude_none=True) + assert len(d["inverterInputs"]) == 2 + assert d["inverterInputs"][0]["moduleSpecificationID"] == "ModuleA_380W" + assert d["inverterInputs"][1]["moduleSpecificationID"] == "ModuleB_400W" + assert ( + d["inverterInputs"][0]["moduleStrings"][0]["moduleIndexRanges"][0]["mountingID"] == 200 + )