Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions solarfarmer/models/indexed_object3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand All @@ -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)
10 changes: 8 additions & 2 deletions solarfarmer/models/inverter_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ 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)

Expand All @@ -63,6 +65,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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this pre-existing block more strict than sf-core validation and should be removed? the new block and pydantic limits are sufficient?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new validation check for API v7 (in the upcoming release) that originated from a couple of support issues recently. For those using the SDK, it gets validated it. Those using their own code, the validation service will catch it.

raise ValueError(
"Provide either dc_ohmic_connector_resistance or dc_ohmic_connector_loss, not both. "
Expand Down
4 changes: 2 additions & 2 deletions solarfarmer/models/mini_simple_terrain_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
8 changes: 4 additions & 4 deletions solarfarmer/models/module_index_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 4 additions & 2 deletions solarfarmer/models/terrain_row_start_end_columns_dto.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from pydantic import Field

from ._base import SolarFarmerBaseModel


Expand All @@ -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)
13 changes: 7 additions & 6 deletions solarfarmer/models/tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
2 changes: 1 addition & 1 deletion solarfarmer/models/tracker_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 1 addition & 3 deletions tests/test_endpoint_about.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", [("v4", "4."), ("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
):
Expand Down
1 change: 1 addition & 0 deletions tests/test_models/test_composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions tests/test_models/test_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Loading