Skip to content
Merged
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
4 changes: 4 additions & 0 deletions solarfarmer/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .__version__ import __version__
from .api import SolarFarmerAPIError
from .config import (
ABOUT_ENDPOINT_URL,
ANNUAL_MONTHLY_RESULTS_FILENAME,
Expand Down Expand Up @@ -54,6 +55,7 @@
TransformerSpecification,
ValidationMessage,
)
from .weather import TSV_COLUMNS

__all__ = [
"__version__",
Expand Down Expand Up @@ -104,8 +106,10 @@
"PVSystem",
"run_energy_calculation",
"service",
"SolarFarmerAPIError",
"terminate_calculation",
"TrackerSystem",
"TSV_COLUMNS",
"Transformer",
"TransformerLossModelTypes",
"TransformerSpecification",
Expand Down
47 changes: 45 additions & 2 deletions solarfarmer/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,55 @@ class Response:
success: bool
method: str
exception: str | None = None
problem_details_json: str | None = None
problem_details_json: dict | None = None

def __repr__(self) -> str:
return f"status code={self.code}, url={self.url}, method={self.method}"


class SolarFarmerAPIError(Exception):
"""Raised when the SolarFarmer API returns a non-2xx response.

Attributes
----------
status_code : int
HTTP status code returned by the API.
message : str
Human-readable error message extracted from the response.
problem_details : dict or None
Full ProblemDetails JSON body from the API, if available.
May contain ``title``, ``detail``, and ``errors`` fields.

Examples
--------
>>> import solarfarmer as sf
>>> try:
... result = sf.run_energy_calculation(inputs_folder_path="my_inputs/")
... except sf.SolarFarmerAPIError as e:
... logger.error("API error %s: %s", e.status_code, e)
... raise
"""

def __init__(
self,
status_code: int,
message: str,
problem_details: dict | None = None,
) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.problem_details = problem_details

def __str__(self) -> str:
base = f"HTTP {self.status_code}: {self.message}"
if self.problem_details:
detail = self.problem_details.get("detail")
if detail:
base += f" — {detail}"
return base


class Client:
"""Handles all API requests for the different endpoints."""

Expand Down Expand Up @@ -237,7 +280,7 @@ def _make_request(
try:
problem_details_json = response.json()
except Exception:
problem_details_json = ""
problem_details_json = None

return self.response_class(
code=response.status_code,
Expand Down
116 changes: 99 additions & 17 deletions solarfarmer/endpoint_modelchains.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from datetime import datetime
from typing import IO, Any

from .api import Client, Response, build_api_url
from .api import Client, Response, SolarFarmerAPIError, build_api_url
from .config import (
MODELCHAIN_ASYNC_ENDPOINT_URL,
MODELCHAIN_ASYNC_TIMEOUT_CONNECTION,
Expand All @@ -28,6 +28,69 @@
_logger = get_logger("endpoint.modelchains")


def _validate_spec_ids_match_files(
request_content: str,
files: list[tuple[str, IO[bytes]]],
) -> None:
"""
Validate that module and inverter spec IDs in the payload match uploaded file stems.

The SolarFarmer API resolves ``moduleSpecificationID`` and ``inverterSpecID``
by matching them against the filename stems of uploaded PAN and OND files
(i.e. filename without extension). A mismatch causes a KeyNotFoundException
on the server. This check catches the error client-side before the HTTP call.

Parameters
----------
request_content : str
JSON-serialized API payload.
files : list of tuple[str, IO[bytes]]
Multipart upload files as ``(field_name, file_handle)`` pairs.

Raises
------
ValueError
If a spec ID in the payload has no matching uploaded file.
"""
import json

pan_stems = {
pathlib.Path(f.name).stem
for field, f in files
if field == "panFiles" and hasattr(f, "name")
}
ond_stems = {
pathlib.Path(f.name).stem
for field, f in files
if field == "ondFiles" and hasattr(f, "name")
}

# Nothing to validate if no PAN/OND files were uploaded (e.g. inline payload)
if not pan_stems and not ond_stems:
return

payload = json.loads(request_content)
pv_plant = payload.get("pvPlant", {})

for transformer in pv_plant.get("transformers", []):
for inverter in transformer.get("inverters", []):
if ond_stems:
inv_id = inverter.get("inverterSpecID")
if inv_id and inv_id not in ond_stems:
raise ValueError(
f"Inverter references spec ID '{inv_id}' but no matching OND file "
f"was uploaded. Available stems: {sorted(ond_stems)}"
)
for layout in inverter.get("layouts") or []:
if pan_stems:
mod_id = layout.get("moduleSpecificationID")
if mod_id and mod_id not in pan_stems:
raise ValueError(
f"Layout references module spec '{mod_id}' but no matching PAN file "
f"was uploaded. Available stems: {sorted(pan_stems)}"
)


def _resolve_request_payload(
inputs_folder_path: str | pathlib.Path | None,
energy_calculation_inputs_file_path: str | None,
Expand Down Expand Up @@ -201,7 +264,14 @@ def _handle_successful_response(
runtime_status,
elapsed_time,
)
return None
# "Terminated" means the user explicitly cancelled via terminate_calculation() — not an error.
# All other non-Completed statuses (Failed, Canceled, Unknown) are unexpected failures.
if runtime_status == "Terminated":
return None
message = f"Async calculation ended with status '{runtime_status}'"
if output_message:
message += f": {output_message}"
raise SolarFarmerAPIError(status_code=200, message=message)


def _log_api_failure(response: Response, elapsed_time: float) -> None:
Expand All @@ -225,19 +295,20 @@ def _log_api_failure(response: Response, elapsed_time: float) -> None:
elapsed_time,
)
_logger.error("Failure message: %s", response.exception)
try:
json_response = response.problem_details_json
if json_response is not None and "title" in json_response:
_logger.error("Title: %s", json_response["title"])
if json_response.get("errors") is not None:
_logger.error("Errors:")
for _errorKey, errors in json_response["errors"].items():
for error in errors:
_logger.error(" - %s", error)
if json_response.get("detail") is not None:
_logger.error("Detail: %s", json_response["detail"])
except Exception:
pass
json_response = response.problem_details_json
if json_response is None:
return
if "title" in json_response:
_logger.error("Title: %s", json_response["title"])
errors = json_response.get("errors")
if errors is not None:
_logger.error("Errors:")
for _errorKey, error_list in errors.items():
for error in error_list:
_logger.error(" - %s", error)
detail = json_response.get("detail")
if detail is not None:
_logger.error("Detail: %s", detail)


def run_energy_calculation(
Expand Down Expand Up @@ -342,7 +413,13 @@ def run_energy_calculation(
-------
CalculationResults or None
An instance of CalculationResults with the API results for the project,
or None if the calculation failed or was terminated
or None if the calculation was terminated or cancelled

Raises
------
SolarFarmerAPIError
If the API returns a non-2xx response. The exception carries
``status_code``, ``message``, and the full ``problem_details`` body.
"""

# Fold explicit API params into kwargs for the downstream request functions
Expand All @@ -367,6 +444,7 @@ def run_energy_calculation(
)

# 2. Dispatch to the appropriate endpoint
_validate_spec_ids_match_files(request_content, files)
are_files_3d = check_for_3d_files(request_content)
start_time = time.time()
if force_async_call or are_files_3d:
Expand Down Expand Up @@ -398,7 +476,11 @@ def run_energy_calculation(
)
else:
_log_api_failure(response, elapsed_time)
return None
raise SolarFarmerAPIError(
status_code=response.code,
message=response.exception or "API request failed",
problem_details=response.problem_details_json,
)


def modelchain_call(
Expand Down
45 changes: 40 additions & 5 deletions solarfarmer/models/energy_calculation_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,29 @@ class EnergyCalculationOptions(SolarFarmerBaseModel):
include_horizon : bool
Whether to include horizon shading. Required
calculation_year : int
Year to use for the calculation
Year to use for the calculation. Default is 1990. Behavior depends on
meteorological file format:

- **TSV format**: Ignored — timestamps in the file are used as-is.
- **Meteonorm .dat format**: All timestamps are replaced with this year
while preserving month/day/hour. Used for calculating solar position.
- **PVsyst CSV format with TMY data**: If the file header contains
``#TMY hourly data``, timestamps are replaced with this year. If the
header is ``#Meteo hourly data`` (non-TMY), original years are preserved.

**Warning for TMY files**: When using typical meteorological year (TMY)
data with mixed years per month (common in NSRDB, PVGIS datasets), ensure
the file is properly formatted with the ``#TMY hourly data`` header, or
manually remap all timestamps to a single year. Otherwise, only rows
matching ``calculation_year`` may be processed, causing silent partial-year
calculations with incorrect results.
default_wind_speed : float
Wind speed (m/s) when met data has no wind
calculate_dhi : bool
Whether to calculate DHI from GHI
Whether to calculate diffuse horizontal irradiance (DHI) from global
horizontal irradiance (GHI) when the ``DHI`` column is missing from the
meteorological file. If ``False`` and ``DHI`` is missing, the calculation
will fail. Set to ``True`` when using weather files that only provide GHI.
apply_diffuse_to_horizon : bool or None
Apply diffuse irradiance to the horizon model. Engine default is True
horizon_type : HorizonType or None
Expand Down Expand Up @@ -69,7 +87,17 @@ class EnergyCalculationOptions(SolarFarmerBaseModel):
solar_zenith_angle_limit : float or None
Maximum solar zenith angle in degrees, range [75, 89.9]
missing_met_data_handling : MissingMetDataMethod or None
How to handle missing meteorological data
How to handle missing meteorological data (NaN values, empty cells, or
the sentinel value ``9999`` / ``-9999`` in TSV files) in required columns:
``GHI``, ``DHI``, ``TAmb``, ``WS``. Options:

- ``MissingMetDataMethod.FAIL_ON_VALIDATION``: Raise an error if any
required data is missing.
- ``MissingMetDataMethod.REMOVE_TIMESTAMP``: Skip timesteps with missing
data and continue the calculation.

If ``None`` (default), the engine uses ``FAIL_ON_VALIDATION``.
See :data:`solarfarmer.weather.TSV_COLUMNS` for TSV sentinel value details.
return_pv_syst_format_time_series_results : bool
Return PVsyst-format time-series results
return_detailed_time_series_results : bool
Expand All @@ -81,9 +109,16 @@ class EnergyCalculationOptions(SolarFarmerBaseModel):
choice_columns_order_pv_syst_format_time_series : OrderColumnsPvSystFormatTimeSeries or None
Column ordering for PVsyst-format output
use_albedo_from_met_data_when_available : bool or None
Use albedo from met data when available. Engine default is True
Use albedo from the meteorological file's ``Albedo`` column when available,
instead of the ``MonthlyAlbedo`` payload values. If ``True`` and the
``Albedo`` column is present, monthly albedo values are ignored. If the
column is absent, falls back to ``MonthlyAlbedo``. Engine default is ``True``.
use_soiling_from_met_data_when_available : bool or None
Use soiling from met data when available. Engine default is True
Use soiling loss from the meteorological file's ``Soiling`` column when
available, instead of the ``MountingTypeSpecification.monthly_soiling_loss``
values. If ``True`` and the ``Soiling`` column is present, monthly soiling
values are ignored. If the column is absent, falls back to
``monthly_soiling_loss``. Engine default is ``True``.
module_mismatch_bin_width : float or None
Bin width for module mismatch deduplication. Advanced setting
connector_resistance_bin_width : float or None
Expand Down
33 changes: 32 additions & 1 deletion solarfarmer/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,40 @@ class IAMModelTypeForOverride(str, Enum):


class MeteoFileFormat(str, Enum):
"""Meteorological file format."""
"""Meteorological file format.

The SDK maps file extensions to the correct multipart upload field
automatically:

- ``.dat`` → ``tmyFile`` (Meteonorm hourly TMY)
- ``.tsv`` → ``tmyFile`` (SolarFarmer tab-separated values)
- ``.csv`` → ``pvSystStandardFormatFile`` (PVsyst standard format)
- ``.gz`` → ``metDataTransferFile`` (SolarFarmer desktop binary export)

For TSV column names, units, timestamp format, and accepted header aliases,
see :data:`solarfarmer.weather.TSV_COLUMNS` or the user guide:
https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/UserGuide/DefineClimate/SolarResources.html
"""

DAT = "dat"
"""Meteonorm PVsyst Hourly TMY format (``.dat``)."""
TSV = "tsv"
"""SolarFarmer tab-separated values format (``.tsv``).

Tab-separated, one row per timestep. Timestamp format is
``YYYY-MM-DDThh:mm+OO:OO`` (ISO 8601, mandatory UTC offset,
``T`` separator required, no seconds component). Example::

DateTime GHI DHI Temp Water Pressure Albedo Soiling
2011-02-02T13:40+00:00 1023.212 1175.619 23.123 1.4102 997 0.2 0.01
2011-02-02T13:50+00:00 1026.319 1175.092 23.322 2.0391 997 0.2 0.02
2011-02-02T14:00+00:00 871.987 1008.851 23.764 8.9167 1004 0.2 0.03

See :data:`solarfarmer.weather.TSV_COLUMNS` for the full column
specification including required/optional columns, units, ranges,
and accepted header aliases.
"""
PVSYST_STANDARD_FORMAT = "PvSystStandardFormat"
"""PVsyst standard CSV export format (``.csv``)."""
PROTOBUF_GZ = "ProtobufGz"
"""SolarFarmer desktop binary transfer format (protobuf, gzip-compressed, ``.gz``)."""
5 changes: 4 additions & 1 deletion solarfarmer/models/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ class Inverter(SolarFarmerBaseModel):
Attributes
----------
inverter_spec_id : str
Reference to an inverter specification (OND file key)
Reference to an inverter specification. Must exactly match the
filename stem of the uploaded OND file (filename without the
``.OND`` extension). Example: ``"Sungrow_SG125HV_APP"`` for a
file named ``Sungrow_SG125HV_APP.OND``.
inverter_count : int
Number of identical inverters, >= 1
layouts : list[Layout] or None
Expand Down
5 changes: 4 additions & 1 deletion solarfarmer/models/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ class Layout(SolarFarmerBaseModel):
layout_count : int
Number of identical copies of this layout, >= 1
module_specification_id : str
Reference to a module specification (PAN file key)
Reference to a module specification. Must exactly match the
filename stem of the uploaded PAN file (filename without the
``.PAN`` extension). Example: ``"Trina_TSM-DEG19C.20-550_APP"``
for a file named ``Trina_TSM-DEG19C.20-550_APP.PAN``.
mounting_type_id : str
Reference to a mounting type specification
is_trackers : bool
Expand Down
Loading
Loading