From 36af76f6cdf5e665e53fad29bb2fea292b2d8863 Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Mon, 20 Jul 2026 12:22:06 +0200 Subject: [PATCH 1/4] Expand validation pydantic models for 3D classes --- solarfarmer/models/indexed_object3d.py | 12 +++++++----- solarfarmer/models/inverter_input.py | 8 ++++++-- solarfarmer/models/mini_simple_terrain_dto.py | 4 ++-- solarfarmer/models/module_index_range.py | 8 ++++---- .../models/terrain_row_start_end_columns_dto.py | 6 ++++-- solarfarmer/models/tracker.py | 13 +++++++------ tests/test_models/test_composition.py | 1 + 7 files changed, 31 insertions(+), 21 deletions(-) diff --git a/solarfarmer/models/indexed_object3d.py b/solarfarmer/models/indexed_object3d.py index c9b73ab..44d556c 100644 --- a/solarfarmer/models/indexed_object3d.py +++ b/solarfarmer/models/indexed_object3d.py @@ -16,9 +16,11 @@ class IndexedObject3D(SolarFarmerBaseModel): ---------- 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 + and irradiance modelling assumptions). Defaults to ``False`` when + omitted from the server payload + name : str or None + Descriptive name for this object in the 3D scene. May be ``None`` + when the server omits it quad_indices : list[list[int]] Face connectivity for quadrilateral faces. Each inner list contains four vertex indices referencing entries in ``vertices`` @@ -29,8 +31,8 @@ class IndexedObject3D(SolarFarmerBaseModel): 3D vertex positions shared by both quad and triangle faces """ - is_building: bool - name: str + is_building: bool = False + name: str | None = None 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_input.py b/solarfarmer/models/inverter_input.py index 714f333..c013753 100644 --- a/solarfarmer/models/inverter_input.py +++ b/solarfarmer/models/inverter_input.py @@ -49,9 +49,9 @@ class InverterInput(SolarFarmerBaseModel): 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) + dc_ohmic_connector_resistance: float | None = Field(None, ge=0, le=10) module_quality_factor: float | None = Field(None, ge=-0.4, le=0.1) - optimizer_specification_id: str | None = Field(None, alias="optimizerSpecificationID") + optimizer_specification_id: str | None = Field(None, alias="optimizerSpecificationID", min_length=1) optimizers_per_module: PowerOptimizerOperationType | None = None fixed_voltage_from_inverter: float | None = Field(None, ge=0) @@ -63,6 +63,10 @@ def _check_invariants(self) -> InverterInput: raise ValueError( "optimizer_specification_id and optimizers_per_module must be provided together" ) + if has_optimizer_id and self.fixed_voltage_from_inverter is None: + raise ValueError( + "fixed_voltage_from_inverter is required when power optimizers are configured" + ) 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. " diff --git a/solarfarmer/models/mini_simple_terrain_dto.py b/solarfarmer/models/mini_simple_terrain_dto.py index 2d0c004..844cae4 100644 --- a/solarfarmer/models/mini_simple_terrain_dto.py +++ b/solarfarmer/models/mini_simple_terrain_dto.py @@ -29,7 +29,7 @@ class MiniSimpleTerrainDto(SolarFarmerBaseModel): ``num_vertices_across`` entries) """ - num_vertices_across: int - num_vertices_down: int + num_vertices_across: int = Field(..., ge=0) + num_vertices_down: int = Field(..., ge=0) 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 index de3de16..42de6ee 100644 --- a/solarfarmer/models/module_index_range.py +++ b/solarfarmer/models/module_index_range.py @@ -26,7 +26,7 @@ class ModuleIndexRange(SolarFarmerBaseModel): 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 + mounting_id: int = Field(..., alias="mountingID", ge=0) + start_x: int = Field(..., ge=0) + end_x: int = Field(..., ge=0) + y: int = Field(..., ge=0) diff --git a/solarfarmer/models/terrain_row_start_end_columns_dto.py b/solarfarmer/models/terrain_row_start_end_columns_dto.py index 37fb49a..07f3663 100644 --- a/solarfarmer/models/terrain_row_start_end_columns_dto.py +++ b/solarfarmer/models/terrain_row_start_end_columns_dto.py @@ -1,3 +1,5 @@ +from pydantic import Field + from ._base import SolarFarmerBaseModel @@ -16,5 +18,5 @@ class TerrainRowStartEndColumnsDto(SolarFarmerBaseModel): Zero-based index of the last active column in the row (inclusive) """ - start_column_index: int - end_column_index: int + start_column_index: int = Field(..., ge=0) + end_column_index: int = Field(..., ge=0) diff --git a/solarfarmer/models/tracker.py b/solarfarmer/models/tracker.py index 8bae944..ba22bc3 100644 --- a/solarfarmer/models/tracker.py +++ b/solarfarmer/models/tracker.py @@ -28,12 +28,13 @@ class Tracker(SolarFarmerBaseModel): ``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 + tracker_rotation_id : str or None Reference to the tracker rotation specification that governs how - this tracker rotates throughout the day - tracker_system_id : str + this tracker rotates throughout the day. ``None`` when no custom + rotation schedule is assigned (the server default is used) + tracker_system_id : str or None Reference to a tracker system specification. Must match a key in - ``PVPlant.tracker_systems`` + ``PVPlant.tracker_systems``. ``None`` for fixed-tilt systems """ id: int @@ -42,5 +43,5 @@ class Tracker(SolarFarmerBaseModel): 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) + tracker_rotation_id: str | None = Field(None, alias="trackerRotationID") + tracker_system_id: str | None = Field(None, alias="trackerSystemID") diff --git a/tests/test_models/test_composition.py b/tests/test_models/test_composition.py index ce6ae42..ee3b621 100644 --- a/tests/test_models/test_composition.py +++ b/tests/test_models/test_composition.py @@ -621,6 +621,7 @@ def test_optimizer_enum_all_variants_serialize(self) -> None: module_mismatch_loss=0.0, optimizer_specification_id="opt_spec", optimizers_per_module=variant, + fixed_voltage_from_inverter=48.0, ) d = inp.model_dump(by_alias=True, exclude_none=True) assert d["optimizersPerModule"] == expected From 8bfe69b261971ef77d2c364a38597cee1dadeec6 Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Mon, 20 Jul 2026 12:22:37 +0200 Subject: [PATCH 2/4] Fix test with deprecated API v4 --- tests/test_endpoint_about.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_endpoint_about.py b/tests/test_endpoint_about.py index b33a413..8983e2d 100644 --- a/tests/test_endpoint_about.py +++ b/tests/test_endpoint_about.py @@ -31,7 +31,7 @@ def test_about_version_structure(self, api_key): assert len(result["solarFarmerApiVersion"]) > 0 @pytest.mark.parametrize( - "version,expected_version_prefix", [("v4", "4."), ("v5", "5."), ("v6", "6.")] + "version,expected_version_prefix", [("v5", "5."), ("v6", "6.")] ) def test_about_all_versions_return_proper_structure( self, api_key, version, expected_version_prefix From 1b56d6501d1dc36f5b0d19c092c19e05ae9c80ed Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Mon, 20 Jul 2026 12:30:24 +0200 Subject: [PATCH 3/4] Fix ruff format for long line instance --- solarfarmer/models/inverter_input.py | 4 +++- tests/test_endpoint_about.py | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/solarfarmer/models/inverter_input.py b/solarfarmer/models/inverter_input.py index c013753..b34f0f3 100644 --- a/solarfarmer/models/inverter_input.py +++ b/solarfarmer/models/inverter_input.py @@ -51,7 +51,9 @@ class InverterInput(SolarFarmerBaseModel): module_mismatch_loss: float = Field(0.0, ge=0, le=0.1) dc_ohmic_connector_resistance: float | None = Field(None, ge=0, le=10) module_quality_factor: float | None = Field(None, ge=-0.4, le=0.1) - optimizer_specification_id: str | None = Field(None, alias="optimizerSpecificationID", min_length=1) + optimizer_specification_id: str | None = Field( + None, alias="optimizerSpecificationID", min_length=1 + ) optimizers_per_module: PowerOptimizerOperationType | None = None fixed_voltage_from_inverter: float | None = Field(None, ge=0) diff --git a/tests/test_endpoint_about.py b/tests/test_endpoint_about.py index 8983e2d..cebc217 100644 --- a/tests/test_endpoint_about.py +++ b/tests/test_endpoint_about.py @@ -30,9 +30,7 @@ def test_about_version_structure(self, api_key): assert len(result["solarFarmerCoreVersion"]) > 0 assert len(result["solarFarmerApiVersion"]) > 0 - @pytest.mark.parametrize( - "version,expected_version_prefix", [("v5", "5."), ("v6", "6.")] - ) + @pytest.mark.parametrize("version,expected_version_prefix", [("v5", "5."), ("v6", "6.")]) def test_about_all_versions_return_proper_structure( self, api_key, version, expected_version_prefix ): From 786f9096888bdbc72a1a2efbec344f47745612c4 Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Tue, 21 Jul 2026 10:14:27 +0200 Subject: [PATCH 4/4] Fix TrackerSystem.east_west_gcr serialization alias. Added tests for 3D pydantic model to serialize into sample Matera tutorial 3D payload. --- solarfarmer/models/tracker_system.py | 2 +- tests/test_models/test_serialization.py | 46 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/solarfarmer/models/tracker_system.py b/solarfarmer/models/tracker_system.py index 6437d77..8c8d295 100644 --- a/solarfarmer/models/tracker_system.py +++ b/solarfarmer/models/tracker_system.py @@ -31,6 +31,6 @@ class TrackerSystem(SolarFarmerBaseModel): rotation_min_deg: float = Field(0.0, ge=-90, le=0) rotation_max_deg: float = Field(0.0, ge=0, le=90) tracker_azimuth: float | None = None - east_west_gcr: float | None = None + east_west_gcr: float | None = Field(None, alias="eastWestGCR") is_backtracking: bool | None = None use_slope_aware_backtracking: bool | None = None diff --git a/tests/test_models/test_serialization.py b/tests/test_models/test_serialization.py index a925998..deeb4f3 100644 --- a/tests/test_models/test_serialization.py +++ b/tests/test_models/test_serialization.py @@ -261,3 +261,49 @@ def test_meteo_file_format_value(self) -> None: ) d = wrapper.model_dump(by_alias=True, exclude_none=True) assert d["meteoFileFormat"] == "tsv" + + +class TestKnownJson3DTrackers: + """EnergyCalculationInputs round-trips correctly against the known-good 3D tracker JSON.""" + + @pytest.fixture() + def matera_json(self, sample_data_dir) -> dict: + path = sample_data_dir / "Inputs_Matera_3D_trackers" / "EnergyCalcInputs.json" + return json.loads(path.read_text(encoding="utf-8")) + + @pytest.fixture() + def matera_inputs(self, matera_json) -> EnergyCalculationInputs: + return EnergyCalculationInputs.model_validate(matera_json) + + @pytest.fixture() + def matera_dumped(self, matera_inputs) -> dict: + return matera_inputs.model_dump(by_alias=True, exclude_none=True) + + def test_parses_without_error(self, matera_inputs) -> None: + assert isinstance(matera_inputs, EnergyCalculationInputs) + + def test_tracker_system_east_west_gcr_key_is_uppercase(self, matera_dumped) -> None: + tracker_systems = matera_dumped["pvPlant"]["trackerSystems"] + for system in tracker_systems.values(): + assert "eastWestGCR" in system, "alias must be eastWestGCR, not eastWestGcr" + assert "eastWestGcr" not in system + + def test_tracker_system_east_west_gcr_value(self, matera_json, matera_dumped) -> None: + ref = matera_json["pvPlant"]["trackerSystems"] + out = matera_dumped["pvPlant"]["trackerSystems"] + for key in ref: + assert out[key]["eastWestGCR"] == pytest.approx(ref[key]["eastWestGCR"]) + + def test_location_round_trips(self, matera_json, matera_dumped) -> None: + ref = matera_json["location"] + out = matera_dumped["location"] + assert out["latitude"] == pytest.approx(ref["latitude"]) + assert out["longitude"] == pytest.approx(ref["longitude"]) + assert out["altitude"] == pytest.approx(ref["altitude"]) + + def test_tracker_system_rotation_limits(self, matera_json, matera_dumped) -> None: + ref = matera_json["pvPlant"]["trackerSystems"] + out = matera_dumped["pvPlant"]["trackerSystems"] + for key in ref: + assert out[key]["rotationMinDeg"] == pytest.approx(ref[key]["rotationMinDeg"]) + assert out[key]["rotationMaxDeg"] == pytest.approx(ref[key]["rotationMaxDeg"])