From 78e9d8056ff679734c8f44b968473f7f10312b4e Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Fri, 17 Jul 2026 13:58:30 +0200 Subject: [PATCH 1/8] Add new TrackerAlgorithm definition to TrackerSystem --- solarfarmer/models/enums.py | 16 ++++++++ solarfarmer/models/tracker_system.py | 6 +++ tests/test_models/test_serialization.py | 51 +++++++++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/solarfarmer/models/enums.py b/solarfarmer/models/enums.py index d02deef..e195299 100644 --- a/solarfarmer/models/enums.py +++ b/solarfarmer/models/enums.py @@ -76,6 +76,22 @@ class IAMModelTypeForOverride(str, Enum): CUSTOM = "Custom" +class TrackerAlgorithm(str, Enum): + """Rotation algorithm used by a single-axis tracker system. + + Determines how the tracker computes its rotation angle at each time step. + """ + + CUSTOM_ROTATIONS = "CustomRotations" + """User-supplied rotation table drives the tracker angle.""" + SLOPE_AWARE_BACKTRACKING = "SlopeAwareBacktracking" + """Backtracking algorithm that accounts for terrain slope.""" + STANDARD_BACKTRACKING = "StandardBacktracking" + """Standard backtracking algorithm (no slope correction).""" + SUN_TRACKING = "SunTracking" + """Pure astronomical sun-tracking with no backtracking.""" + + class MeteoFileFormat(str, Enum): """Meteorological file format. diff --git a/solarfarmer/models/tracker_system.py b/solarfarmer/models/tracker_system.py index 6437d77..c9f0197 100644 --- a/solarfarmer/models/tracker_system.py +++ b/solarfarmer/models/tracker_system.py @@ -1,6 +1,7 @@ from pydantic import Field from ._base import SolarFarmerBaseModel +from .enums import TrackerAlgorithm class TrackerSystem(SolarFarmerBaseModel): @@ -24,6 +25,10 @@ class TrackerSystem(SolarFarmerBaseModel): Whether backtracking is enabled. Default in the engine is True use_slope_aware_backtracking : bool or None Whether slope-aware backtracking is used. Default in the engine is True + tracker_algorithm : TrackerAlgorithm or None + Rotation algorithm the tracker uses to determine its angle at each + time step. See :class:`~solarfarmer.models.TrackerAlgorithm` for + allowed values. If ``None`` the engine uses its own default. """ system_plane_azimuth: float @@ -34,3 +39,4 @@ class TrackerSystem(SolarFarmerBaseModel): east_west_gcr: float | None = None is_backtracking: bool | None = None use_slope_aware_backtracking: bool | None = None + tracker_algorithm: TrackerAlgorithm | None = None diff --git a/tests/test_models/test_serialization.py b/tests/test_models/test_serialization.py index a925998..b7bed8e 100644 --- a/tests/test_models/test_serialization.py +++ b/tests/test_models/test_serialization.py @@ -17,6 +17,7 @@ OndFileSupplements, PanFileSupplements, PVPlant, + TrackerAlgorithm, TrackerSystem, Transformer, TransformerLossModelTypes, @@ -166,6 +167,42 @@ def test_tracker_system_round_trip(self) -> None: rebuilt = TrackerSystem.model_validate(ts.model_dump(by_alias=True)) assert rebuilt == ts + def test_tracker_algorithm_serializes(self) -> None: + ts = TrackerSystem( + system_plane_azimuth=0.0, + system_plane_tilt=0.0, + tracker_algorithm=TrackerAlgorithm.CUSTOM_ROTATIONS, + ) + d = ts.model_dump(by_alias=True, exclude_none=True) + assert d["trackerAlgorithm"] == "CustomRotations" + + def test_tracker_algorithm_absent_when_none(self) -> None: + ts = TrackerSystem(system_plane_azimuth=0.0, system_plane_tilt=0.0) + d = ts.model_dump(by_alias=True, exclude_none=True) + assert "trackerAlgorithm" not in d + + def test_return_tracker_time_series_fields_serialize(self) -> None: + opts = EnergyCalculationOptions( + diffuse_model=DiffuseModel.PEREZ, + include_horizon=False, + return_tracker_rotations_time_series=True, + return_tracker_incidence_angles_time_series=True, + ) + d = opts.model_dump(by_alias=True) + assert d["returnTrackerRotationsTimeSeries"] is True + assert d["returnTrackerIncidenceAnglesTimeSeries"] is True + + def test_custom_tracker_rotations_at_middle_round_trip(self) -> None: + opts = EnergyCalculationOptions( + diffuse_model=DiffuseModel.PEREZ, + include_horizon=False, + custom_tracker_rotations_are_at_middle_of_period=True, + ) + d = opts.model_dump(by_alias=True) + assert d["customTrackerRotationsAreAtMiddleOfPeriod"] is True + rebuilt = EnergyCalculationOptions.model_validate(d) + assert rebuilt.custom_tracker_rotations_are_at_middle_of_period is True + def test_auxiliary_losses_round_trip(self) -> None: aux = AuxiliaryLosses(simple_loss_factor=0.02, night_consumption=500.0) rebuilt = AuxiliaryLosses.model_validate(aux.model_dump(by_alias=True)) @@ -225,6 +262,20 @@ def test_calc_options_exclude_none(self, calc_options: EnergyCalculationOptions) d = calc_options.model_dump(by_alias=True, exclude_none=True) assert "horizonType" not in d + def test_custom_tracker_rotations_absent_when_none(self, calc_options: EnergyCalculationOptions) -> None: + d = calc_options.model_dump(by_alias=True, exclude_none=True) + assert "customTrackerRotationsAreAtMiddleOfPeriod" not in d + + def test_custom_tracker_rotations_present_when_false(self) -> None: + opts = EnergyCalculationOptions( + diffuse_model=DiffuseModel.PEREZ, + include_horizon=False, + custom_tracker_rotations_are_at_middle_of_period=False, + ) + d = opts.model_dump(by_alias=True, exclude_none=True) + assert "customTrackerRotationsAreAtMiddleOfPeriod" in d + assert d["customTrackerRotationsAreAtMiddleOfPeriod"] is False + class TestEnumSerialization: """Enums serialize as their string values.""" From 2fed271d101788a2a2a3fd82c82a5cb30999bf59 Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Fri, 17 Jul 2026 13:58:53 +0200 Subject: [PATCH 2/8] Add new energy calculation options for custom tracker rotations --- solarfarmer/__init__.py | 2 ++ solarfarmer/models/__init__.py | 2 ++ solarfarmer/models/energy_calculation_options.py | 11 +++++++++++ 3 files changed, 15 insertions(+) diff --git a/solarfarmer/__init__.py b/solarfarmer/__init__.py index c1a786c..bba8e8f 100644 --- a/solarfarmer/__init__.py +++ b/solarfarmer/__init__.py @@ -66,6 +66,7 @@ TerrainRowDto, TerrainRowStartEndColumnsDto, Tracker, + TrackerAlgorithm, Trackers, TrackerSystem, Transformer, @@ -150,6 +151,7 @@ "TerrainRowStartEndColumnsDto", "terminate_calculation", "Tracker", + "TrackerAlgorithm", "TrackerSystem", "Trackers", "TSV_COLUMNS", diff --git a/solarfarmer/models/__init__.py b/solarfarmer/models/__init__.py index 87c15ff..c9b89e1 100644 --- a/solarfarmer/models/__init__.py +++ b/solarfarmer/models/__init__.py @@ -15,6 +15,7 @@ MissingMetDataMethod, OrderColumnsPvSystFormatTimeSeries, PowerOptimizerOperationType, + TrackerAlgorithm, TransformerLossModelTypes, ) from .indexed_object3d import IndexedObject3D @@ -89,6 +90,7 @@ "TerrainRowDto", "TerrainRowStartEndColumnsDto", "Tracker", + "TrackerAlgorithm", "TrackerSystem", "Trackers", "Transformer", diff --git a/solarfarmer/models/energy_calculation_options.py b/solarfarmer/models/energy_calculation_options.py index 5d7ce0d..c610ca6 100644 --- a/solarfarmer/models/energy_calculation_options.py +++ b/solarfarmer/models/energy_calculation_options.py @@ -39,6 +39,10 @@ class EnergyCalculationOptions(SolarFarmerBaseModel): calculations with incorrect results. default_wind_speed : float Wind speed (m/s) when met data has no wind + custom_tracker_rotations_are_at_middle_of_period : bool or None + When using a custom rotation table, specifies whether the supplied + rotation values represent the middle of each time-step (``True``) or + the start (``False``). If ``None``, the engine uses its own default. calculate_dhi : bool Whether to calculate diffuse horizontal irradiance (DHI) from global horizontal irradiance (GHI) when the ``DHI`` column is missing from the @@ -104,6 +108,10 @@ class EnergyCalculationOptions(SolarFarmerBaseModel): Return detailed time-series results return_loss_tree_time_series_results : bool Return loss-tree time-series results + return_tracker_rotations_time_series : bool + Return tracker rotation angles as a time-series output. Default is False + return_tracker_incidence_angles_time_series : bool + Return tracker incidence angles as a time-series output. Default is False desired_variables_for_pv_syst_format_time_series : list[str] or None Specific variables to include in PVsyst-format output choice_columns_order_pv_syst_format_time_series : OrderColumnsPvSystFormatTimeSeries or None @@ -146,6 +154,7 @@ class EnergyCalculationOptions(SolarFarmerBaseModel): # --- General options --- calculation_year: int = 1990 default_wind_speed: float = 0.0 + custom_tracker_rotations_are_at_middle_of_period: bool | None = None calculate_dhi: bool = Field(False, alias="calculateDHI") # --- Horizon options --- @@ -188,6 +197,8 @@ class EnergyCalculationOptions(SolarFarmerBaseModel): return_pv_syst_format_time_series_results: bool = True return_detailed_time_series_results: bool = False return_loss_tree_time_series_results: bool = False + return_tracker_rotations_time_series: bool = False + return_tracker_incidence_angles_time_series: bool = False desired_variables_for_pv_syst_format_time_series: list[str] | None = Field(default_factory=list) choice_columns_order_pv_syst_format_time_series: OrderColumnsPvSystFormatTimeSeries | None = ( None From 7ac176e624fedcdf0daac5abe394a95ded8b4cef Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Fri, 17 Jul 2026 14:10:17 +0200 Subject: [PATCH 3/8] Add TrackerRotationID to Layout class. Add DC ohmic loss resistance to Layout class. --- solarfarmer/models/layout.py | 24 ++++++++++++- tests/test_models/test_serialization.py | 12 +++++++ tests/test_models/test_validation.py | 45 +++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/solarfarmer/models/layout.py b/solarfarmer/models/layout.py index fd63d24..8846050 100644 --- a/solarfarmer/models/layout.py +++ b/solarfarmer/models/layout.py @@ -1,4 +1,6 @@ -from pydantic import Field +from __future__ import annotations + +from pydantic import Field, model_validator from ._base import SolarFarmerBaseModel @@ -34,12 +36,21 @@ class Layout(SolarFarmerBaseModel): Inverter MPPT input indices this layout connects to dc_ohmic_connector_loss : float DC wiring ohmic loss as a fraction, range [0, 1] + dc_ohmic_connector_resistance : float or None + DC wiring ohmic resistance in ohms (Ω), given directly. When set, + this value is used instead of deriving resistance from + ``dc_ohmic_connector_loss``. If ``None``, the resistance is derived + from ``dc_ohmic_connector_loss``. module_mismatch_loss : float Module mismatch loss as a fraction, range [0, 0.1] name : str or None Optional descriptive name tracker_system_id : str or None Reference to a tracker system, required when ``is_trackers`` is True + tracker_rotation_id : str or None + Reference to a custom tracker rotation schedule. Must match one of the + keys in the custom tracking rotation schedules dictionary. Only used + when ``tracker_algorithm`` is set to ``CustomRotations``. number_of_strings_in_front_row : int Number of strings in the front row (fixed-tilt only) number_of_strings_in_back_row : int @@ -68,9 +79,11 @@ class Layout(SolarFarmerBaseModel): string_length: int = Field(..., ge=1) inverter_input: list[int] = Field(default_factory=list) dc_ohmic_connector_loss: float = Field(0.0, ge=0, le=1) + dc_ohmic_connector_resistance: float | None = Field(None, ge=0) module_mismatch_loss: float = Field(0.0, ge=0, le=0.1) name: str | None = None tracker_system_id: str | None = Field(None, alias="trackerSystemID") + tracker_rotation_id: str | None = Field(None, alias="trackerRotationID") number_of_strings_in_front_row: int = 0 number_of_strings_in_back_row: int = 0 number_of_strings_in_right_row: int = 0 @@ -79,3 +92,12 @@ class Layout(SolarFarmerBaseModel): terrain_azimuth: float = 0.0 terrain_slope: float = 0.0 module_quality_factor: float | None = Field(None, ge=-0.4, le=0.1) + + @model_validator(mode="after") + def _check_dc_ohmic_invariant(self) -> Layout: + 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/tests/test_models/test_serialization.py b/tests/test_models/test_serialization.py index b7bed8e..a341080 100644 --- a/tests/test_models/test_serialization.py +++ b/tests/test_models/test_serialization.py @@ -252,6 +252,18 @@ def test_layout_exclude_none(self, layout: Layout) -> None: assert "name" not in d assert "trackerSystemId" not in d assert "moduleQualityFactor" not in d + assert "trackerRotationID" not in d + assert "dcOhmicConnectorResistance" not in d + + def test_layout_tracker_rotation_id_present_when_set(self, layout: Layout) -> None: + updated = layout.model_copy(update={"tracker_rotation_id": "Index_101"}) + d = updated.model_dump(by_alias=True, exclude_none=True) + assert d["trackerRotationID"] == "Index_101" + + def test_layout_dc_ohmic_connector_resistance_present_when_set(self, layout: Layout) -> None: + updated = layout.model_copy(update={"dc_ohmic_connector_resistance": 0.05}) + d = updated.model_dump(by_alias=True, exclude_none=True) + assert d["dcOhmicConnectorResistance"] == pytest.approx(0.05) def test_inverter_exclude_none(self, inverter: Inverter) -> None: d = inverter.model_dump(by_alias=True, exclude_none=True) diff --git a/tests/test_models/test_validation.py b/tests/test_models/test_validation.py index a17d4dd..66a6e95 100644 --- a/tests/test_models/test_validation.py +++ b/tests/test_models/test_validation.py @@ -126,6 +126,51 @@ def test_azimuth_out_of_range(self) -> None: string_length=1, ) + def test_dc_ohmic_resistance_and_loss_both_set_raises(self) -> None: + with pytest.raises(ValidationError, match="dc_ohmic_connector_resistance"): + Layout( + layout_count=1, + module_specification_id="m", + mounting_type_id="mt", + is_trackers=False, + azimuth=180, + pitch=5, + total_number_of_strings=1, + string_length=1, + dc_ohmic_connector_loss=0.01, + dc_ohmic_connector_resistance=0.5, + ) + + def test_dc_ohmic_resistance_alone_is_valid(self) -> None: + layout = Layout( + layout_count=1, + module_specification_id="m", + mounting_type_id="mt", + is_trackers=False, + azimuth=180, + pitch=5, + total_number_of_strings=1, + string_length=1, + dc_ohmic_connector_resistance=0.5, + ) + assert layout.dc_ohmic_connector_resistance == 0.5 + assert layout.dc_ohmic_connector_loss == 0.0 + + def test_dc_ohmic_resistance_with_loss_zero_is_valid(self) -> None: + layout = Layout( + layout_count=1, + module_specification_id="m", + mounting_type_id="mt", + is_trackers=False, + azimuth=180, + pitch=5, + total_number_of_strings=1, + string_length=1, + dc_ohmic_connector_loss=0.0, + dc_ohmic_connector_resistance=0.3, + ) + assert layout.dc_ohmic_connector_resistance == 0.3 + # --------------------------------------------------------------------------- # Inverter From ff8260afb0721659552d63e7cf9b70e69717e8e4 Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Fri, 17 Jul 2026 14:40:54 +0200 Subject: [PATCH 4/8] Add trackers conditions dataset --- solarfarmer/__init__.py | 4 + solarfarmer/models/__init__.py | 3 + .../models/energy_calculation_inputs.py | 5 + .../models/trackers_conditions_dataset.py | 92 +++++++++++++++++++ tests/test_models/test_serialization.py | 90 ++++++++++++++++++ tests/test_models/test_validation.py | 59 ++++++++++++ 6 files changed, 253 insertions(+) create mode 100644 solarfarmer/models/trackers_conditions_dataset.py diff --git a/solarfarmer/__init__.py b/solarfarmer/__init__.py index bba8e8f..b0b6b0d 100644 --- a/solarfarmer/__init__.py +++ b/solarfarmer/__init__.py @@ -67,7 +67,9 @@ TerrainRowStartEndColumnsDto, Tracker, TrackerAlgorithm, + TrackerCondition, Trackers, + TrackersConditionsDataset, TrackerSystem, Transformer, TransformerLossModelTypes, @@ -152,8 +154,10 @@ "terminate_calculation", "Tracker", "TrackerAlgorithm", + "TrackerCondition", "TrackerSystem", "Trackers", + "TrackersConditionsDataset", "TSV_COLUMNS", "Transformer", "TransformerLossModelTypes", diff --git a/solarfarmer/models/__init__.py b/solarfarmer/models/__init__.py index c9b89e1..b8f5cc2 100644 --- a/solarfarmer/models/__init__.py +++ b/solarfarmer/models/__init__.py @@ -45,6 +45,7 @@ from .tracker import Tracker from .tracker_system import TrackerSystem from .trackers import Trackers +from .trackers_conditions_dataset import TrackerCondition, TrackersConditionsDataset from .transformer import Transformer from .transformer_specification import TransformerSpecification from .vector3double import Vector3Double @@ -91,8 +92,10 @@ "TerrainRowStartEndColumnsDto", "Tracker", "TrackerAlgorithm", + "TrackerCondition", "TrackerSystem", "Trackers", + "TrackersConditionsDataset", "Transformer", "TransformerLossModelTypes", "TransformerSpecification", diff --git a/solarfarmer/models/energy_calculation_inputs.py b/solarfarmer/models/energy_calculation_inputs.py index a65a474..0952a33 100644 --- a/solarfarmer/models/energy_calculation_inputs.py +++ b/solarfarmer/models/energy_calculation_inputs.py @@ -8,6 +8,7 @@ from .ond_supplements import OndFileSupplements from .pan_supplements import PanFileSupplements from .pv_plant import PVPlant +from .trackers_conditions_dataset import TrackersConditionsDataset class EnergyCalculationInputs(SolarFarmerBaseModel): @@ -34,6 +35,9 @@ class EnergyCalculationInputs(SolarFarmerBaseModel): PAN file overrides keyed by module spec ID ond_file_supplements : dict[str, OndFileSupplements] or None OND file overrides keyed by inverter spec ID + trackers_conditions_dataset : TrackersConditionsDataset or None + Custom tracker rotation schedules. Required when any layout uses + ``TrackerAlgorithm.CUSTOM_ROTATIONS``. """ location: Location @@ -44,6 +48,7 @@ class EnergyCalculationInputs(SolarFarmerBaseModel): horizon_angles: list[float] | None = None pan_file_supplements: dict[str, PanFileSupplements] | None = None ond_file_supplements: dict[str, OndFileSupplements] | None = None + trackers_conditions_dataset: TrackersConditionsDataset | None = None class EnergyCalculationInputsWithFiles(SolarFarmerBaseModel): diff --git a/solarfarmer/models/trackers_conditions_dataset.py b/solarfarmer/models/trackers_conditions_dataset.py new file mode 100644 index 0000000..ebe0534 --- /dev/null +++ b/solarfarmer/models/trackers_conditions_dataset.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import Field, field_validator, model_validator + +from ._base import SolarFarmerBaseModel + + +class TrackerCondition(SolarFarmerBaseModel): + """Tracker system conditions for a specific time step. + + Each instance describes the rotation angles of all trackers for one + timestep. The rotations can be expressed either as a single shared + value (when every tracker has the same angle) or as an array of + per-tracker values. + + Attributes + ---------- + period_in_minutes : float + Duration of the time period in minutes, starting from + ``start_of_period``. + start_of_period : datetime + Start of the time period this record relates to. Timezone-aware + (ISO 8601 offset notation recommended, e.g. + ``"2020-01-01T00:00:00+00:00"``). + tracker_rotations_array_values : list[int] + Per-tracker rotation angles encoded as integers (value × 100, + i.e. a rotation of 12.34° is stored as ``1234``). Each value must + be in the range ``[-8990, 8990]`` (i.e. -89.90° to +89.90°). + Empty when all trackers share the same rotation angle — use + ``tracker_rotation_unique_value`` instead. + tracker_rotation_unique_value : int or None + Shared rotation angle (encoded as integer × 100) used when all + trackers have the same rotation. Must be in ``[-8990, 8990]`` + (i.e. -89.90° to +89.90°). + ``None`` when trackers have different rotation angles (use + ``tracker_rotations_array_values``). + """ + + period_in_minutes: float + start_of_period: datetime + tracker_rotations_array_values: list[int] = Field(default_factory=list) + tracker_rotation_unique_value: int | None = Field(None, ge=-8990, le=8990) + + @field_validator("tracker_rotations_array_values") + @classmethod + def _array_values_in_range(cls, v: list[int]) -> list[int]: + if any(x < -8990 or x > 8990 for x in v): + raise ValueError( + "all values must be in the range [-8990, 8990] (i.e. -89.90° to +89.90°)" + ) + return v + + @model_validator(mode="after") + def _check_rotation_invariant(self) -> TrackerCondition: + if self.tracker_rotations_array_values and self.tracker_rotation_unique_value is not None: + raise ValueError( + "Provide either tracker_rotations_array_values or tracker_rotation_unique_value, " + "not both. Use tracker_rotation_unique_value when all trackers share the same " + "angle and tracker_rotations_array_values when they differ." + ) + return self + + +class TrackersConditionsDataset(SolarFarmerBaseModel): + """Custom tracker rotation schedules dataset. + + Contains a time series of tracker rotation conditions used to drive + ``TrackerAlgorithm.CUSTOM_ROTATIONS`` simulations. Passed as the + ``trackers_conditions_dataset`` field of :class:`EnergyCalculationInputs`. + + Attributes + ---------- + data : list[TrackerCondition] + Time-ordered list of tracker conditions, one per time step. + offset_from_utc : float + Hourly UTC offset of the timestamps in ``data``. Positive values + indicate time zones east of Greenwich (e.g. ``+1`` for CET). + rotations_are_at_middle_of_period : bool + ``True`` if the rotation values represent the middle of each + time period; ``False`` (default) if they represent the start. + tracker_rotation_ids : list[str] + Ordered list of tracker rotation IDs. The position of each ID + corresponds to the index into each + :attr:`TrackerCondition.tracker_rotations_array_values` array. + """ + + data: list[TrackerCondition] = Field(default_factory=list) + offset_from_utc: float = 0.0 + rotations_are_at_middle_of_period: bool = False + tracker_rotation_ids: list[str] = Field(default_factory=list) diff --git a/tests/test_models/test_serialization.py b/tests/test_models/test_serialization.py index a341080..d4439c3 100644 --- a/tests/test_models/test_serialization.py +++ b/tests/test_models/test_serialization.py @@ -18,6 +18,8 @@ PanFileSupplements, PVPlant, TrackerAlgorithm, + TrackerCondition, + TrackersConditionsDataset, TrackerSystem, Transformer, TransformerLossModelTypes, @@ -226,6 +228,64 @@ def test_transformer_spec_round_trip(self) -> None: rebuilt = TransformerSpecification.model_validate(spec.model_dump(by_alias=True)) assert rebuilt == spec + def test_tracker_condition_round_trip(self) -> None: + from datetime import datetime, timezone + + # Mirrors the first data row in EnergyCalcInputsTrackerTest.json: + # all trackers flat (angle = 0) early morning. + cond = TrackerCondition( + period_in_minutes=5.0, + start_of_period=datetime(2018, 1, 1, 8, 0, tzinfo=timezone.utc), + tracker_rotation_unique_value=0, + ) + rebuilt = TrackerCondition.model_validate(cond.model_dump(by_alias=True)) + assert rebuilt == cond + + def test_tracker_condition_array_round_trip(self) -> None: + from datetime import datetime, timezone + + # Mirrors the second data row in EnergyCalcInputsTrackerTest.json: + # trackers have different angles, encoded as degrees × 100. + cond = TrackerCondition( + period_in_minutes=5.0, + start_of_period=datetime(2018, 1, 1, 8, 5, tzinfo=timezone.utc), + tracker_rotations_array_values=[-1570, -780, -1050], + ) + d = cond.model_dump(by_alias=True) + assert d["trackerRotationsArrayValues"] == [-1570, -780, -1050] + assert d["trackerRotationUniqueValue"] is None + rebuilt = TrackerCondition.model_validate(d) + assert rebuilt == cond + + def test_trackers_conditions_dataset_round_trip(self) -> None: + from datetime import datetime, timezone + + # Small 3-tracker / 2-timestep dataset representative of the JSON file. + dataset = TrackersConditionsDataset( + offset_from_utc=0.0, + rotations_are_at_middle_of_period=False, + tracker_rotation_ids=["Index_0", "Index_1", "Index_2"], + data=[ + TrackerCondition( + period_in_minutes=5.0, + start_of_period=datetime(2018, 1, 1, 8, 0, tzinfo=timezone.utc), + tracker_rotation_unique_value=0, + ), + TrackerCondition( + period_in_minutes=5.0, + start_of_period=datetime(2018, 1, 1, 8, 5, tzinfo=timezone.utc), + tracker_rotations_array_values=[-1570, -780, -1050], + ), + ], + ) + d = dataset.model_dump(by_alias=True) + assert d["offsetFromUtc"] == 0.0 + assert d["rotationsAreAtMiddleOfPeriod"] is False + assert d["trackerRotationIds"] == ["Index_0", "Index_1", "Index_2"] + assert len(d["data"]) == 2 + rebuilt = TrackersConditionsDataset.model_validate(d) + assert rebuilt == dataset + class TestJsonSerialization: """Models can be serialized to/from JSON strings.""" @@ -243,6 +303,36 @@ def test_nested_json(self, transformer: Transformer) -> None: assert "inverters" in parsed assert parsed["inverters"][0]["inverterSpecID"] == "inv1" + def test_parse_tracker_conditions_json_file(self) -> None: + """EnergyCalcInputsTrackerTest.json round-trips through TrackersConditionsDataset.""" + from pathlib import Path + + def _pascal_to_camel(obj: object) -> object: + """Recursively lower-case the first character of every dict key.""" + if isinstance(obj, dict): + return {k[0].lower() + k[1:]: _pascal_to_camel(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_pascal_to_camel(item) for item in obj] + return obj + + json_path = Path(__file__).parent.parent.parent / "EnergyCalcInputsTrackerTest.json" + # File is a JSON fragment (key: value), wrap it to make valid JSON. + text = json_path.read_text(encoding="utf-8") + raw = json.loads("{" + text + "}") + camel = _pascal_to_camel(raw) + + dataset = TrackersConditionsDataset.model_validate( + camel["trackersConditionsDataset"] + ) + assert len(dataset.tracker_rotation_ids) == 497 + assert len(dataset.data) == 2 + # First timestep: all trackers horizontal (unique value 0) + assert dataset.data[0].tracker_rotation_unique_value == 0 + assert dataset.data[0].tracker_rotations_array_values == [] + # Second timestep: per-tracker angles + assert dataset.data[1].tracker_rotation_unique_value is None + assert len(dataset.data[1].tracker_rotations_array_values) == 497 + class TestExcludeNone: """Optional None values can be excluded from output.""" diff --git a/tests/test_models/test_validation.py b/tests/test_models/test_validation.py index 66a6e95..5edd1b9 100644 --- a/tests/test_models/test_validation.py +++ b/tests/test_models/test_validation.py @@ -10,6 +10,7 @@ MountingTypeSpecification, OndFileSupplements, PanFileSupplements, + TrackerCondition, TrackerSystem, TransformerLossModelTypes, TransformerSpecification, @@ -376,3 +377,61 @@ def test_is_mutable(self) -> None: ) opts.calculation_year = 2024 assert opts.calculation_year == 2024 + + +# --------------------------------------------------------------------------- +# TrackerCondition +# --------------------------------------------------------------------------- + + +class TestTrackerConditionValidation: + def test_both_rotation_fields_raises(self) -> None: + from datetime import datetime, timezone + + with pytest.raises(ValidationError, match="tracker_rotations_array_values"): + TrackerCondition( + period_in_minutes=60.0, + start_of_period=datetime(2020, 1, 1, tzinfo=timezone.utc), + tracker_rotations_array_values=[1234, -500], + tracker_rotation_unique_value=0, + ) + + def test_unique_value_out_of_range_raises(self) -> None: + from datetime import datetime, timezone + + with pytest.raises(ValidationError, match="tracker_rotation_unique_value"): + TrackerCondition( + period_in_minutes=5.0, + start_of_period=datetime(2018, 1, 1, tzinfo=timezone.utc), + tracker_rotation_unique_value=9000, # > 8990 + ) + + def test_unique_value_negative_out_of_range_raises(self) -> None: + from datetime import datetime, timezone + + with pytest.raises(ValidationError, match="tracker_rotation_unique_value"): + TrackerCondition( + period_in_minutes=5.0, + start_of_period=datetime(2018, 1, 1, tzinfo=timezone.utc), + tracker_rotation_unique_value=-9000, # < -8990 + ) + + def test_array_value_out_of_range_raises(self) -> None: + from datetime import datetime, timezone + + with pytest.raises(ValidationError, match="tracker_rotations_array_values"): + TrackerCondition( + period_in_minutes=5.0, + start_of_period=datetime(2018, 1, 1, tzinfo=timezone.utc), + tracker_rotations_array_values=[-1570, 9000], # 9000 > 8990 + ) + + def test_boundary_values_are_valid(self) -> None: + from datetime import datetime, timezone + + cond = TrackerCondition( + period_in_minutes=5.0, + start_of_period=datetime(2018, 1, 1, tzinfo=timezone.utc), + tracker_rotations_array_values=[-8990, 0, 8990], + ) + assert cond.tracker_rotations_array_values == [-8990, 0, 8990] From 504c3d452a8a01e15099467a59a3916fb3e08e05 Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Fri, 17 Jul 2026 15:52:00 +0200 Subject: [PATCH 5/8] Add support custom rotations for Workflow 1 (using existing files) --- solarfarmer/endpoint_modelchains.py | 16 ++++++++++++ solarfarmer/endpoint_modelchains_utils.py | 32 +++++++++++++++++++++++ tests/test_endpoint_modelchain.py | 1 + 3 files changed, 49 insertions(+) diff --git a/solarfarmer/endpoint_modelchains.py b/solarfarmer/endpoint_modelchains.py index e96a55c..096da94 100644 --- a/solarfarmer/endpoint_modelchains.py +++ b/solarfarmer/endpoint_modelchains.py @@ -99,6 +99,7 @@ def _resolve_request_payload( pan_file_paths: list[str] | None, ond_file_paths: list[str] | None, plant_builder: str | SolarFarmerBaseModel | None, + tracker_rotation_paths: list[str] | None = None, ) -> tuple[str, list[tuple[str, IO[bytes]]]]: """ Resolve the API request payload and associated input files. @@ -123,6 +124,10 @@ def _resolve_request_payload( Paths to OND inverter files plant_builder : str or SolarFarmerBaseModel or None Pre-built payload as a model instance or JSON string + tracker_rotation_paths : list of str or None, optional + Paths to ``TrackersConditionsDatasetDto_Protobuf*.gz`` files. + Ignored when ``inputs_folder_path`` is used (files are discovered + automatically from the folder) Returns ------- @@ -155,6 +160,7 @@ def _resolve_request_payload( pan_file_paths, ond_file_paths, energy_calculation_inputs_file_path, + tracker_rotation_paths=tracker_rotation_paths, ) elif plant_builder is not None: # Option 3: use the data from plant builder @@ -165,6 +171,7 @@ def _resolve_request_payload( ond_file_paths, energy_calculation_inputs_file_path=None, parse_energy_calc_inputs=False, + tracker_rotation_paths=tracker_rotation_paths, ) # Ensure the request is a JSON string if isinstance(plant_builder, SolarFarmerBaseModel): @@ -319,6 +326,7 @@ def run_energy_calculation( horizon_file_path: str | None = None, ond_file_paths: list[str] | None = None, pan_file_paths: list[str] | None = None, + tracker_rotation_paths: list[str] | None = None, print_summary: bool = True, outputs_folder_path: str | pathlib.Path | None = None, save_outputs: bool = True, @@ -366,6 +374,13 @@ def run_energy_calculation( One or more paths to PAN module specification files. At least one PAN file is required if not provided via ``modelchain_payload`` or ``folder_path`` + tracker_rotation_paths : list of str, optional + One or more paths to ``TrackersConditionsDatasetDto_Protobuf*.gz`` + files carrying custom tracker rotation data. Pass them in the + correct part order when using multi-part files (e.g. + ``001of002`` before ``002of002``). When ``inputs_folder_path`` is + used, matching files are discovered automatically from the folder + and this parameter is ignored print_summary : bool, optional If True, it will print out the summary of the energy calculation results. Default is True @@ -441,6 +456,7 @@ def run_energy_calculation( pan_file_paths, ond_file_paths, plant_builder, + tracker_rotation_paths, ) # 2. Dispatch to the appropriate endpoint diff --git a/solarfarmer/endpoint_modelchains_utils.py b/solarfarmer/endpoint_modelchains_utils.py index 7420265..9af95d2 100644 --- a/solarfarmer/endpoint_modelchains_utils.py +++ b/solarfarmer/endpoint_modelchains_utils.py @@ -131,6 +131,18 @@ def get_files(sample_data_folder: str | pathlib.Path) -> list[tuple[str, IO[byte " Only a single meteorological file is supported." ) + # Look for TrackersConditionsDatasetDto_Protobuf*.gz files (custom tracker rotation data) + tracker_rotation_file_paths = sorted( + get_file_paths_in_folder( + sample_data_folder, "TrackersConditionsDatasetDto_Protobuf*.gz" + ) + ) + for tracker_rotation_file_path in tracker_rotation_file_paths: + _logger.debug("customRotationDataTransferFiles = %s", tracker_rotation_file_path) + fh = pathlib.Path(tracker_rotation_file_path).open("rb") + stack.callback(fh.close) + files.append(("customRotationDataTransferFiles", fh)) + # Look for PAN files in the folder and add them pan_file_paths = get_file_paths_in_folder(sample_data_folder, "*.PAN") for pan_file_path in pan_file_paths: @@ -266,6 +278,7 @@ def parse_files_from_paths( ond_file_paths: list[str], energy_calculation_inputs_file_path: str | None, parse_energy_calc_inputs: bool = True, + tracker_rotation_paths: list[str] | None = None, ) -> tuple[str, list[tuple[str, IO[bytes]]]]: """ Parse input files for the ModelChain or ModelChainAsync call from explicit paths. @@ -288,6 +301,12 @@ def parse_files_from_paths( parse_energy_calc_inputs : bool, default True If False, the JSON inputs file is not read and an empty string is returned as the request content + tracker_rotation_paths : list of str or None, optional + Paths to one or more ``TrackersConditionsDatasetDto_Protobuf*.gz`` + files carrying custom tracker rotation data. When provided, the + files are uploaded as ``customRotationDataTransferFiles``. + Pass them in the correct order when using multi-part files + (e.g. ``001of002`` before ``002of002``) Returns ------- @@ -369,6 +388,19 @@ def parse_files_from_paths( else: raise FileNotFoundError(f"Error: Path does not exist -> {ond_file_path}") + # Add any tracker rotation transfer files + if tracker_rotation_paths is not None: + for tracker_rotation_path in tracker_rotation_paths: + if path_exists(tracker_rotation_path): + _logger.debug("customRotationDataTransferFiles = %s", tracker_rotation_path) + fh = pathlib.Path(tracker_rotation_path).open("rb") + stack.callback(fh.close) + files.append(("customRotationDataTransferFiles", fh)) + else: + raise FileNotFoundError( + f"Error: Path does not exist -> {tracker_rotation_path}" + ) + if parse_energy_calc_inputs: # Get the JSON energy calculation inputs from the input folder path with pathlib.Path(energy_calculation_inputs_file_path).open("rb") as file: diff --git a/tests/test_endpoint_modelchain.py b/tests/test_endpoint_modelchain.py index b20264f..ce8ac02 100644 --- a/tests/test_endpoint_modelchain.py +++ b/tests/test_endpoint_modelchain.py @@ -70,6 +70,7 @@ def test_individual_paths_delegates_to_parse_files_from_paths(self, mock_parse_p ["/path/to/mod.PAN"], ["/path/to/inv.OND"], "/path/to/inputs.json", + tracker_rotation_paths=None, ) assert content == '{"pvPlant":{}}' From 536ab0da8d1f5910aa3c04663a919b9cf2d433dc Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Fri, 17 Jul 2026 15:53:00 +0200 Subject: [PATCH 6/8] Add Protobuf serialization for TrackersConditionsDataset --- pyproject.toml | 1 + solarfarmer/models/_proto/__init__.py | 14 + .../_proto/trackers_conditions_dataset.proto | 38 +++ .../_proto/trackers_conditions_dataset_pb2.py | 39 +++ .../models/trackers_conditions_dataset.py | 290 ++++++++++++++++- tests/test_models/test_serialization.py | 8 +- .../test_trackers_conditions_protobuf.py | 301 ++++++++++++++++++ 7 files changed, 686 insertions(+), 5 deletions(-) create mode 100644 solarfarmer/models/_proto/__init__.py create mode 100644 solarfarmer/models/_proto/trackers_conditions_dataset.proto create mode 100644 solarfarmer/models/_proto/trackers_conditions_dataset_pb2.py create mode 100644 tests/test_models/test_trackers_conditions_protobuf.py diff --git a/pyproject.toml b/pyproject.toml index a5b27d3..c7be161 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ classifiers = [ dependencies = [ "pydantic>=2.0", + "protobuf>=5.0", "requests>=2.28", "tabulate>=0.9.0", ] diff --git a/solarfarmer/models/_proto/__init__.py b/solarfarmer/models/_proto/__init__.py new file mode 100644 index 0000000..b22e6e9 --- /dev/null +++ b/solarfarmer/models/_proto/__init__.py @@ -0,0 +1,14 @@ +# Generated protobuf message classes for SolarFarmer models. +from solarfarmer.models._proto.trackers_conditions_dataset_pb2 import ( + LongTuple, + NullableShortWrapper, + ShortArrayWrapper, + TrackersConditionsDatasetDto, +) + +__all__ = [ + "LongTuple", + "NullableShortWrapper", + "ShortArrayWrapper", + "TrackersConditionsDatasetDto", +] diff --git a/solarfarmer/models/_proto/trackers_conditions_dataset.proto b/solarfarmer/models/_proto/trackers_conditions_dataset.proto new file mode 100644 index 0000000..9222d08 --- /dev/null +++ b/solarfarmer/models/_proto/trackers_conditions_dataset.proto @@ -0,0 +1,38 @@ +// Proto3 schema for TrackersConditionsDataset wire format. +// Mirrors the C# protobuf-net DTO (TrackersConditionsDatasetDto) used by +// the SolarFarmer API to store tracker condition data in binary form. +// +// Encoding notes: +// • NullableShortWrapper uses proto3 optional so that value=0 can be +// distinguished from a null (absent) entry, matching the C# short? type. +// • DateTimeOffset is encoded as a pair of .NET ticks: item1 = local +// DateTime ticks, item2 = UTC-offset ticks (100 ns intervals since +// 0001-01-01T00:00:00). +// +// Source of truth for the serialized descriptor embedded in _pb2.py. + +syntax = "proto3"; + +message ShortArrayWrapper { + repeated int32 values = 1; +} + +message NullableShortWrapper { + optional int32 value = 1; +} + +message LongTuple { + int64 item1 = 1; + int64 item2 = 2; +} + +message TrackersConditionsDatasetDto { + double offset_from_utc = 1; + bool rotations_are_at_middle_of_period = 2; + repeated string tracker_rotation_ids = 3; + optional double period_in_minutes_for_all_records = 4; + repeated LongTuple start_of_period = 5; + repeated float period_in_minutes = 6; + repeated ShortArrayWrapper tracker_rotations_array_values = 7; + repeated NullableShortWrapper tracker_rotation_unique_value = 8; +} diff --git a/solarfarmer/models/_proto/trackers_conditions_dataset_pb2.py b/solarfarmer/models/_proto/trackers_conditions_dataset_pb2.py new file mode 100644 index 0000000..11ec169 --- /dev/null +++ b/solarfarmer/models/_proto/trackers_conditions_dataset_pb2.py @@ -0,0 +1,39 @@ +# Auto-generated from trackers_conditions_dataset.proto — do not edit by hand. +# Regenerate with: +# python -m grpc_tools.protoc -I solarfarmer/models/_proto \ +# --python_out=solarfarmer/models/_proto \ +# trackers_conditions_dataset.proto +# +# The serialized FileDescriptorProto bytes below are equivalent to running +# protoc on the accompanying .proto source file. + +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder + +_sym_db = _symbol_database.Default() + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b"\n;solarfarmer/models/_proto/trackers_conditions_dataset.proto" + b'"#\n\x11ShortArrayWrapper\x12\x0e\n\x06values\x18\x01 \x03(\x05' + b'"4\n\x14NullableShortWrapper\x12\x12\n\x05value\x18\x01 \x01(\x05' + b"H\x00\x88\x01\x01B\x08\n\x06_value" + b'")\n\tLongTuple\x12\r\n\x05item1\x18\x01 \x01(\x03\x12\r\n\x05item2\x18\x02 \x01(\x03' + b'"\x8d\x03\n\x1cTrackersConditionsDatasetDto' + b"\x12\x17\n\x0foffset_from_utc\x18\x01 \x01(\x01" + b"\x12)\n!rotations_are_at_middle_of_period\x18\x02 \x01(\x08" + b"\x12\x1c\n\x14tracker_rotation_ids\x18\x03 \x03(\t" + b"\x12.\n!period_in_minutes_for_all_records\x18\x04 \x01(\x01H\x00\x88\x01\x01" + b'\x12"\n\x0fstart_of_period\x18\x05 \x03(\x0b2\tLongTuple' + b"\x12\x19\n\x11period_in_minutes\x18\x06 \x03(\x02" + b"\x129\n\x1etracker_rotations_array_values\x18\x07 \x03(\x0b2\x11ShortArrayWrapper" + b"\x12;\n\x1dtracker_rotation_unique_value\x18\x08 \x03(\x0b2\x14NullableShortWrapper" + b'B$\n"_period_in_minutes_for_all_records' + b"b\x06proto3" +) + +_builder.BuildTopDescriptorsAndMessages( + DESCRIPTOR, + "solarfarmer/models/_proto/trackers_conditions_dataset.proto", + globals(), +) diff --git a/solarfarmer/models/trackers_conditions_dataset.py b/solarfarmer/models/trackers_conditions_dataset.py index ebe0534..a4be80b 100644 --- a/solarfarmer/models/trackers_conditions_dataset.py +++ b/solarfarmer/models/trackers_conditions_dataset.py @@ -1,11 +1,81 @@ from __future__ import annotations -from datetime import datetime +import gzip +import re +from collections.abc import Iterable +from datetime import datetime, timedelta, timezone +from pathlib import Path from pydantic import Field, field_validator, model_validator from ._base import SolarFarmerBaseModel +# --------------------------------------------------------------------------- +# Protobuf / .NET conversion helpers +# --------------------------------------------------------------------------- + +# Number of 100-ns ticks that elapsed from .NET epoch (0001-01-01T00:00:00) +# to the Unix epoch (1970-01-01T00:00:00 UTC). +_DOTNET_EPOCH_TICKS: int = 621_355_968_000_000_000 + +# File naming patterns for protobuf gzip files: +# single : TrackersConditionsDatasetDto_Protobuf.gz +# multi : TrackersConditionsDatasetDto_Protobuf001of003.gz +_PROTO_SINGLE_RE = re.compile( + r"^TrackersConditionsDatasetDto_Protobuf\.gz$", + re.IGNORECASE, +) +_PROTO_MULTI_RE = re.compile( + r"^TrackersConditionsDatasetDto_Protobuf(\d+)of(\d+)\.gz$", + re.IGNORECASE, +) + + +def _dotnet_ticks_to_datetime(item1: int, item2: int) -> datetime: + """Convert a .NET ``DateTimeOffset`` encoded as ticks to a Python datetime. + + Parameters + ---------- + item1 : int + Local DateTime ticks (100-ns intervals since 0001-01-01T00:00:00). + item2 : int + UTC-offset ticks (100-ns intervals; positive = east of Greenwich). + + Returns + ------- + datetime + Timezone-aware Python :class:`~datetime.datetime`. + """ + tz = timezone(timedelta(microseconds=item2 // 10)) + local_us = (item1 - _DOTNET_EPOCH_TICKS) // 10 + return datetime(1970, 1, 1, tzinfo=tz) + timedelta(microseconds=local_us) + + +def _datetime_to_dotnet_ticks(dt: datetime) -> tuple[int, int]: + """Convert a Python datetime to a .NET ``DateTimeOffset`` ticks pair. + + Parameters + ---------- + dt : datetime + Timezone-aware or naive datetime. Naive datetimes are treated as UTC. + + Returns + ------- + tuple[int, int] + ``(item1, item2)`` where *item1* is local DateTime ticks and *item2* + is the UTC-offset in 100-ns ticks. + """ + utc_offset: timedelta = ( + (dt.utcoffset() or timedelta(0)) if dt.tzinfo is not None else timedelta(0) + ) + item2 = int(utc_offset.total_seconds() * 10_000_000) # seconds → 100-ns ticks + + naive_local = dt.replace(tzinfo=None) + local_us = int((naive_local - datetime(1970, 1, 1)) / timedelta(microseconds=1)) + item1 = local_us * 10 + _DOTNET_EPOCH_TICKS # µs → 100-ns ticks + .NET epoch offset + + return item1, item2 + class TrackerCondition(SolarFarmerBaseModel): """Tracker system conditions for a specific time step. @@ -90,3 +160,221 @@ class TrackersConditionsDataset(SolarFarmerBaseModel): offset_from_utc: float = 0.0 rotations_are_at_middle_of_period: bool = False tracker_rotation_ids: list[str] = Field(default_factory=list) + + # ------------------------------------------------------------------ + # Protobuf I/O + # ------------------------------------------------------------------ + + @classmethod + def from_protobuf_file(cls, path: Path | str) -> TrackersConditionsDataset: + """Deserialize a single gzip-compressed protobuf file. + + Parameters + ---------- + path : Path or str + Path to a ``*.gz`` file containing a serialized + ``TrackersConditionsDatasetDto`` protobuf message. + + Returns + ------- + TrackersConditionsDataset + """ + from solarfarmer.models._proto.trackers_conditions_dataset_pb2 import ( # noqa: PLC0415 + TrackersConditionsDatasetDto, + ) + + with gzip.open(Path(path), "rb") as fh: + raw = fh.read() + dto = TrackersConditionsDatasetDto() + dto.ParseFromString(raw) + return _dto_to_dataset(dto) + + @classmethod + def from_protobuf_files(cls, paths: Iterable[Path | str]) -> TrackersConditionsDataset: + """Deserialize and merge multiple gzip-compressed protobuf files. + + Metadata (``offset_from_utc``, ``rotations_are_at_middle_of_period``, + ``tracker_rotation_ids``) is taken from the **first** file; subsequent + files contribute additional :class:`TrackerCondition` records. + + Parameters + ---------- + paths : Iterable[Path or str] + Paths to ``*.gz`` files, in order. + + Returns + ------- + TrackersConditionsDataset + """ + sorted_paths = [Path(p) for p in paths] + if not sorted_paths: + raise ValueError("at least one path must be provided") + + datasets = [cls.from_protobuf_file(p) for p in sorted_paths] + first = datasets[0] + combined_data = [record for ds in datasets for record in ds.data] + return cls( + data=combined_data, + offset_from_utc=first.offset_from_utc, + rotations_are_at_middle_of_period=first.rotations_are_at_middle_of_period, + tracker_rotation_ids=first.tracker_rotation_ids, + ) + + @classmethod + def from_protobuf_dir(cls, directory: Path | str) -> TrackersConditionsDataset: + """Deserialize protobuf file(s) auto-discovered in a directory. + + Looks for files matching the SolarFarmer naming convention: + + * **Single file**: ``TrackersConditionsDatasetDto_Protobuf.gz`` + * **Multi-part files**: ``TrackersConditionsDatasetDto_Protobuf001of003.gz``, + ``…002of003.gz``, etc. (sorted by part number) + + Parameters + ---------- + directory : Path or str + Directory to search. + + Returns + ------- + TrackersConditionsDataset + + Raises + ------ + FileNotFoundError + If no matching protobuf files are found. + """ + base = Path(directory) + files = list(base.iterdir()) if base.is_dir() else [] + + # Check for single-file pattern first + single = [f for f in files if _PROTO_SINGLE_RE.match(f.name)] + if single: + return cls.from_protobuf_file(single[0]) + + # Fall back to multi-part pattern, sorted by part number + multi: list[tuple[int, Path]] = [] + for f in files: + m = _PROTO_MULTI_RE.match(f.name) + if m: + multi.append((int(m.group(1)), f)) + if multi: + multi.sort(key=lambda t: t[0]) + return cls.from_protobuf_files(p for _, p in multi) + + raise FileNotFoundError( + f"No TrackersConditionsDatasetDto_Protobuf*.gz files found in {base}" + ) + + def to_protobuf_file(self, path: Path | str) -> None: + """Serialize to a gzip-compressed protobuf file. + + Parameters + ---------- + path : Path or str + Destination path. The file is written (or overwritten) atomically + using gzip compression. + """ + from solarfarmer.models._proto.trackers_conditions_dataset_pb2 import ( # noqa: PLC0415 + TrackersConditionsDatasetDto, + ) + + dto = _dataset_to_dto(self, TrackersConditionsDatasetDto) + raw = dto.SerializeToString() + with gzip.open(Path(path), "wb") as fh: + fh.write(raw) + + +# --------------------------------------------------------------------------- +# Private DTO ↔ domain-model conversion helpers +# (defined after the classes so forward references resolve naturally) +# --------------------------------------------------------------------------- + + +def _dto_to_dataset(dto: object) -> TrackersConditionsDataset: + """Convert a ``TrackersConditionsDatasetDto`` protobuf message to the domain model.""" + n = len(dto.start_of_period) # type: ignore[union-attr] + + # period_in_minutes: prefer per-record array; fall back to scalar field + raw_periods = list(dto.period_in_minutes) # type: ignore[union-attr] + if len(raw_periods) == n: + periods: list[float] = raw_periods + elif dto.HasField("period_in_minutes_for_all_records"): # type: ignore[union-attr] + periods = [dto.period_in_minutes_for_all_records] * n # type: ignore[union-attr] + elif n == 0: + periods = [] + else: + raise ValueError( + "period_in_minutes data is missing or inconsistent with start_of_period count" + ) + + arr_wrappers = list(dto.tracker_rotations_array_values) # type: ignore[union-attr] + uni_wrappers = list(dto.tracker_rotation_unique_value) # type: ignore[union-attr] + + data: list[TrackerCondition] = [] + for i in range(n): + start = _dotnet_ticks_to_datetime( + dto.start_of_period[i].item1, # type: ignore[union-attr] + dto.start_of_period[i].item2, # type: ignore[union-attr] + ) + arr_values = list(arr_wrappers[i].values) if i < len(arr_wrappers) else [] + unique_val: int | None = None + if i < len(uni_wrappers) and uni_wrappers[i].HasField("value"): + unique_val = uni_wrappers[i].value + + data.append( + TrackerCondition( + period_in_minutes=float(periods[i]), + start_of_period=start, + tracker_rotations_array_values=arr_values, + tracker_rotation_unique_value=unique_val, + ) + ) + + return TrackersConditionsDataset( + data=data, + offset_from_utc=float(dto.offset_from_utc), # type: ignore[union-attr] + rotations_are_at_middle_of_period=bool(dto.rotations_are_at_middle_of_period), # type: ignore[union-attr] + tracker_rotation_ids=list(dto.tracker_rotation_ids), # type: ignore[union-attr] + ) + + +def _dataset_to_dto(dataset: TrackersConditionsDataset, dto_cls: type) -> object: + """Convert a :class:`TrackersConditionsDataset` to a ``TrackersConditionsDatasetDto``.""" + from solarfarmer.models._proto.trackers_conditions_dataset_pb2 import ( # noqa: PLC0415 + LongTuple, + NullableShortWrapper, + ShortArrayWrapper, + ) + + start_of_period = [] + period_in_minutes: list[float] = [] + arr_wrappers = [] + uni_wrappers = [] + + for condition in dataset.data: + item1, item2 = _datetime_to_dotnet_ticks(condition.start_of_period) + start_of_period.append(LongTuple(item1=item1, item2=item2)) + period_in_minutes.append(condition.period_in_minutes) + + if condition.tracker_rotations_array_values: + arr_wrappers.append(ShortArrayWrapper(values=condition.tracker_rotations_array_values)) + uni_wrappers.append(NullableShortWrapper()) # absent → null + else: + arr_wrappers.append(ShortArrayWrapper()) # empty array + if condition.tracker_rotation_unique_value is not None: + uni_wrappers.append( + NullableShortWrapper(value=condition.tracker_rotation_unique_value) + ) + else: + uni_wrappers.append(NullableShortWrapper()) # absent → null + + return dto_cls( + offset_from_utc=dataset.offset_from_utc, + rotations_are_at_middle_of_period=dataset.rotations_are_at_middle_of_period, + tracker_rotation_ids=dataset.tracker_rotation_ids, + start_of_period=start_of_period, + period_in_minutes=period_in_minutes, + tracker_rotations_array_values=arr_wrappers, + tracker_rotation_unique_value=uni_wrappers, + ) diff --git a/tests/test_models/test_serialization.py b/tests/test_models/test_serialization.py index d4439c3..c709eab 100644 --- a/tests/test_models/test_serialization.py +++ b/tests/test_models/test_serialization.py @@ -321,9 +321,7 @@ def _pascal_to_camel(obj: object) -> object: raw = json.loads("{" + text + "}") camel = _pascal_to_camel(raw) - dataset = TrackersConditionsDataset.model_validate( - camel["trackersConditionsDataset"] - ) + dataset = TrackersConditionsDataset.model_validate(camel["trackersConditionsDataset"]) assert len(dataset.tracker_rotation_ids) == 497 assert len(dataset.data) == 2 # First timestep: all trackers horizontal (unique value 0) @@ -364,7 +362,9 @@ def test_calc_options_exclude_none(self, calc_options: EnergyCalculationOptions) d = calc_options.model_dump(by_alias=True, exclude_none=True) assert "horizonType" not in d - def test_custom_tracker_rotations_absent_when_none(self, calc_options: EnergyCalculationOptions) -> None: + def test_custom_tracker_rotations_absent_when_none( + self, calc_options: EnergyCalculationOptions + ) -> None: d = calc_options.model_dump(by_alias=True, exclude_none=True) assert "customTrackerRotationsAreAtMiddleOfPeriod" not in d diff --git a/tests/test_models/test_trackers_conditions_protobuf.py b/tests/test_models/test_trackers_conditions_protobuf.py new file mode 100644 index 0000000..88ffa0b --- /dev/null +++ b/tests/test_models/test_trackers_conditions_protobuf.py @@ -0,0 +1,301 @@ +"""Tests for TrackersConditionsDataset protobuf serialization / deserialization.""" + +from __future__ import annotations + +import gzip +import tempfile +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from solarfarmer.models.trackers_conditions_dataset import ( # noqa: PLC2701 + TrackerCondition, + TrackersConditionsDataset, + _datetime_to_dotnet_ticks, + _dotnet_ticks_to_datetime, +) + +# --------------------------------------------------------------------------- +# Helper factories +# --------------------------------------------------------------------------- + + +def _make_dataset( + n: int = 3, + *, + use_array: bool = False, + tz_offset_hours: float = 0.0, +) -> TrackersConditionsDataset: + """Build a small TrackersConditionsDataset for testing.""" + tz = timezone(timedelta(hours=tz_offset_hours)) + rotation_ids = [f"ID_{i}" for i in range(3)] + + data = [] + for i in range(n): + start = datetime(2020, 1, 1, i, 0, 0, tzinfo=tz) + if use_array: + data.append( + TrackerCondition( + period_in_minutes=30.0, + start_of_period=start, + tracker_rotations_array_values=[-1570 + i * 100, 0, 200 - i * 50], + ) + ) + else: + data.append( + TrackerCondition( + period_in_minutes=30.0, + start_of_period=start, + tracker_rotation_unique_value=i * 100 - 100, # -100, 0, 100 + ) + ) + + return TrackersConditionsDataset( + data=data, + offset_from_utc=tz_offset_hours, + rotations_are_at_middle_of_period=False, + tracker_rotation_ids=rotation_ids, + ) + + +# --------------------------------------------------------------------------- +# datetime ↔ .NET ticks conversion +# --------------------------------------------------------------------------- + + +class TestDotnetTicksConversion: + def test_utc_roundtrip(self) -> None: + dt = datetime(2020, 6, 15, 12, 30, 0, tzinfo=timezone.utc) + item1, item2 = _datetime_to_dotnet_ticks(dt) + result = _dotnet_ticks_to_datetime(item1, item2) + assert result == dt + + def test_positive_offset_roundtrip(self) -> None: + tz = timezone(timedelta(hours=5, minutes=30)) # India Standard Time + dt = datetime(2021, 3, 14, 9, 0, 0, tzinfo=tz) + item1, item2 = _datetime_to_dotnet_ticks(dt) + result = _dotnet_ticks_to_datetime(item1, item2) + assert result == dt + + def test_negative_offset_roundtrip(self) -> None: + tz = timezone(timedelta(hours=-5)) # UTC-5 + dt = datetime(2022, 12, 31, 23, 59, 59, tzinfo=tz) + item1, item2 = _datetime_to_dotnet_ticks(dt) + result = _dotnet_ticks_to_datetime(item1, item2) + assert result == dt + + def test_naive_datetime_treated_as_utc(self) -> None: + naive = datetime(2020, 1, 1, 0, 0, 0) + item1, item2 = _datetime_to_dotnet_ticks(naive) + assert item2 == 0 # UTC offset = 0 + result = _dotnet_ticks_to_datetime(item1, item2) + assert result == datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + def test_zero_offset_item2(self) -> None: + _, item2 = _datetime_to_dotnet_ticks(datetime(2020, 1, 1, tzinfo=timezone.utc)) + assert item2 == 0 + + def test_one_hour_offset_item2(self) -> None: + tz = timezone(timedelta(hours=1)) + _, item2 = _datetime_to_dotnet_ticks(datetime(2020, 1, 1, tzinfo=tz)) + assert item2 == 36_000_000_000 # 1h in 100-ns ticks + + +# --------------------------------------------------------------------------- +# Protobuf round-trip via bytes +# --------------------------------------------------------------------------- + + +class TestProtobufRoundTrip: + def test_unique_value_roundtrip(self) -> None: + ds = _make_dataset(n=4, use_array=False) + with tempfile.NamedTemporaryFile(suffix=".gz", delete=False) as f: + tmp = Path(f.name) + try: + ds.to_protobuf_file(tmp) + loaded = TrackersConditionsDataset.from_protobuf_file(tmp) + finally: + tmp.unlink(missing_ok=True) + + assert loaded.offset_from_utc == ds.offset_from_utc + assert loaded.rotations_are_at_middle_of_period == ds.rotations_are_at_middle_of_period + assert loaded.tracker_rotation_ids == ds.tracker_rotation_ids + assert len(loaded.data) == len(ds.data) + for orig, back in zip(ds.data, loaded.data, strict=True): + assert back.period_in_minutes == orig.period_in_minutes + assert back.start_of_period == orig.start_of_period + assert back.tracker_rotation_unique_value == orig.tracker_rotation_unique_value + assert back.tracker_rotations_array_values == [] + + def test_array_values_roundtrip(self) -> None: + ds = _make_dataset(n=3, use_array=True) + with tempfile.NamedTemporaryFile(suffix=".gz", delete=False) as f: + tmp = Path(f.name) + try: + ds.to_protobuf_file(tmp) + loaded = TrackersConditionsDataset.from_protobuf_file(tmp) + finally: + tmp.unlink(missing_ok=True) + + for orig, back in zip(ds.data, loaded.data, strict=True): + assert back.tracker_rotations_array_values == orig.tracker_rotations_array_values + assert back.tracker_rotation_unique_value is None + + def test_null_vs_zero_unique_value_preserved(self) -> None: + """NullableShortWrapper presence tracking must survive the wire round-trip.""" + ds = TrackersConditionsDataset( + data=[ + TrackerCondition( + period_in_minutes=60.0, + start_of_period=datetime(2020, 1, 1, 0, tzinfo=timezone.utc), + tracker_rotation_unique_value=0, # explicitly zero — must survive + ), + TrackerCondition( + period_in_minutes=60.0, + start_of_period=datetime(2020, 1, 1, 1, tzinfo=timezone.utc), + # unique_value absent → null + ), + ] + ) + with tempfile.NamedTemporaryFile(suffix=".gz", delete=False) as f: + tmp = Path(f.name) + try: + ds.to_protobuf_file(tmp) + loaded = TrackersConditionsDataset.from_protobuf_file(tmp) + finally: + tmp.unlink(missing_ok=True) + + assert loaded.data[0].tracker_rotation_unique_value == 0 # zero preserved + assert loaded.data[1].tracker_rotation_unique_value is None # null preserved + + def test_offset_from_utc_preserved(self) -> None: + ds = _make_dataset(n=2, tz_offset_hours=5.5) + with tempfile.NamedTemporaryFile(suffix=".gz", delete=False) as f: + tmp = Path(f.name) + try: + ds.to_protobuf_file(tmp) + loaded = TrackersConditionsDataset.from_protobuf_file(tmp) + finally: + tmp.unlink(missing_ok=True) + assert loaded.offset_from_utc == pytest.approx(5.5) + + def test_rotations_are_at_middle_of_period_preserved(self) -> None: + ds = TrackersConditionsDataset( + data=[ + TrackerCondition( + period_in_minutes=30.0, + start_of_period=datetime(2020, 1, 1, tzinfo=timezone.utc), + tracker_rotation_unique_value=100, + ) + ], + rotations_are_at_middle_of_period=True, + ) + with tempfile.NamedTemporaryFile(suffix=".gz", delete=False) as f: + tmp = Path(f.name) + try: + ds.to_protobuf_file(tmp) + loaded = TrackersConditionsDataset.from_protobuf_file(tmp) + finally: + tmp.unlink(missing_ok=True) + assert loaded.rotations_are_at_middle_of_period is True + + +# --------------------------------------------------------------------------- +# from_protobuf_files — multi-file merge +# --------------------------------------------------------------------------- + + +class TestFromProtobufFiles: + def test_multi_file_combines_records(self) -> None: + ds_a = _make_dataset(n=2, use_array=False) + tz = timezone.utc + ds_b = TrackersConditionsDataset( + data=[ + TrackerCondition( + period_in_minutes=30.0, + start_of_period=datetime(2020, 1, 1, 10, tzinfo=tz), + tracker_rotation_unique_value=500, + ) + ], + offset_from_utc=0.0, + tracker_rotation_ids=ds_a.tracker_rotation_ids, + ) + + with tempfile.TemporaryDirectory() as td: + p_a = Path(td) / "a.gz" + p_b = Path(td) / "b.gz" + ds_a.to_protobuf_file(p_a) + ds_b.to_protobuf_file(p_b) + + merged = TrackersConditionsDataset.from_protobuf_files([p_a, p_b]) + + assert len(merged.data) == len(ds_a.data) + len(ds_b.data) + assert merged.offset_from_utc == ds_a.offset_from_utc + assert merged.tracker_rotation_ids == ds_a.tracker_rotation_ids + + def test_empty_paths_raises(self) -> None: + with pytest.raises(ValueError, match="at least one path"): + TrackersConditionsDataset.from_protobuf_files([]) + + +# --------------------------------------------------------------------------- +# from_protobuf_dir — auto-discovery +# --------------------------------------------------------------------------- + + +class TestFromProtobufDir: + def test_single_file_discovered(self) -> None: + ds = _make_dataset(n=2) + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "TrackersConditionsDatasetDto_Protobuf.gz" + ds.to_protobuf_file(p) + loaded = TrackersConditionsDataset.from_protobuf_dir(td) + assert len(loaded.data) == len(ds.data) + + def test_multi_part_files_discovered_in_order(self) -> None: + tz = timezone.utc + ds1 = TrackersConditionsDataset( + data=[ + TrackerCondition( + period_in_minutes=30.0, + start_of_period=datetime(2020, 1, 1, 0, tzinfo=tz), + tracker_rotation_unique_value=100, + ) + ] + ) + ds2 = TrackersConditionsDataset( + data=[ + TrackerCondition( + period_in_minutes=30.0, + start_of_period=datetime(2020, 1, 1, 1, tzinfo=tz), + tracker_rotation_unique_value=200, + ) + ] + ) + with tempfile.TemporaryDirectory() as td: + # Write in reverse order to ensure sorting by part number works + ds2.to_protobuf_file(Path(td) / "TrackersConditionsDatasetDto_Protobuf002of002.gz") + ds1.to_protobuf_file(Path(td) / "TrackersConditionsDatasetDto_Protobuf001of002.gz") + loaded = TrackersConditionsDataset.from_protobuf_dir(td) + + assert len(loaded.data) == 2 + assert loaded.data[0].tracker_rotation_unique_value == 100 + assert loaded.data[1].tracker_rotation_unique_value == 200 + + def test_missing_files_raises(self) -> None: + with tempfile.TemporaryDirectory() as td: + with pytest.raises(FileNotFoundError, match="TrackersConditionsDatasetDto"): + TrackersConditionsDataset.from_protobuf_dir(td) + + def test_output_file_is_valid_gzip(self) -> None: + ds = _make_dataset(n=1) + with tempfile.NamedTemporaryFile(suffix=".gz", delete=False) as f: + tmp = Path(f.name) + try: + ds.to_protobuf_file(tmp) + with gzip.open(tmp, "rb") as fh: + raw = fh.read() + assert len(raw) > 0 + finally: + tmp.unlink(missing_ok=True) From aa9fe7082854f334a2a67785b70cc5d90e57dbf5 Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Mon, 20 Jul 2026 15:56:30 +0200 Subject: [PATCH 7/8] Fix reference to testing file --- tests/test_models/test_serialization.py | 48 ++++++++++++++----------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/tests/test_models/test_serialization.py b/tests/test_models/test_serialization.py index c709eab..7889c4a 100644 --- a/tests/test_models/test_serialization.py +++ b/tests/test_models/test_serialization.py @@ -303,33 +303,39 @@ def test_nested_json(self, transformer: Transformer) -> None: assert "inverters" in parsed assert parsed["inverters"][0]["inverterSpecID"] == "inv1" - def test_parse_tracker_conditions_json_file(self) -> None: - """EnergyCalcInputsTrackerTest.json round-trips through TrackersConditionsDataset.""" - from pathlib import Path - - def _pascal_to_camel(obj: object) -> object: - """Recursively lower-case the first character of every dict key.""" - if isinstance(obj, dict): - return {k[0].lower() + k[1:]: _pascal_to_camel(v) for k, v in obj.items()} - if isinstance(obj, list): - return [_pascal_to_camel(item) for item in obj] - return obj - - json_path = Path(__file__).parent.parent.parent / "EnergyCalcInputsTrackerTest.json" - # File is a JSON fragment (key: value), wrap it to make valid JSON. - text = json_path.read_text(encoding="utf-8") - raw = json.loads("{" + text + "}") - camel = _pascal_to_camel(raw) - - dataset = TrackersConditionsDataset.model_validate(camel["trackersConditionsDataset"]) - assert len(dataset.tracker_rotation_ids) == 497 + def test_parse_tracker_conditions_large_dataset(self) -> None: + """TrackersConditionsDataset handles large tracker counts and mixed condition types.""" + from datetime import datetime, timezone + + n_trackers = 536 + tracker_ids = [f"Index_{i}" for i in range(n_trackers)] + + dataset = TrackersConditionsDataset( + offset_from_utc=0.0, + rotations_are_at_middle_of_period=False, + tracker_rotation_ids=tracker_ids, + data=[ + TrackerCondition( + period_in_minutes=5.0, + start_of_period=datetime(2018, 1, 1, 8, 0, tzinfo=timezone.utc), + tracker_rotation_unique_value=0, + ), + TrackerCondition( + period_in_minutes=5.0, + start_of_period=datetime(2018, 1, 1, 8, 5, tzinfo=timezone.utc), + tracker_rotations_array_values=list(range(n_trackers)), + ), + ], + ) + + assert len(dataset.tracker_rotation_ids) == n_trackers assert len(dataset.data) == 2 # First timestep: all trackers horizontal (unique value 0) assert dataset.data[0].tracker_rotation_unique_value == 0 assert dataset.data[0].tracker_rotations_array_values == [] # Second timestep: per-tracker angles assert dataset.data[1].tracker_rotation_unique_value is None - assert len(dataset.data[1].tracker_rotations_array_values) == 497 + assert len(dataset.data[1].tracker_rotations_array_values) == n_trackers class TestExcludeNone: From 0e7e3ba3e1184e29608e08da6939618dcb98752e Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Mon, 20 Jul 2026 18:10:06 +0200 Subject: [PATCH 8/8] Add export for new CSV result files for trackers --- solarfarmer/config.py | 4 + .../models/energy_calculation_results.py | 202 +++++++++++++++++- solarfarmer/models/model_chain_response.py | 14 ++ tests/test_energy_calculation_results.py | 2 + 4 files changed, 221 insertions(+), 1 deletion(-) diff --git a/solarfarmer/config.py b/solarfarmer/config.py index 73cc21e..74f8945 100644 --- a/solarfarmer/config.py +++ b/solarfarmer/config.py @@ -16,6 +16,8 @@ "PVSYST_TIMESERIES_FILENAME", "PVSYST_TIMESERIES_DATAFRAME_FILENAME", "DETAILED_TIMESERIES_FILENAME", + "TRACKER_INCIDENCE_ANGLES_FILENAME", + "TRACKER_ROTATION_ANGLES_FILENAME", "GENERAL_TIMEOUT", "MODELCHAIN_TIMEOUT", "MODELCHAIN_ASYNC_TIMEOUT_CONNECTION", @@ -42,6 +44,8 @@ PVSYST_TIMESERIES_FILENAME = "PVsystResults.csv" PVSYST_TIMESERIES_DATAFRAME_FILENAME = "PVsystResults_frame.csv" DETAILED_TIMESERIES_FILENAME = "DetailedTimeseries.tsv" +TRACKER_INCIDENCE_ANGLES_FILENAME = "TrackerPositions_IncidenceAngles.csv" +TRACKER_ROTATION_ANGLES_FILENAME = "TrackerPositions_RotationAngles.csv" # Default times (in seconds) GENERAL_TIMEOUT = 15 # Used in About, Service endpoints diff --git a/solarfarmer/models/energy_calculation_results.py b/solarfarmer/models/energy_calculation_results.py index c43d199..9f4648e 100644 --- a/solarfarmer/models/energy_calculation_results.py +++ b/solarfarmer/models/energy_calculation_results.py @@ -23,6 +23,8 @@ PANDAS_INSTALL_MSG, PVSYST_TIMESERIES_DATAFRAME_FILENAME, PVSYST_TIMESERIES_FILENAME, + TRACKER_INCIDENCE_ANGLES_FILENAME, + TRACKER_ROTATION_ANGLES_FILENAME, ) from ..endpoint_modelchains_utils import path_exists from ..logging import get_logger @@ -127,6 +129,14 @@ class CalculationResults: DetailedTimeseries : pd.DataFrame or None The detailed timeseries results. Additional data from the modeling chain of SolarFarmer performance model. Useful for debugging. + TrackerIncidenceAnglesTimeseries : pd.DataFrame or None + The tracker incidence angles timeseries. Each string in the API response + list is one line of the file; they are joined to form a single DataFrame. + None if not returned by the API (e.g., fixed-tilt plants). + TrackerRotationAnglesTimeseries : pd.DataFrame or None + The tracker rotation angles timeseries. Each string in the API response + list is one line of the file; they are joined to form a single DataFrame. + None if not returned by the API (e.g., fixed-tilt plants). Name: str or None Name of project. It is populated with the ``project_id`` property if availabe. @@ -156,6 +166,8 @@ class CalculationResults: LossTreeTimeseries: pd.DataFrame | None = None PVsystTimeseries: pd.DataFrame | None = None DetailedTimeseries: pd.DataFrame | None = None + TrackerIncidenceAnglesTimeseries: pd.DataFrame | None = None + TrackerRotationAnglesTimeseries: pd.DataFrame | None = None Name: str | None = None # ----- Convenience properties for common metrics (year 1) ----- @@ -238,6 +250,14 @@ def from_modelchain_response( modelchain_response, outputs_folder_path, save_outputs ) + tracker_incidence_timeseries = _handle_tracker_incidence_results( + modelchain_response, outputs_folder_path, save_outputs + ) + + tracker_rotation_timeseries = _handle_tracker_rotation_results( + modelchain_response, outputs_folder_path, save_outputs + ) + calculation_results = cls( ModelChainResponse=modelchain_response, AnnualData=annual_data, @@ -246,6 +266,8 @@ def from_modelchain_response( LossTreeTimeseries=losstree_timeseries, PVsystTimeseries=pvsyst_timeseries, DetailedTimeseries=detailed_timeseries, + TrackerIncidenceAnglesTimeseries=tracker_incidence_timeseries, + TrackerRotationAnglesTimeseries=tracker_rotation_timeseries, Name=modelchain_response.Name, ) @@ -338,6 +360,22 @@ def from_folder(cls, output_folder_path: str) -> CalculationResults: detailed_timeseries_file_path, "\t", DETAILED_TIMESERIES_FILENAME ) + # Tracker incidence angles timeseries + tracker_incidence_timeseries = _read_dataframe_pandas_safe( + output_folder_path / TRACKER_INCIDENCE_ANGLES_FILENAME, + ";", + TRACKER_INCIDENCE_ANGLES_FILENAME, + optional=True, + ) + + # Tracker rotation angles timeseries + tracker_rotation_timeseries = _read_dataframe_pandas_safe( + output_folder_path / TRACKER_ROTATION_ANGLES_FILENAME, + ";", + TRACKER_ROTATION_ANGLES_FILENAME, + optional=True, + ) + return cls( ModelChainResponse=None, AnnualData=annual_data, @@ -346,6 +384,8 @@ def from_folder(cls, output_folder_path: str) -> CalculationResults: LossTreeTimeseries=losstree_timeseries, PVsystTimeseries=pvsyst_timeseries, DetailedTimeseries=detailed_timeseries, + TrackerIncidenceAnglesTimeseries=tracker_incidence_timeseries, + TrackerRotationAnglesTimeseries=tracker_rotation_timeseries, Name=None, ) @@ -498,6 +538,44 @@ def to_folder(self, output_folder_path: str) -> None: detailed_timeseries_file_path, ) + # Tracker incidence angles timeseries + if response_exists: + incidence_results_list = self.ModelChainResponse.TrackerResultsIncidenceAngles + if incidence_results_list: + _save_content( + "\n".join(incidence_results_list), + output_folder_path / TRACKER_INCIDENCE_ANGLES_FILENAME, + type_file="tracker incidence angles", + ) + else: + incidence_df = self.TrackerIncidenceAnglesTimeseries + if incidence_df is not None and len(incidence_df) > 0: + file_path = output_folder_path / TRACKER_INCIDENCE_ANGLES_FILENAME + incidence_df.to_csv(file_path, sep=";") + _logger.debug( + "Saved tracker incidence angles file to %s (exported from DataFrame, metadata may be missing)", + file_path, + ) + + # Tracker rotation angles timeseries + if response_exists: + rotation_results_list = self.ModelChainResponse.TrackerResultsRotationAngles + if rotation_results_list: + _save_content( + "\n".join(rotation_results_list), + output_folder_path / TRACKER_ROTATION_ANGLES_FILENAME, + type_file="tracker rotation angles", + ) + else: + rotation_df = self.TrackerRotationAnglesTimeseries + if rotation_df is not None and len(rotation_df) > 0: + file_path = output_folder_path / TRACKER_ROTATION_ANGLES_FILENAME + rotation_df.to_csv(file_path, sep=";") + _logger.debug( + "Saved tracker rotation angles file to %s (exported from DataFrame, metadata may be missing)", + file_path, + ) + _logger.info("Results written out to %s", output_folder_path) return @@ -515,6 +593,12 @@ def info(self) -> None: print(f"Loss tree timeseries included: {data['has_loss_tree_timeseries']}") print(f"PVsyst timeseries included: {data['has_pvsyst_timeseries']}") print(f"Detailed timeseries included: {data['has_detailed_timeseries']}") + print( + f"Tracker incidence angles timeseries included: {data['has_tracker_incidence_angles_timeseries']}" + ) + print( + f"Tracker rotation angles timeseries included: {data['has_tracker_rotation_angles_timeseries']}" + ) return def describe(self, project_year: int = 1) -> None: @@ -676,6 +760,8 @@ def get_info(self) -> dict[str, bool | str]: - 'has_loss_tree_timeseries': Whether loss tree timeseries is available - 'has_pvsyst_timeseries': Whether PVsyst timeseries is available - 'has_detailed_timeseries': Whether detailed timeseries is available + - 'has_tracker_incidence_angles_timeseries': Whether tracker incidence angles timeseries is available + - 'has_tracker_rotation_angles_timeseries': Whether tracker rotation angles timeseries is available Examples -------- @@ -691,6 +777,10 @@ def get_info(self) -> dict[str, bool | str]: "has_loss_tree_timeseries": self.LossTreeTimeseries is not None, "has_pvsyst_timeseries": self.PVsystTimeseries is not None, "has_detailed_timeseries": self.DetailedTimeseries is not None, + "has_tracker_incidence_angles_timeseries": self.TrackerIncidenceAnglesTimeseries + is not None, + "has_tracker_rotation_angles_timeseries": self.TrackerRotationAnglesTimeseries + is not None, } def get_performance(self, project_year: int = 1) -> dict[str, int | float]: @@ -1858,6 +1948,108 @@ def _handle_timeseries_results( return None +def _handle_tracker_incidence_results( + modelchain_response: ModelChainResponse, + outputs_folder_path: str | Path, + save_outputs: bool, +) -> pd.DataFrame | None: + """ + Extract tracker incidence angles timeseries results from ModelChainResponse. + + The API returns the file contents as ``ICollection`` where each + element is one line of the CSV. The lines are joined and saved as a single + ``TrackerPositions_IncidenceAngles.csv`` file. + + Parameters + ---------- + modelchain_response : ModelChainResponse + The ModelChainResponse object from the API call. + outputs_folder_path : str + The path of the output folder, where the results will be written. + save_outputs : bool + If True, saves the joined content to + ``TrackerPositions_IncidenceAngles.csv`` in the output folder. + + Returns + ------- + pandas.DataFrame or None + The incidence angles timeseries as a DataFrame if pandas is installed; + None if the response contains no tracker incidence data or pandas + is unavailable. + """ + incidence_results_list = modelchain_response.TrackerResultsIncidenceAngles + if not incidence_results_list: + _logger.debug("No tracker incidence angles results returned.") + return None + + content = "\n".join(incidence_results_list) + + if save_outputs: + _save_content( + content, + Path(outputs_folder_path) / TRACKER_INCIDENCE_ANGLES_FILENAME, + type_file="tracker incidence angles", + ) + + if not _PANDAS: + warnings.warn(PANDAS_INSTALL_MSG, stacklevel=2) + return None + + with io.StringIO(content) as g: + return pd.read_csv(g, sep=";") + + +def _handle_tracker_rotation_results( + modelchain_response: ModelChainResponse, + outputs_folder_path: str | Path, + save_outputs: bool, +) -> pd.DataFrame | None: + """ + Extract tracker rotation angles timeseries results from ModelChainResponse. + + The API returns the file contents as ``ICollection`` where each + element is one line of the CSV. The lines are joined and saved as a single + ``TrackerPositions_RotationAngles.csv`` file. + + Parameters + ---------- + modelchain_response : ModelChainResponse + The ModelChainResponse object from the API call. + outputs_folder_path : str + The path of the output folder, where the results will be written. + save_outputs : bool + If True, saves the joined content to + ``TrackerPositions_RotationAngles.csv`` in the output folder. + + Returns + ------- + pandas.DataFrame or None + The rotation angles timeseries as a DataFrame if pandas is installed; + None if the response contains no tracker rotation data or pandas + is unavailable. + """ + rotation_results_list = modelchain_response.TrackerResultsRotationAngles + if not rotation_results_list: + _logger.debug("No tracker rotation angles results returned.") + return None + + content = "\n".join(rotation_results_list) + + if save_outputs: + _save_content( + content, + Path(outputs_folder_path) / TRACKER_ROTATION_ANGLES_FILENAME, + type_file="tracker rotation angles", + ) + + if not _PANDAS: + warnings.warn(PANDAS_INSTALL_MSG, stacklevel=2) + return None + + with io.StringIO(content) as g: + return pd.read_csv(g, sep=";") + + def _save_content( content: str | bytes | dict | list, dest_path: str | Path, @@ -1954,6 +2146,7 @@ def _read_dataframe_pandas_safe( separator_character: str, name_file: str, skip_rows: list[int] | None = None, + optional: bool = False, ) -> pd.DataFrame | None: """ Safely read a pandas DataFrame from a CSV file. @@ -1968,6 +2161,10 @@ def _read_dataframe_pandas_safe( Name of the file for logging purposes. skip_rows : list[int] | None, optional Row indices to skip. Default is None. + optional : bool, optional + If True, a missing file is logged at DEBUG level rather than WARNING. + Use for files that are only present for certain calculation types + (e.g., tracker results on fixed-tilt plants). Default is False. Returns ------- @@ -1975,7 +2172,10 @@ def _read_dataframe_pandas_safe( Parsed DataFrame, or None if file doesn't exist or pandas unavailable. """ if path_exists(file_path) is False: - _logger.warning("The file %s could not be found.", file_path) + if optional: + _logger.debug("Optional file %s not found, skipping.", file_path) + else: + _logger.warning("The file %s could not be found.", file_path) else: if _PANDAS: dataframe = pd.read_csv(file_path, sep=separator_character, skiprows=skip_rows) diff --git a/solarfarmer/models/model_chain_response.py b/solarfarmer/models/model_chain_response.py index afee99a..54554ec 100644 --- a/solarfarmer/models/model_chain_response.py +++ b/solarfarmer/models/model_chain_response.py @@ -56,6 +56,14 @@ class ModelChainResponse: TotalModuleArea : float | None Total PV module area in square meters (m²) for the entire plant. None if not returned by the API. + TrackerResultsIncidenceAngles : list[str] | None + List of CSV text contents for the trackers' incidence angles timeseries. + Each entry corresponds to one tracker group or file chunk. + None if not returned by the API (e.g., non-tracker plants). + TrackerResultsRotationAngles : list[str] | None + List of CSV text contents for the trackers' rotation angles timeseries. + Each entry corresponds to one tracker group or file chunk. + None if not returned by the API (e.g., non-tracker plants). """ Name: str | None = None @@ -67,6 +75,8 @@ class ModelChainResponse: ResultsFile: str | None = None SystemAttributes: dict[str, Any] | None = None TotalModuleArea: float | None = None + TrackerResultsIncidenceAngles: list[str] | None = None + TrackerResultsRotationAngles: list[str] | None = None def __repr__(self) -> str: """ @@ -87,6 +97,8 @@ def __repr__(self) -> str: f"ResultsFile={'present' if self.ResultsFile else 'None'}", f"SystemAttributes={'present' if self.SystemAttributes else 'None'}", f"TotalModuleArea={self.TotalModuleArea}", + f"TrackerResultsIncidenceAngles={len(self.TrackerResultsIncidenceAngles) if self.TrackerResultsIncidenceAngles else 'None'} file(s)", + f"TrackerResultsRotationAngles={len(self.TrackerResultsRotationAngles) if self.TrackerResultsRotationAngles else 'None'} file(s)", ] return f"ModelChainResponse({', '.join(fields)})" @@ -199,4 +211,6 @@ def from_dict(cls, data: dict[str, Any], project_id: str | None = None) -> Model ResultsFile=data.get("resultsFile"), SystemAttributes=data.get("systemAttributes"), TotalModuleArea=data.get("totalModuleArea"), + TrackerResultsIncidenceAngles=data.get("trackerResultsIncidenceAngles"), + TrackerResultsRotationAngles=data.get("trackerResultsRotationAngles"), ) diff --git a/tests/test_energy_calculation_results.py b/tests/test_energy_calculation_results.py index 410db79..20117ee 100644 --- a/tests/test_energy_calculation_results.py +++ b/tests/test_energy_calculation_results.py @@ -346,6 +346,8 @@ def test_get_info_all_expected_keys_present(self, results): "has_loss_tree_timeseries", "has_pvsyst_timeseries", "has_detailed_timeseries", + "has_tracker_incidence_angles_timeseries", + "has_tracker_rotation_angles_timeseries", } assert expected_keys == set(results.get_info().keys())