From e9f1389a1e036fbb903f4df47f22f2d81a50041d Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Thu, 9 Apr 2026 13:13:34 -0700 Subject: [PATCH 01/18] Use Path.stem for PAN/OND spec IDs, export PVSystem enums, improve docstrings --- solarfarmer/__init__.py | 6 +++ solarfarmer/models/__init__.py | 4 ++ .../models/energy_calculation_results.py | 18 +++++++ solarfarmer/models/pvsystem/__init__.py | 4 ++ solarfarmer/models/pvsystem/pvsystem.py | 25 ++++++++- solarfarmer/weather.py | 37 +++++++++++++ tests/test_construct_plant.py | 52 +++++++++++++++++++ 7 files changed, 144 insertions(+), 2 deletions(-) diff --git a/solarfarmer/__init__.py b/solarfarmer/__init__.py index 14038c2..18fcee3 100644 --- a/solarfarmer/__init__.py +++ b/solarfarmer/__init__.py @@ -37,15 +37,18 @@ IAMModelTypeForOverride, Inverter, InverterOverPowerShutdownMode, + InverterType, Layout, Location, MeteoFileFormat, MissingMetDataMethod, ModelChainResponse, MonthlyAlbedo, + MountingType, MountingTypeSpecification, OndFileSupplements, OrderColumnsPvSystFormatTimeSeries, + OrientationType, PanFileSupplements, PVPlant, PVSystem, @@ -92,15 +95,18 @@ "IAMModelTypeForOverride", "Inverter", "InverterOverPowerShutdownMode", + "InverterType", "Layout", "Location", "MeteoFileFormat", "MissingMetDataMethod", "ModelChainResponse", "MonthlyAlbedo", + "MountingType", "MountingTypeSpecification", "OndFileSupplements", "OrderColumnsPvSystFormatTimeSeries", + "OrientationType", "PanFileSupplements", "PVPlant", "PVSystem", diff --git a/solarfarmer/models/__init__.py b/solarfarmer/models/__init__.py index 5c9c6cd..503b14e 100644 --- a/solarfarmer/models/__init__.py +++ b/solarfarmer/models/__init__.py @@ -26,6 +26,7 @@ from .pan_supplements import PanFileSupplements from .pv_plant import PVPlant from .pvsystem.pvsystem import PVSystem +from .pvsystem.plant_defaults import InverterType, MountingType, OrientationType from .pvsystem.validation import ValidationMessage from .tracker_system import TrackerSystem from .transformer import Transformer @@ -42,15 +43,18 @@ "IAMModelTypeForOverride", "Inverter", "InverterOverPowerShutdownMode", + "InverterType", "Layout", "Location", "MeteoFileFormat", "MissingMetDataMethod", "ModelChainResponse", "MonthlyAlbedo", + "MountingType", "MountingTypeSpecification", "OndFileSupplements", "OrderColumnsPvSystFormatTimeSeries", + "OrientationType", "PanFileSupplements", "PVPlant", "PVSystem", diff --git a/solarfarmer/models/energy_calculation_results.py b/solarfarmer/models/energy_calculation_results.py index c088a51..109531c 100644 --- a/solarfarmer/models/energy_calculation_results.py +++ b/solarfarmer/models/energy_calculation_results.py @@ -127,6 +127,24 @@ class CalculationResults: SolarFarmer performance model. Useful for debugging. Name: str or None Name of project. It is populated with the ``project_id`` property if availabe. + + Examples + -------- + Retrieve key performance metrics for the first project year: + + >>> perf = results.get_performance(project_year=1) + >>> print(f"Net energy: {perf['net_energy']:.2f} MWh/year") + >>> print(f"Performance ratio: {perf['performance_ratio']:.3f}") + >>> print(f"Specific yield: {perf['energy_yield']:.1f} kWh/kWp") + + Print a summary table: + + >>> results.describe() + + Access monthly or annual DataFrames: + + >>> annual_df = results.get_annual_results_table() + >>> monthly_df = results.get_monthly_results_table() """ ModelChainResponse: ModelChainResponse diff --git a/solarfarmer/models/pvsystem/__init__.py b/solarfarmer/models/pvsystem/__init__.py index f3a0da3..4af9242 100644 --- a/solarfarmer/models/pvsystem/__init__.py +++ b/solarfarmer/models/pvsystem/__init__.py @@ -1,7 +1,11 @@ """Plant model subpackage for PV plant design and configuration.""" +from .plant_defaults import InverterType, MountingType, OrientationType from .validation import ValidationMessage __all__ = [ + "InverterType", + "MountingType", + "OrientationType", "ValidationMessage", ] diff --git a/solarfarmer/models/pvsystem/pvsystem.py b/solarfarmer/models/pvsystem/pvsystem.py index 35ae995..668d2b0 100644 --- a/solarfarmer/models/pvsystem/pvsystem.py +++ b/solarfarmer/models/pvsystem/pvsystem.py @@ -115,6 +115,7 @@ class PVSystem: Array azimuth in degrees (default 180, i.e., south-facing). mounting: str Mounting configuration: 'Fixed' for fixed-tilt or 'Tracker' for single-axis trackers. + Available as ``solarfarmer.MountingType`` enum (e.g., ``sf.MountingType.FIXED``). flush_mount : bool If True, indicates flush-mounted arrays (default is False). bifacial: bool @@ -318,6 +319,20 @@ def _ensure_len_12(self, arr: Sequence[float], *, name: str) -> None: # ----------------------------- @property def weather_file(self) -> Path | None: + """Path to a meteorological data file (TSV, Meteonorm .dat, or PVsyst CSV). + + .. warning:: + TMY (Typical Meteorological Year) data from sources like NSRDB or + PVGIS contains timestamps from multiple source years. When using TSV + format, all timestamps must belong to a single contiguous calendar + year. Remap mixed-year TMY timestamps to one year (e.g., 1990) + before submission; otherwise the API will return a 400 error. + + See Also + -------- + EnergyCalculationOptions.calculation_year : Controls year handling + for Meteonorm and PVsyst TMY formats. + """ return self._weather_file @weather_file.setter @@ -428,6 +443,9 @@ def pan_files(self, mapping: Mapping[str, PathLike]) -> None: ---------- mapping : dict[str, str|Path] Mapping 'Name of Module' -> file path (string or Path). + Keys are user-facing labels only. The spec ID sent to the API + is derived from the filename via ``Path.stem`` (everything + before the last dot), not from the dict key. """ self._pan_files.clear() for name, p in mapping.items(): @@ -468,6 +486,9 @@ def ond_files(self, mapping: Mapping[str, PathLike]) -> None: ---------- mapping : dict[str, str|Path] Mapping 'Name of Inverter' -> file path (string or Path). + Keys are user-facing labels only. The spec ID sent to the API + is derived from the filename via ``Path.stem`` (everything + before the last dot), not from the dict key. """ self._ond_files.clear() for name, p in mapping.items(): @@ -1707,7 +1728,7 @@ def get_inverter_info_from_ond(plant: PVSystem) -> dict[str, Any]: # Store the data indexed by inverter name inverter_info[inverter_name] = { "name": inverter_name, - "ond_filename": ond_filename.split(".")[0], # Use filename without extension as spec ID + "ond_filename": Path(ond_filename).stem, # Use filename without extension as spec ID "path": ond_file_path, "data": ond_dict, } @@ -1734,7 +1755,7 @@ def get_module_info_from_pan(plant: PVSystem) -> dict[str, Any]: module_info[module_name] = { "name": module_name, "path": pan_file_path, - "pan_filename": pan_filename.split(".")[0], # Use filename without extension as spec ID + "pan_filename": Path(pan_filename).stem, # Use filename without extension as spec ID "data": pan_dict, } diff --git a/solarfarmer/weather.py b/solarfarmer/weather.py index 430519f..ff12868 100644 --- a/solarfarmer/weather.py +++ b/solarfarmer/weather.py @@ -13,6 +13,43 @@ Timestamp format: ``YYYY-MM-DDThh:mm+OO:OO`` — mandatory UTC offset, ``T`` separator required, no seconds (e.g. ``2011-02-02T13:40+00:00``). + +TMY Data Warning +~~~~~~~~~~~~~~~~ +Typical Meteorological Year (TMY) datasets (e.g., from NSRDB PSM or PVGIS) +contain timestamps drawn from different source years. When writing a TSV file, +**all timestamps must belong to a single contiguous calendar year**. Remap +mixed-year timestamps to one year (e.g., 1990) before export; otherwise the +SolarFarmer API will return an HTTP 400 error with no field-level detail. + +pvlib Column Mapping +~~~~~~~~~~~~~~~~~~~~ +When converting a ``pvlib`` DataFrame to SolarFarmer TSV format, use the +following column name mapping and note the unit for Pressure: + +============== =========== ========================== +pvlib column SF column Notes +============== =========== ========================== +``ghi`` ``GHI`` W/m² +``dhi`` ``DHI`` W/m² +``temp_air`` ``TAmb`` °C +``wind_speed`` ``WS`` m/s +``pressure`` ``Pressure`` **Convert Pa → mbar** (÷ 100) +============== =========== ========================== + +Minimal conversion example:: + + import pandas as pd + + rename = {"ghi": "GHI", "dhi": "DHI", "temp_air": "TAmb", + "wind_speed": "WS", "pressure": "Pressure"} + df = pvlib_df.rename(columns=rename) + df["Pressure"] = df["Pressure"] / 100 # Pa → mbar + df.index = df.index.map( + lambda t: t.replace(year=1990).strftime("%Y-%m-%dT%H:%M+00:00") + ) + df.index.name = "DateTime" + df.to_csv("weather.tsv", sep="\\t") """ __all__ = ["TSV_COLUMNS"] diff --git a/tests/test_construct_plant.py b/tests/test_construct_plant.py index f16f272..671a937 100644 --- a/tests/test_construct_plant.py +++ b/tests/test_construct_plant.py @@ -106,3 +106,55 @@ def total_inverters(pv_plant): small_plant, _ = design_plant(small) large_plant, _ = design_plant(large) assert total_inverters(small_plant) < total_inverters(large_plant) + + +class TestSpecIdDerivation: + """Verify spec IDs are derived via Path.stem (last-dot split), not split('.')[0].""" + + def test_multi_dot_pan_filename_produces_correct_spec_id(self, bern_2d_racks_inputs): + """PAN filenames with multiple dots must use Path.stem for spec ID.""" + from pathlib import Path + + from solarfarmer.models.pvsystem.pvsystem import get_module_info_from_pan + + p = PVSystem(latitude=46.95, longitude=7.44) + original = Path(bern_2d_racks_inputs) / "CanadianSolar_CS6U-330M_APP.PAN" + multi_dot = Path(bern_2d_racks_inputs) / "Trina_TSM-DEG19C.20-550_APP.PAN" + + created_link = False + try: + if not multi_dot.exists(): + multi_dot.symlink_to(original) + created_link = True + p.pan_files = {"TestModule": str(multi_dot)} + info = get_module_info_from_pan(p) + # Path.stem gives "Trina_TSM-DEG19C.20-550_APP" + # split(".")[0] would give "Trina_TSM-DEG19C" — wrong + assert info["pan_filename"] == "Trina_TSM-DEG19C.20-550_APP" + finally: + if created_link and multi_dot.exists(): + multi_dot.unlink() + + def test_multi_dot_ond_filename_produces_correct_spec_id(self, bern_2d_racks_inputs): + """OND filenames with multiple dots must use Path.stem for spec ID.""" + from pathlib import Path + + from solarfarmer.models.pvsystem.pvsystem import get_inverter_info_from_ond + + p = PVSystem(latitude=46.95, longitude=7.44) + original = Path(bern_2d_racks_inputs) / "Sungrow_SG125HV_APP.OND" + multi_dot = Path(bern_2d_racks_inputs) / "SMA_Sunny-Central.2200-UP.OND" + + created_link = False + try: + if not multi_dot.exists(): + multi_dot.symlink_to(original) + created_link = True + p.ond_files = {"TestInverter": str(multi_dot)} + info = get_inverter_info_from_ond(p) + # Path.stem gives "SMA_Sunny-Central.2200-UP" + # split(".")[0] would give "SMA_Sunny-Central" — wrong + assert info["ond_filename"] == "SMA_Sunny-Central.2200-UP" + finally: + if created_link and multi_dot.exists(): + multi_dot.unlink() From 248132d6bb2b464573091cbb84980ef6fc4a31ac Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Thu, 9 Apr 2026 14:37:12 -0700 Subject: [PATCH 02/18] Fix PAN/OND spec ID parsing, add weather conversion utils and convenience APIs, improve docs per SDK --- README.md | 24 ++- docs/getting-started/index.md | 9 + pyproject.toml | 7 +- solarfarmer/__init__.py | 5 +- solarfarmer/endpoint_modelchains_utils.py | 3 + .../models/energy_calculation_results.py | 40 ++-- solarfarmer/models/pvsystem/pvsystem.py | 26 ++- solarfarmer/weather.py | 171 +++++++++++++++++- tests/test_construct_plant.py | 43 +++++ tests/test_energy_calculation_results.py | 34 ++++ tests/test_weather.py | 157 ++++++++++++++++ 11 files changed, 489 insertions(+), 30 deletions(-) create mode 100644 tests/test_weather.py diff --git a/README.md b/README.md index 25c965d..8a996c4 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,10 @@ import solarfarmer as sf Install with optional extras: ```bash +pip install "dnv-solarfarmer[weather]" # pandas for weather file conversion and DataFrame results pip install "dnv-solarfarmer[notebooks]" # JupyterLab and notebook support -pip install "dnv-solarfarmer[all]" # full installation including pandas and matplotlib -pip install "dnv-solarfarmer[dev]" # linting and testing tools (for contributors) +pip install "dnv-solarfarmer[all]" # full installation including pandas and matplotlib +pip install "dnv-solarfarmer[dev]" # linting and testing tools (for contributors) ``` Install from source: @@ -70,6 +71,25 @@ Alternatively, pass it directly as the `api_key` parameter to any function that | `SF_API_KEY` | *(none — required for calculations)* | API authentication token | | `SF_API_URL` | `https://solarfarmer.dnv.com/latest/api` | Override the base API URL for custom deployments | +## Optional Dependencies + +The core SDK (`pydantic`, `requests`, `tabulate`) has no dependency on `pandas`. +Install the `weather` extra to unlock DataFrame-based features: + +```bash +pip install "dnv-solarfarmer[weather]" +``` + +**Functions that require pandas:** + +| Function / Feature | Module | What it does | +|---|---|---| +| `sf.from_dataframe()` | `solarfarmer.weather` | Write a DataFrame to SolarFarmer TSV weather file | +| `sf.from_pvlib()` | `solarfarmer.weather` | Convert a pvlib DataFrame to TSV (column rename + unit conversion) | +| `CalculationResults` timeseries parsing | `solarfarmer.models` | Parse loss-tree, PVsyst-format, and detailed timeseries into DataFrames | + +Without pandas these functions raise `ImportError` (weather utilities) or return `None` with a warning (result timeseries parsing). All other SDK functionality — building payloads, running calculations, accessing annual/monthly summary data — works without pandas. + ## Getting Started The SDK is built around three workflows suited to different use cases: diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 1930904..08f2cc0 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -15,6 +15,15 @@ The SolarFarmer SDK supports three distinct user workflows. Choose the one that Once you have your API key, provide it to the SDK using either the **SF_API_KEY** environment variable or pass it directly as the `api_key` argument when calling the energy calculation function. +!!! tip "Optional: pandas for weather conversion and timeseries results" + + The core SDK works without pandas. To use `sf.from_dataframe()`, `sf.from_pvlib()`, + or to parse timeseries results as DataFrames, install the `weather` extra: + + ```bash + pip install "dnv-solarfarmer[weather]" + ``` + ## Choose Your Path ### [Workflow 1: Load and Execute Existing API Files](workflow-1-existing-api-files.md) diff --git a/pyproject.toml b/pyproject.toml index 9081d5d..914c69a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,10 @@ dev = [ "ruff>=0.5", ] +weather = [ + "pandas>=2.0", +] + notebooks = [ "ipykernel>=6.29,<8", "jupyterlab>=4.0,<6", @@ -58,8 +62,7 @@ notebooks = [ ] all = [ - "dnv-solarfarmer[docs,dev,notebooks]", - "pandas>=2.0", + "dnv-solarfarmer[docs,dev,notebooks,weather]", "matplotlib>=3.5", ] diff --git a/solarfarmer/__init__.py b/solarfarmer/__init__.py index 18fcee3..aa8a8a0 100644 --- a/solarfarmer/__init__.py +++ b/solarfarmer/__init__.py @@ -58,7 +58,7 @@ TransformerSpecification, ValidationMessage, ) -from .weather import TSV_COLUMNS +from .weather import TSV_COLUMNS, from_dataframe, from_pvlib, validate_tsv_timestamps __all__ = [ "__version__", @@ -120,4 +120,7 @@ "TransformerLossModelTypes", "TransformerSpecification", "ValidationMessage", + "from_dataframe", + "from_pvlib", + "validate_tsv_timestamps", ] diff --git a/solarfarmer/endpoint_modelchains_utils.py b/solarfarmer/endpoint_modelchains_utils.py index cfe17d2..8f981d2 100644 --- a/solarfarmer/endpoint_modelchains_utils.py +++ b/solarfarmer/endpoint_modelchains_utils.py @@ -7,6 +7,7 @@ from .config import MODELCHAIN_ASYNC_POLL_TIME from .logging import get_logger +from .weather import validate_tsv_timestamps _logger = get_logger("endpoint.modelchains.utils") @@ -96,6 +97,7 @@ def get_files(sample_data_folder: str | pathlib.Path) -> list[tuple[str, IO[byte ) if tsv_file_paths: _logger.debug("tmyFile = %s", tsv_file_paths[0]) + validate_tsv_timestamps(tsv_file_paths[0]) fh = pathlib.Path(tsv_file_paths[0]).open("rb") stack.callback(fh.close) files.append(("tmyFile", fh)) @@ -310,6 +312,7 @@ def parse_files_from_paths( extension_met_data = pathlib.Path(meteorological_data_file_path).suffix.lower() if extension_met_data in (".tsv", ".dat"): + validate_tsv_timestamps(meteorological_data_file_path) fh = pathlib.Path(meteorological_data_file_path).open("rb") stack.callback(fh.close) files.append(("tmyFile", fh)) diff --git a/solarfarmer/models/energy_calculation_results.py b/solarfarmer/models/energy_calculation_results.py index 109531c..ef7996a 100644 --- a/solarfarmer/models/energy_calculation_results.py +++ b/solarfarmer/models/energy_calculation_results.py @@ -30,10 +30,10 @@ try: import pandas as pd + + _PANDAS = True except ImportError: _PANDAS = False -else: - _PANDAS = True # Constants for accessing the results data ANNUAL_ENERGY_YIELD_RESULTS_KEY = "energyYieldResults" @@ -156,6 +156,23 @@ class CalculationResults: DetailedTimeseries: pd.DataFrame | None = None Name: str | None = None + # ----- Convenience properties for common metrics (year 1) ----- + + @property + def net_energy_MWh(self) -> float: + """Net energy production for year 1 in MWh/year.""" + return self.get_performance(project_year=1).get("net_energy", float("nan")) + + @property + def performance_ratio(self) -> float: + """Performance ratio for year 1 (0–1).""" + return self.get_performance(project_year=1).get("performance_ratio", float("nan")) + + @property + def energy_yield_kWh_per_kWp(self) -> float: + """Specific energy yield for year 1 in kWh/kWp.""" + return self.get_performance(project_year=1).get("energy_yield", float("nan")) + def __repr__(self) -> str: """Return a concise string representation of the CalculationResults.""" num_years = len(self.AnnualData) if self.AnnualData else 0 @@ -1710,9 +1727,8 @@ def _handle_losstree_results( return data else: warnings.warn( - "Warning: Pandas library is needed to " - "read the loss tree results timeseries. " - "Please run 'pip install pandas'.", + "pandas is required to parse loss tree timeseries. " + "Install with: pip install 'dnv-solarfarmer[weather]'", stacklevel=2, ) return None @@ -1767,9 +1783,8 @@ def _handle_pvsyst_results( return data else: warnings.warn( - "Warning: Pandas library is needed to " - "read the pvsyst-style results timeseries. " - "Please run 'pip install pandas'.", + "pandas is required to parse PVsyst-format timeseries. " + "Install with: pip install 'dnv-solarfarmer[weather]'", stacklevel=2, ) return None @@ -1820,9 +1835,8 @@ def _handle_timeseries_results( return data else: warnings.warn( - "Warning: Pandas library is needed to " - "read the detailed results timeseries. " - "Please run 'pip install pandas'.", + "pandas is required to parse detailed timeseries. " + "Install with: pip install 'dnv-solarfarmer[weather]'", stacklevel=2, ) return None @@ -1956,8 +1970,8 @@ def _read_dataframe_pandas_safe( return dataframe else: warnings.warn( - "Warning: Pandas library is needed to " - "read the file. Please run 'pip install pandas'.", + "pandas is required to read result files. " + "Install with: pip install 'dnv-solarfarmer[weather]'", stacklevel=2, ) return None diff --git a/solarfarmer/models/pvsystem/pvsystem.py b/solarfarmer/models/pvsystem/pvsystem.py index 668d2b0..50ca7be 100644 --- a/solarfarmer/models/pvsystem/pvsystem.py +++ b/solarfarmer/models/pvsystem/pvsystem.py @@ -436,18 +436,25 @@ def pan_files(self) -> dict[str, Path]: return dict(self._pan_files) @pan_files.setter - def pan_files(self, mapping: Mapping[str, PathLike]) -> None: + def pan_files(self, mapping: Mapping[str, PathLike] | Sequence[PathLike]) -> None: """Set PAN files, replacing any existing mappings. Parameters ---------- - mapping : dict[str, str|Path] - Mapping 'Name of Module' -> file path (string or Path). + mapping : dict[str, str|Path] or list[str|Path] + Mapping 'Name of Module' -> file path (string or Path), or a list + of file paths. When a list is provided, keys are derived from + each filename via ``Path.stem``. Keys are user-facing labels only. The spec ID sent to the API is derived from the filename via ``Path.stem`` (everything before the last dot), not from the dict key. """ self._pan_files.clear() + if isinstance(mapping, (list, tuple)): + for p in mapping: + path = Path(p) + self._pan_files[path.stem] = path + return for name, p in mapping.items(): key = str(name).strip() if not key: @@ -479,18 +486,25 @@ def ond_files(self) -> dict[str, Path]: return dict(self._ond_files) @ond_files.setter - def ond_files(self, mapping: Mapping[str, PathLike]) -> None: + def ond_files(self, mapping: Mapping[str, PathLike] | Sequence[PathLike]) -> None: """Set OND files, replacing any existing mappings. Parameters ---------- - mapping : dict[str, str|Path] - Mapping 'Name of Inverter' -> file path (string or Path). + mapping : dict[str, str|Path] or list[str|Path] + Mapping 'Name of Inverter' -> file path (string or Path), or a + list of file paths. When a list is provided, keys are derived + from each filename via ``Path.stem``. Keys are user-facing labels only. The spec ID sent to the API is derived from the filename via ``Path.stem`` (everything before the last dot), not from the dict key. """ self._ond_files.clear() + if isinstance(mapping, (list, tuple)): + for p in mapping: + path = Path(p) + self._ond_files[path.stem] = path + return for name, p in mapping.items(): key = str(name).strip() if not key: diff --git a/solarfarmer/weather.py b/solarfarmer/weather.py index ff12868..64e067e 100644 --- a/solarfarmer/weather.py +++ b/solarfarmer/weather.py @@ -52,11 +52,173 @@ df.to_csv("weather.tsv", sep="\\t") """ -__all__ = ["TSV_COLUMNS"] +from __future__ import annotations + +import pathlib +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pandas as pd + +_PANDAS_INSTALL_MSG = ( + "pandas is required for this function. " + "Install it with: pip install 'dnv-solarfarmer[weather]'" +) + +__all__ = ["TSV_COLUMNS", "validate_tsv_timestamps", "from_dataframe", "from_pvlib"] + + +def validate_tsv_timestamps(file_path: str | pathlib.Path) -> None: + """Check that all timestamps in a TSV weather file belong to a single year. + + Parameters + ---------- + file_path : str or Path + Path to the TSV weather file. + + Raises + ------ + ValueError + If timestamps span more than one calendar year. + """ + path = pathlib.Path(file_path) + year_pattern = re.compile(r"^(\d{4})-") + years: set[str] = set() + + with path.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + m = year_pattern.match(line) + if m: + years.add(m.group(1)) + + if len(years) > 1: + sorted_years = sorted(years) + raise ValueError( + f"TSV weather file contains timestamps from multiple years: " + f"{sorted_years}. SolarFarmer requires all timestamps to belong " + f"to a single contiguous calendar year. Remap timestamps to one " + f"year (e.g., 1990) before submission." + ) + + +PVLIB_COLUMN_MAP: dict[str, str] = { + "ghi": "GHI", + "dhi": "DHI", + "temp_air": "TAmb", + "wind_speed": "WS", + "pressure": "Pressure", +} + + +def from_dataframe( + df: pd.DataFrame, + output_path: str | pathlib.Path, + *, + column_map: dict[str, str] | None = None, + year: int | None = None, + pressure_pa_to_mbar: bool = False, +) -> pathlib.Path: + """Write a DataFrame to a SolarFarmer TSV weather file. + + .. note:: Requires ``pandas``. Install with ``pip install 'dnv-solarfarmer[weather]'``. + + Parameters + ---------- + df : pandas.DataFrame + DataFrame with a DatetimeIndex and meteorological columns. + output_path : str or Path + Destination file path. + column_map : dict[str, str], optional + DataFrame column names → SolarFarmer TSV column names. + Columns not in the map are passed through unchanged. + year : int, optional + Remap all timestamps to this calendar year (needed for TMY data). + pressure_pa_to_mbar : bool, default False + Divide the ``Pressure`` column by 100 (Pa → mbar) after renaming. + + Returns + ------- + pathlib.Path + + Raises + ------ + ValueError + If the DataFrame has no DatetimeIndex. + ImportError + If pandas is not installed. + """ + try: + import pandas as pd + except ImportError: + raise ImportError(_PANDAS_INSTALL_MSG) from None + + if not isinstance(df.index, pd.DatetimeIndex): + raise ValueError( + "DataFrame must have a DatetimeIndex. " + "Use df.set_index(pd.to_datetime(df['timestamp'])) or similar." + ) + + out = df.copy() + + if column_map: + out = out.rename(columns=column_map) + + if pressure_pa_to_mbar and "Pressure" in out.columns: + out["Pressure"] = out["Pressure"] / 100 + + if year is not None: + out.index = out.index.map(lambda t: t.replace(year=year)) + + out.index = out.index.map( + lambda t: t.strftime("%Y-%m-%dT%H:%M") + t.strftime("%z")[:3] + ":" + t.strftime("%z")[3:] + ) + out.index.name = "DateTime" + + path = pathlib.Path(output_path) + out.to_csv(path, sep="\t") + return path + + +def from_pvlib( + df: pd.DataFrame, + output_path: str | pathlib.Path, + *, + year: int = 1990, +) -> pathlib.Path: + """Convert a pvlib DataFrame to a SolarFarmer TSV weather file. + + Wrapper around :func:`from_dataframe` with the standard pvlib column + mapping and Pa → mbar pressure conversion. + + .. note:: Requires ``pandas``. Install with ``pip install 'dnv-solarfarmer[weather]'``. + + Parameters + ---------- + df : pandas.DataFrame + pvlib-style DataFrame (``ghi``, ``dhi``, ``temp_air``, + ``wind_speed``, ``pressure``) with a DatetimeIndex. + output_path : str or Path + Destination file path. + year : int, default 1990 + Remap all timestamps to this calendar year. + + Returns + ------- + pathlib.Path + """ + return from_dataframe( + df, + output_path, + column_map=PVLIB_COLUMN_MAP, + year=year, + pressure_pa_to_mbar=True, + ) TSV_COLUMNS: dict = { - # Required columns — parser raises FormatException if absent. - # DateTime must be the first column; others may appear in any order. "required": [ { "name": "DateTime", @@ -79,7 +241,6 @@ "aliases": ["TAmb", "Temp", "T"], }, ], - # Optional columns — omitting them is accepted. "optional": [ { "name": "DHI", @@ -132,7 +293,5 @@ }, ], "delimiter": "\t", - # Both 9999 and -9999 are treated as missing; 9999.0 / -9999.000 also accepted. - # Behaviour on missing data is controlled by EnergyCalculationOptions.missing_met_data_handling. "missing_value_sentinel": 9999, } diff --git a/tests/test_construct_plant.py b/tests/test_construct_plant.py index 671a937..eee373f 100644 --- a/tests/test_construct_plant.py +++ b/tests/test_construct_plant.py @@ -158,3 +158,46 @@ def test_multi_dot_ond_filename_produces_correct_spec_id(self, bern_2d_racks_inp finally: if created_link and multi_dot.exists(): multi_dot.unlink() + + +class TestListPathInput: + """Verify pan_files and ond_files accept list[Path] in addition to dict.""" + + def test_pan_files_accepts_list(self, bern_2d_racks_inputs): + from pathlib import Path + + p = PVSystem(latitude=46.95, longitude=7.44) + pan_path = Path(bern_2d_racks_inputs) / "CanadianSolar_CS6U-330M_APP.PAN" + p.pan_files = [pan_path] + assert "CanadianSolar_CS6U-330M_APP" in p.pan_files + assert p.pan_files["CanadianSolar_CS6U-330M_APP"] == pan_path + + def test_ond_files_accepts_list(self, bern_2d_racks_inputs): + from pathlib import Path + + p = PVSystem(latitude=46.95, longitude=7.44) + ond_path = Path(bern_2d_racks_inputs) / "Sungrow_SG125HV_APP.OND" + p.ond_files = [ond_path] + assert "Sungrow_SG125HV_APP" in p.ond_files + assert p.ond_files["Sungrow_SG125HV_APP"] == ond_path + + def test_pan_files_list_of_strings(self, bern_2d_racks_inputs): + p = PVSystem(latitude=46.95, longitude=7.44) + p.pan_files = [f"{bern_2d_racks_inputs}/CanadianSolar_CS6U-330M_APP.PAN"] + assert "CanadianSolar_CS6U-330M_APP" in p.pan_files + + def test_dict_still_works(self, bern_2d_racks_inputs): + p = PVSystem(latitude=46.95, longitude=7.44) + p.pan_files = { + "MyLabel": f"{bern_2d_racks_inputs}/CanadianSolar_CS6U-330M_APP.PAN" + } + assert "MyLabel" in p.pan_files + + def test_list_input_constructs_valid_plant(self, bern_2d_racks_inputs): + """Full round-trip: list input → construct_plant → valid JSON.""" + p = PVSystem(latitude=46.95, longitude=7.44) + p.pan_files = [f"{bern_2d_racks_inputs}/CanadianSolar_CS6U-330M_APP.PAN"] + p.ond_files = [f"{bern_2d_racks_inputs}/Sungrow_SG125HV_APP.OND"] + result = construct_plant(p) + payload = json.loads(result) + assert "pvPlant" in payload diff --git a/tests/test_energy_calculation_results.py b/tests/test_energy_calculation_results.py index ee604a5..96756e4 100644 --- a/tests/test_energy_calculation_results.py +++ b/tests/test_energy_calculation_results.py @@ -741,3 +741,37 @@ def test_print_monthly_results_single_year(self, results, capsys): """print_monthly_results(project_years=[1]) should not raise.""" results.print_monthly_results(project_years=[1]) assert capsys.readouterr().out.strip() != "" + + +class TestConvenienceProperties: + """Test cases for convenience properties on CalculationResults.""" + + @pytest.fixture + def results(self): + return _two_year_results() + + def test_net_energy_MWh(self, results): + """net_energy_MWh should return year-1 net energy.""" + assert results.net_energy_MWh == results.get_performance()["net_energy"] + + def test_performance_ratio(self, results): + """performance_ratio should return year-1 PR.""" + assert results.performance_ratio == results.get_performance()["performance_ratio"] + + def test_energy_yield_kWh_per_kWp(self, results): + """energy_yield_kWh_per_kWp should return year-1 specific yield.""" + assert results.energy_yield_kWh_per_kWp == results.get_performance()["energy_yield"] + + def test_empty_results_return_nan(self): + """Convenience properties should return NaN when no data is available.""" + import math + + results = CalculationResults( + ModelChainResponse=ModelChainResponse(Name=None), + AnnualData=[], + MonthlyData=[], + CalculationAttributes={}, + ) + assert math.isnan(results.net_energy_MWh) + assert math.isnan(results.performance_ratio) + assert math.isnan(results.energy_yield_kWh_per_kWp) diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 0000000..bce5a4a --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,157 @@ +"""Tests for weather module: TSV validation and DataFrame conversion utilities.""" + +import textwrap +from pathlib import Path + +import pytest + +from solarfarmer.weather import ( + PVLIB_COLUMN_MAP, + from_dataframe, + from_pvlib, + validate_tsv_timestamps, +) + + +class TestValidateTsvTimestamps: + """Tests for validate_tsv_timestamps().""" + + def test_single_year_passes(self, tmp_path): + tsv = tmp_path / "good.tsv" + tsv.write_text(textwrap.dedent("""\ + DateTime\tGHI\tTAmb + 1990-01-01T00:00+00:00\t0\t5.0 + 1990-06-15T12:00+00:00\t800\t25.0 + 1990-12-31T23:00+00:00\t0\t3.0 + """)) + validate_tsv_timestamps(tsv) # should not raise + + def test_mixed_years_raises(self, tmp_path): + tsv = tmp_path / "bad.tsv" + tsv.write_text(textwrap.dedent("""\ + DateTime\tGHI\tTAmb + 2003-01-01T00:00+00:00\t0\t5.0 + 2010-06-15T12:00+00:00\t800\t25.0 + 2016-12-31T23:00+00:00\t0\t3.0 + """)) + with pytest.raises(ValueError, match="multiple years"): + validate_tsv_timestamps(tsv) + + def test_header_only_passes(self, tmp_path): + tsv = tmp_path / "header_only.tsv" + tsv.write_text("DateTime\tGHI\tTAmb\n") + validate_tsv_timestamps(tsv) # no data lines, no error + + def test_string_path_accepted(self, tmp_path): + tsv = tmp_path / "str_path.tsv" + tsv.write_text(textwrap.dedent("""\ + DateTime\tGHI\tTAmb + 1990-01-01T00:00+00:00\t0\t5.0 + """)) + validate_tsv_timestamps(str(tsv)) # str path should work + + +class TestFromDataframe: + """Tests for from_dataframe().""" + + @pytest.fixture + def sample_df(self): + pd = pytest.importorskip("pandas") + idx = pd.date_range("2020-01-01", periods=3, freq="h", tz="UTC") + return pd.DataFrame( + {"ghi": [0, 100, 200], "temp_air": [5.0, 6.0, 7.0]}, + index=idx, + ) + + def test_basic_write(self, tmp_path, sample_df): + out = from_dataframe(sample_df, tmp_path / "out.tsv") + assert out.exists() + lines = out.read_text().splitlines() + assert lines[0].startswith("DateTime") + assert len(lines) == 4 # header + 3 data rows + + def test_column_rename(self, tmp_path, sample_df): + out = from_dataframe( + sample_df, + tmp_path / "out.tsv", + column_map={"ghi": "GHI", "temp_air": "TAmb"}, + ) + header = out.read_text().splitlines()[0] + assert "GHI" in header + assert "TAmb" in header + + def test_year_remap(self, tmp_path, sample_df): + out = from_dataframe(sample_df, tmp_path / "out.tsv", year=1990) + first_data = out.read_text().splitlines()[1] + assert first_data.startswith("1990-") + + def test_pressure_conversion(self, tmp_path): + pd = pytest.importorskip("pandas") + idx = pd.date_range("2020-01-01", periods=2, freq="h", tz="UTC") + df = pd.DataFrame({"Pressure": [101325.0, 100000.0]}, index=idx) + out = from_dataframe(df, tmp_path / "out.tsv", pressure_pa_to_mbar=True) + lines = out.read_text().splitlines() + # 101325 / 100 = 1013.25 + assert "1013.25" in lines[1] + + def test_no_datetimeindex_raises(self, tmp_path): + pd = pytest.importorskip("pandas") + df = pd.DataFrame({"ghi": [0, 100]}) + with pytest.raises(ValueError, match="DatetimeIndex"): + from_dataframe(df, tmp_path / "out.tsv") + + def test_timestamp_format_has_utc_offset(self, tmp_path, sample_df): + out = from_dataframe(sample_df, tmp_path / "out.tsv") + first_data = out.read_text().splitlines()[1] + # Should contain +00:00 UTC offset + assert "+00:00" in first_data + + def test_returns_path(self, tmp_path, sample_df): + result = from_dataframe(sample_df, tmp_path / "weather.tsv") + assert isinstance(result, Path) + + +class TestFromPvlib: + """Tests for from_pvlib() convenience wrapper.""" + + @pytest.fixture + def pvlib_df(self): + pd = pytest.importorskip("pandas") + idx = pd.date_range("2020-01-01", periods=3, freq="h", tz="UTC") + return pd.DataFrame( + { + "ghi": [0, 500, 800], + "dhi": [0, 200, 300], + "temp_air": [5.0, 15.0, 25.0], + "wind_speed": [2.0, 3.0, 4.0], + "pressure": [101325.0, 101325.0, 101325.0], + }, + index=idx, + ) + + def test_columns_renamed(self, tmp_path, pvlib_df): + out = from_pvlib(pvlib_df, tmp_path / "out.tsv") + header = out.read_text().splitlines()[0] + for sf_col in PVLIB_COLUMN_MAP.values(): + assert sf_col in header + + def test_pressure_converted(self, tmp_path, pvlib_df): + out = from_pvlib(pvlib_df, tmp_path / "out.tsv") + lines = out.read_text().splitlines() + # 101325 / 100 = 1013.25 + assert "1013.25" in lines[1] + + def test_year_remapped_to_1990(self, tmp_path, pvlib_df): + out = from_pvlib(pvlib_df, tmp_path / "out.tsv") + first_data = out.read_text().splitlines()[1] + assert first_data.startswith("1990-") + + def test_custom_year(self, tmp_path, pvlib_df): + out = from_pvlib(pvlib_df, tmp_path / "out.tsv", year=2000) + first_data = out.read_text().splitlines()[1] + assert first_data.startswith("2000-") + + def test_output_passes_validation(self, tmp_path, pvlib_df): + """TSV written by from_pvlib should pass validate_tsv_timestamps.""" + out = from_pvlib(pvlib_df, tmp_path / "out.tsv") + validate_tsv_timestamps(out) # should not raise From f861cedf21e8cbf6c9f9596f0ed106ed14245763 Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Thu, 9 Apr 2026 15:01:10 -0700 Subject: [PATCH 03/18] Update documentation --- docs/api.md | 50 +++++++++++++++++++ docs/faq.md | 20 ++++++++ .../workflow-1-existing-api-files.md | 14 +++--- .../workflow-2-pvplant-builder.md | 25 ++++++++-- 4 files changed, 97 insertions(+), 12 deletions(-) diff --git a/docs/api.md b/docs/api.md index 6c26e39..15957d3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -17,11 +17,13 @@ The SolarFarmer SDK is organized into the following main categories: - [**Endpoint Functions**](#endpoint-functions): Core functions for making API calls - [**Main Classes**](#main-classes): Key data models for calculations and plant design +- [**Weather Utilities**](#weather-utilities): Convert DataFrames to SolarFarmer weather files (requires `pandas`) ### Configuration & Design - [**Plant Configuration Classes**](#plant-configuration-classes): Location, layout, and inverter specifications - [**Module and Equipment Specifications**](#module-and-equipment-specifications): Mounting types, trackers, and component files +- [**Enums**](#enums): PVSystem configuration enums (`MountingType`, `InverterType`, `OrientationType`) ### Advanced Options @@ -68,6 +70,41 @@ These are the primary functions for interacting with the SolarFarmer API. --- +## Weather Utilities + +!!! note + These functions require `pandas`. Install with `pip install 'dnv-solarfarmer[weather]'`. + +### `from_dataframe()` + +::: solarfarmer.weather.from_dataframe + options: + extra: + show_root_toc_entry: false + show_root_members: true + +### `from_pvlib()` + +::: solarfarmer.weather.from_pvlib + options: + extra: + show_root_toc_entry: false + show_root_members: true + +### `validate_tsv_timestamps()` + +::: solarfarmer.weather.validate_tsv_timestamps + options: + extra: + show_root_toc_entry: false + show_root_members: true + +### `TSV_COLUMNS` + +Data dictionary describing the SolarFarmer TSV weather file format — required and optional columns, units, valid ranges, aliases, and the missing-value sentinel. See the [`weather` module docstring](../api.md) for full details. + +--- + ## Main Classes The core classes handle the complete workflow from plant design to results analysis: @@ -188,6 +225,19 @@ The core classes handle the complete workflow from plant design to results analy --- +## Enums + +These enums are used with `PVSystem` to configure mounting, inverter, and module orientation. +They are available at the top level: `sf.MountingType`, `sf.InverterType`, `sf.OrientationType`. + +| Enum | Values | Used by | +|---|---|---| +| `MountingType` | `FIXED`, `TRACKER` | `PVSystem.mounting` | +| `InverterType` | `CENTRAL`, `STRING` | `PVSystem.inverter_type` | +| `OrientationType` | `PORTRAIT`, `LANDSCAPE` | `PVSystem.module_orientation` | + +--- + ## Version Information ::: solarfarmer.__version__ diff --git a/docs/faq.md b/docs/faq.md index 2d2ea02..5dcf876 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -35,3 +35,23 @@ Yes! Use the **"Import from API JSON"** feature in SolarFarmer Desktop. See the - Uses simplified row positioning (all strings in middle-row assumption) For information about upcoming features or requests, contact [solarfarmer@dnv.com](mailto:solarfarmer@dnv.com). + +--- + +## Why do I get an `ImportError` when calling `from_dataframe()` or `from_pvlib()`? + +These weather conversion functions require `pandas`, which is an optional dependency. Install it with: + +```bash +pip install "dnv-solarfarmer[weather]" +``` + +The core SDK (payload construction, API calls, annual/monthly summary data) works without pandas. Only the weather file conversion utilities and timeseries result parsing need it. See the [Weather Utilities reference](api.md#weather-utilities) for details. + +--- + +## My TMY weather file gives a 400 error with no useful message. What's wrong? + +TMY (Typical Meteorological Year) datasets from NSRDB, PVGIS, or similar sources contain timestamps from multiple source years. SolarFarmer requires all timestamps in a TSV file to belong to a single calendar year. + +Use [`sf.from_pvlib()`](api.md#from_pvlib) or [`sf.from_dataframe(year=1990)`](api.md#from_dataframe) to remap timestamps automatically. The SDK also calls [`validate_tsv_timestamps()`](api.md#validate_tsv_timestamps) before upload to catch this early. diff --git a/docs/getting-started/workflow-1-existing-api-files.md b/docs/getting-started/workflow-1-existing-api-files.md index a12ce7e..2d41a92 100644 --- a/docs/getting-started/workflow-1-existing-api-files.md +++ b/docs/getting-started/workflow-1-existing-api-files.md @@ -145,17 +145,15 @@ results.describe() ### Access Key Metrics ```python -# Access annual data for the calculation period -annual_data = results.AnnualData[0] +# Convenience properties (year 1) +print(f"Net Energy: {results.net_energy_MWh:.1f} MWh/year") +print(f"Performance Ratio: {results.performance_ratio:.3f}") +print(f"Specific Yield: {results.energy_yield_kWh_per_kWp:.1f} kWh/kWp") -# Get net energy yield (MWh/year) +# Or access full annual data dicts directly +annual_data = results.AnnualData[0] net_energy_mwh = annual_data['energyYieldResults']['netEnergy'] - -# Get performance ratio (%) performance_ratio = annual_data['energyYieldResults']['performanceRatio'] - -print(f"Net Energy: {net_energy_mwh} MWh/year") -print(f"Performance Ratio: {performance_ratio}%") ``` ### Access Simulation Results diff --git a/docs/getting-started/workflow-2-pvplant-builder.md b/docs/getting-started/workflow-2-pvplant-builder.md index 8da6f9f..8c5b4cb 100644 --- a/docs/getting-started/workflow-2-pvplant-builder.md +++ b/docs/getting-started/workflow-2-pvplant-builder.md @@ -79,13 +79,14 @@ plant.grid_limit_MW = 4.8 # Grid connection limit ```python # Mounting and orientation plant.mounting = "Fixed" # or "Tracker" for single-axis trackers + # also available as sf.MountingType.FIXED / sf.MountingType.TRACKER plant.tilt = 25.0 # Array tilt in degrees plant.azimuth = 180.0 # South-facing (0=North, 90=East, 180=South, 270=West) plant.gcr = 0.4 # Ground coverage ratio (spacing between rows) plant.flush_mount = False # Flush-mounted or rack-mounted # Module orientation -plant.module_orientation = "Portrait" # or "Landscape" +plant.module_orientation = "Portrait" # or "Landscape" (sf.OrientationType) plant.modules_across = 1 # Number of modules in height direction ``` @@ -93,7 +94,7 @@ plant.modules_across = 1 # Number of modules in height direction ```python # Inverter type affects default losses -plant.inverter_type = "Central" # or "String" inverters +plant.inverter_type = "Central" # or "String" inverters (sf.InverterType) # Transformer configuration plant.transformer_stages = 1 # 0 (ideal) or 1 (with losses) @@ -124,7 +125,8 @@ plant.bifacial_mismatch_loss = 0.01 ## Step 3: Add Module and Inverter Files -The SDK uses PAN (module) and OND (inverter) files for detailed specifications: +The SDK uses PAN (module) and OND (inverter) files for detailed specifications. +Dict keys are user-facing labels only — the spec ID sent to the API is derived from the filename (everything before the last `.`). ```python from pathlib import Path @@ -138,12 +140,22 @@ plant.add_pan_files({ plant.add_ond_files({ "My_Inverter": Path(r"path/to/inverter.OND") }) + +# Or pass a list of paths (keys are derived from filenames) +plant.pan_files = [Path(r"path/to/module.PAN")] +plant.ond_files = [Path(r"path/to/inverter.OND")] ``` --- ## Step 4: Add Weather and Horizon Data +!!! warning "TMY data: remap timestamps to a single year" + TMY datasets from NSRDB, PVGIS, or similar sources contain timestamps from + multiple source years. When using TSV format, all timestamps must belong to a + single calendar year. Use `sf.from_pvlib()` or `sf.from_dataframe(year=1990)` + to handle this automatically, or remap manually before export. + ```python # Meteorological data (required for calculation) plant.weather_file = Path(r"path/to/weather_data.csv") @@ -234,7 +246,12 @@ Use the [`CalculationResults`](../api.md#calculationresults) class to access and # Evaluate the results from the energy simulation plant.results.performance() -# Access annual data +# Quick access to year-1 metrics via convenience properties +print(f"Net Energy: {plant.results.net_energy_MWh:.1f} MWh/year") +print(f"Performance Ratio: {plant.results.performance_ratio:.3f}") +print(f"Specific Yield: {plant.results.energy_yield_kWh_per_kWp:.1f} kWh/kWp") + +# Or access annual data dicts directly annual_data = plant.results.AnnualData[0] net_energy = annual_data['energyYieldResults']['netEnergy'] performance_ratio = annual_data['energyYieldResults']['performanceRatio'] From 30eba025b86e81851790e88db16fd762c2eb06a7 Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Thu, 9 Apr 2026 15:14:12 -0700 Subject: [PATCH 04/18] Code linting --- solarfarmer/models/__init__.py | 2 +- solarfarmer/weather.py | 4 ++-- tests/test_construct_plant.py | 4 +--- tests/test_weather.py | 18 ++++++++++++------ 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/solarfarmer/models/__init__.py b/solarfarmer/models/__init__.py index 503b14e..f45ce6e 100644 --- a/solarfarmer/models/__init__.py +++ b/solarfarmer/models/__init__.py @@ -25,8 +25,8 @@ from .ond_supplements import OndFileSupplements from .pan_supplements import PanFileSupplements from .pv_plant import PVPlant -from .pvsystem.pvsystem import PVSystem from .pvsystem.plant_defaults import InverterType, MountingType, OrientationType +from .pvsystem.pvsystem import PVSystem from .pvsystem.validation import ValidationMessage from .tracker_system import TrackerSystem from .transformer import Transformer diff --git a/solarfarmer/weather.py b/solarfarmer/weather.py index 64e067e..459f67e 100644 --- a/solarfarmer/weather.py +++ b/solarfarmer/weather.py @@ -62,8 +62,7 @@ import pandas as pd _PANDAS_INSTALL_MSG = ( - "pandas is required for this function. " - "Install it with: pip install 'dnv-solarfarmer[weather]'" + "pandas is required for this function. Install it with: pip install 'dnv-solarfarmer[weather]'" ) __all__ = ["TSV_COLUMNS", "validate_tsv_timestamps", "from_dataframe", "from_pvlib"] @@ -218,6 +217,7 @@ def from_pvlib( pressure_pa_to_mbar=True, ) + TSV_COLUMNS: dict = { "required": [ { diff --git a/tests/test_construct_plant.py b/tests/test_construct_plant.py index eee373f..149ed7b 100644 --- a/tests/test_construct_plant.py +++ b/tests/test_construct_plant.py @@ -188,9 +188,7 @@ def test_pan_files_list_of_strings(self, bern_2d_racks_inputs): def test_dict_still_works(self, bern_2d_racks_inputs): p = PVSystem(latitude=46.95, longitude=7.44) - p.pan_files = { - "MyLabel": f"{bern_2d_racks_inputs}/CanadianSolar_CS6U-330M_APP.PAN" - } + p.pan_files = {"MyLabel": f"{bern_2d_racks_inputs}/CanadianSolar_CS6U-330M_APP.PAN"} assert "MyLabel" in p.pan_files def test_list_input_constructs_valid_plant(self, bern_2d_racks_inputs): diff --git a/tests/test_weather.py b/tests/test_weather.py index bce5a4a..40d4d9b 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -18,22 +18,26 @@ class TestValidateTsvTimestamps: def test_single_year_passes(self, tmp_path): tsv = tmp_path / "good.tsv" - tsv.write_text(textwrap.dedent("""\ + tsv.write_text( + textwrap.dedent("""\ DateTime\tGHI\tTAmb 1990-01-01T00:00+00:00\t0\t5.0 1990-06-15T12:00+00:00\t800\t25.0 1990-12-31T23:00+00:00\t0\t3.0 - """)) + """) + ) validate_tsv_timestamps(tsv) # should not raise def test_mixed_years_raises(self, tmp_path): tsv = tmp_path / "bad.tsv" - tsv.write_text(textwrap.dedent("""\ + tsv.write_text( + textwrap.dedent("""\ DateTime\tGHI\tTAmb 2003-01-01T00:00+00:00\t0\t5.0 2010-06-15T12:00+00:00\t800\t25.0 2016-12-31T23:00+00:00\t0\t3.0 - """)) + """) + ) with pytest.raises(ValueError, match="multiple years"): validate_tsv_timestamps(tsv) @@ -44,10 +48,12 @@ def test_header_only_passes(self, tmp_path): def test_string_path_accepted(self, tmp_path): tsv = tmp_path / "str_path.tsv" - tsv.write_text(textwrap.dedent("""\ + tsv.write_text( + textwrap.dedent("""\ DateTime\tGHI\tTAmb 1990-01-01T00:00+00:00\t0\t5.0 - """)) + """) + ) validate_tsv_timestamps(str(tsv)) # str path should work From 23471c0a782bf564dffaddf52e512cf6c6fa3eea Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Thu, 9 Apr 2026 15:30:09 -0700 Subject: [PATCH 05/18] Update readme --- README.md | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 8a996c4..493ab68 100644 --- a/README.md +++ b/README.md @@ -6,20 +6,19 @@ [![CI](https://github.com/dnv-opensource/solarfarmer-python-sdk/actions/workflows/test.yml/badge.svg)](https://github.com/dnv-opensource/solarfarmer-python-sdk/actions/workflows/test.yml) [![Documentation](https://img.shields.io/badge/docs-online-teal)](https://dnv-opensource.github.io/solarfarmer-python-sdk/) -The official Python SDK for [DNV SolarFarmer](https://www.dnv.com/software/services/solarfarmer/), a bankable solar PV calculation engine. Use it to build validated API payloads, run cloud-based 2D and 3D energy calculations, and analyse simulation results — all from Python. +The official Python SDK for [DNV SolarFarmer](https://www.dnv.com/software/services/solarfarmer/), a bankable solar PV energy yield calculation engine. Build API payloads, run cloud-based 2D and 3D energy calculations, and analyse results from Python. ## Key Features -- **API-faithful data models** that closely mirror the SolarFarmer API schema, with serialization conveniences (snake_case Python fields serialized to the correct camelCase JSON automatically) to reduce integration friction -- **Two plant-building approaches:** a bottom-up route using `EnergyCalculationInputs`, `PVPlant`, and component classes (`Inverter`, `Layout`, `Transformer`, etc.) for full control over the plant topology; and a `PVSystem` convenience class that accepts high-level parameters (capacity, tilt, GCR, equipment files) and constructs the payload automatically, suited to indicative simulations where exhaustive detail is not required -- **`CalculationResults` encapsulation** — the API response is wrapped in a `CalculationResults` object that provides structured access to annual and monthly energy metrics, loss trees, PVsyst-format time-series, and detailed time-series output without manual JSON parsing -- **ModelChain and ModelChainAsync endpoint support** — the SDK dispatches to the synchronous `ModelChain` endpoint or the asynchronous `ModelChainAsync` endpoint as appropriate, and handles polling automatically for long-running jobs -- **Async job management** — poll, monitor, and terminate async calculations via `terminate_calculation()` +- **Data models that mirror the API schema.** Pydantic classes with field validation catch payload errors locally before the API call. +- **Two plant-building paths.** Full control via `EnergyCalculationInputs` and component classes, or quick screening via `PVSystem` from high-level specs (capacity, tilt, GCR, equipment files). +- **Structured results.** `CalculationResults` gives direct access to annual/monthly metrics, loss trees, and time series without parsing raw JSON. +- **Automatic endpoint handling.** One function call runs 2D or 3D calculations. The SDK selects the right endpoint, polls async jobs, and supports cancellation via `terminate_calculation()`. ## Requirements - Python >= 3.10 (tested on 3.10, 3.11, 3.12, 3.13) -- A SolarFarmer API key (commercial licence required) — see [API Key](#api-key) +- A SolarFarmer API key (commercial licence required; see [API Key](#api-key)) ## Installation @@ -68,13 +67,13 @@ Alternatively, pass it directly as the `api_key` parameter to any function that | Environment Variable | Default | Description | |---|---|---| -| `SF_API_KEY` | *(none — required for calculations)* | API authentication token | +| `SF_API_KEY` | *(none; required for calculations)* | API authentication token | | `SF_API_URL` | `https://solarfarmer.dnv.com/latest/api` | Override the base API URL for custom deployments | ## Optional Dependencies -The core SDK (`pydantic`, `requests`, `tabulate`) has no dependency on `pandas`. -Install the `weather` extra to unlock DataFrame-based features: +The core SDK (`pydantic`, `requests`, `tabulate`) does not depend on `pandas`. +Install the `weather` extra for DataFrame-based features: ```bash pip install "dnv-solarfarmer[weather]" @@ -88,23 +87,23 @@ pip install "dnv-solarfarmer[weather]" | `sf.from_pvlib()` | `solarfarmer.weather` | Convert a pvlib DataFrame to TSV (column rename + unit conversion) | | `CalculationResults` timeseries parsing | `solarfarmer.models` | Parse loss-tree, PVsyst-format, and detailed timeseries into DataFrames | -Without pandas these functions raise `ImportError` (weather utilities) or return `None` with a warning (result timeseries parsing). All other SDK functionality — building payloads, running calculations, accessing annual/monthly summary data — works without pandas. +Without pandas, weather utilities raise `ImportError` and timeseries result parsing returns `None` with a warning. Everything else (payload construction, API calls, annual/monthly summaries) works without it. ## Getting Started -The SDK is built around three workflows suited to different use cases: +The SDK supports three workflows for different use cases: | Workflow | Best for | Primary entry point | |---|---|---| | 1. Load existing files | Users with pre-built API payloads from the SolarFarmer desktop app or a previous export | `sf.run_energy_calculation(inputs_folder_path=...)` | -| 2. PVSystem builder | Solar engineers designing new plants programmatically with automatic payload generation | `sf.PVSystem(...)` then `plant.run_energy_calculation()` | +| 2. PVSystem builder | Quick screening from high-level specs (capacity, tilt, equipment files). The design is approximate: string sizing and inverter count are inferred, so DC/AC capacity may not match the target exactly. | `sf.PVSystem(...)` then `plant.run_energy_calculation()` | | 3. Custom integration | Developers mapping internal databases or proprietary formats to the SolarFarmer API | `sf.EnergyCalculationInputs(location=..., pv_plant=..., ...)` | See the [Getting Started guide](https://dnv-opensource.github.io/solarfarmer-python-sdk/getting-started/) for full per-workflow walkthroughs, and the [example notebooks](https://dnv-opensource.github.io/solarfarmer-python-sdk/notebooks/Example_EnergyCalculations/) for runnable end-to-end examples. ## Documentation -Full documentation including API reference, workflow guides, and notebook tutorials: +Full documentation (API reference, workflow guides, notebook tutorials): **https://dnv-opensource.github.io/solarfarmer-python-sdk/** From 768143f3ecd22742cef40a3b173fc2e9b577f65a Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Thu, 9 Apr 2026 16:02:53 -0700 Subject: [PATCH 06/18] update readme --- README.md | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 493ab68..e00f171 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ [![CI](https://github.com/dnv-opensource/solarfarmer-python-sdk/actions/workflows/test.yml/badge.svg)](https://github.com/dnv-opensource/solarfarmer-python-sdk/actions/workflows/test.yml) [![Documentation](https://img.shields.io/badge/docs-online-teal)](https://dnv-opensource.github.io/solarfarmer-python-sdk/) -The official Python SDK for [DNV SolarFarmer](https://www.dnv.com/software/services/solarfarmer/), a bankable solar PV energy yield calculation engine. Build API payloads, run cloud-based 2D and 3D energy calculations, and analyse results from Python. +The official Python SDK for [SolarFarmer](https://www.dnv.com/software/services/solarfarmer/), a bankable solar PV design and energy yield assessment software from DNV. This SDK provides a typed Python interface that simplifies calling SolarFarmer APIs: build payloads, run 2D and 3D energy calculations, and process results programmatically. ## Key Features -- **Data models that mirror the API schema.** Pydantic classes with field validation catch payload errors locally before the API call. +- **Data models that mirror the API schema.** Pydantic classes with field validation catch payload errors locally before the API call. Field descriptions and type hints improve discoverability in IDEs and AI coding agents alike. - **Two plant-building paths.** Full control via `EnergyCalculationInputs` and component classes, or quick screening via `PVSystem` from high-level specs (capacity, tilt, GCR, equipment files). - **Structured results.** `CalculationResults` gives direct access to annual/monthly metrics, loss trees, and time series without parsing raw JSON. - **Automatic endpoint handling.** One function call runs 2D or 3D calculations. The SDK selects the right endpoint, polls async jobs, and supports cancellation via `terminate_calculation()`. @@ -79,25 +79,17 @@ Install the `weather` extra for DataFrame-based features: pip install "dnv-solarfarmer[weather]" ``` -**Functions that require pandas:** - -| Function / Feature | Module | What it does | -|---|---|---| -| `sf.from_dataframe()` | `solarfarmer.weather` | Write a DataFrame to SolarFarmer TSV weather file | -| `sf.from_pvlib()` | `solarfarmer.weather` | Convert a pvlib DataFrame to TSV (column rename + unit conversion) | -| `CalculationResults` timeseries parsing | `solarfarmer.models` | Parse loss-tree, PVsyst-format, and detailed timeseries into DataFrames | - -Without pandas, weather utilities raise `ImportError` and timeseries result parsing returns `None` with a warning. Everything else (payload construction, API calls, annual/monthly summaries) works without it. +This unlocks `sf.from_dataframe()` and `sf.from_pvlib()` for writing weather files from DataFrames, and enables `CalculationResults` to parse timeseries outputs into DataFrames. Without pandas, those functions raise `ImportError` or return `None`. All other SDK features work without it. ## Getting Started The SDK supports three workflows for different use cases: -| Workflow | Best for | Primary entry point | +| Workflow | Best for | Entry point | |---|---|---| | 1. Load existing files | Users with pre-built API payloads from the SolarFarmer desktop app or a previous export | `sf.run_energy_calculation(inputs_folder_path=...)` | -| 2. PVSystem builder | Quick screening from high-level specs (capacity, tilt, equipment files). The design is approximate: string sizing and inverter count are inferred, so DC/AC capacity may not match the target exactly. | `sf.PVSystem(...)` then `plant.run_energy_calculation()` | -| 3. Custom integration | Developers mapping internal databases or proprietary formats to the SolarFarmer API | `sf.EnergyCalculationInputs(location=..., pv_plant=..., ...)` | +| 2. PVSystem builder | Quick screening from high-level specs (capacity, tilt, equipment files). The design is approximate: string sizing and inverter count are inferred, so DC/AC capacity may not match the target exactly. | `plant = sf.PVSystem(...)` then `plant.run_energy_calculation()` | +| 3. Custom integration | Developers mapping internal databases or proprietary formats to the SolarFarmer API | `params = sf.EnergyCalculationInputs(...)` then `sf.run_energy_calculation(plant_builder=params)` | See the [Getting Started guide](https://dnv-opensource.github.io/solarfarmer-python-sdk/getting-started/) for full per-workflow walkthroughs, and the [example notebooks](https://dnv-opensource.github.io/solarfarmer-python-sdk/notebooks/Example_EnergyCalculations/) for runnable end-to-end examples. From 435ed6f5abb62aaf9eab37f9c75b8f7843534792 Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Thu, 9 Apr 2026 16:35:11 -0700 Subject: [PATCH 07/18] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e00f171..14d120b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The official Python SDK for [SolarFarmer](https://www.dnv.com/software/services/ ## Key Features -- **Data models that mirror the API schema.** Pydantic classes with field validation catch payload errors locally before the API call. Field descriptions and type hints improve discoverability in IDEs and AI coding agents alike. +- **Data models that mirror the API schema.** Pydantic classes with field validation catch payload errors locally before the API call. Field descriptions and type hints improve discoverability. - **Two plant-building paths.** Full control via `EnergyCalculationInputs` and component classes, or quick screening via `PVSystem` from high-level specs (capacity, tilt, GCR, equipment files). - **Structured results.** `CalculationResults` gives direct access to annual/monthly metrics, loss trees, and time series without parsing raw JSON. - **Automatic endpoint handling.** One function call runs 2D or 3D calculations. The SDK selects the right endpoint, polls async jobs, and supports cancellation via `terminate_calculation()`. From b86b5ed1cb3e049f37daf3bc7457fa02cb382234 Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Fri, 10 Apr 2026 11:07:13 -0700 Subject: [PATCH 08/18] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 14d120b..482cdf7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The official Python SDK for [SolarFarmer](https://www.dnv.com/software/services/ ## Key Features - **Data models that mirror the API schema.** Pydantic classes with field validation catch payload errors locally before the API call. Field descriptions and type hints improve discoverability. -- **Two plant-building paths.** Full control via `EnergyCalculationInputs` and component classes, or quick screening via `PVSystem` from high-level specs (capacity, tilt, GCR, equipment files). +- **Two plant-building paths.** Full control via `EnergyCalculationInputs` and component classes, or quick screening via `PVSystem` from high-level specs (DC and AC capacities, tilt, GCR) - **Structured results.** `CalculationResults` gives direct access to annual/monthly metrics, loss trees, and time series without parsing raw JSON. - **Automatic endpoint handling.** One function call runs 2D or 3D calculations. The SDK selects the right endpoint, polls async jobs, and supports cancellation via `terminate_calculation()`. From 0230d32edc24f9d41acdf43a438cbc81e2d9efed Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Fri, 10 Apr 2026 11:10:44 -0700 Subject: [PATCH 09/18] Validate PAN/OND file setters: reject empty/duplicate inputs --- solarfarmer/models/pvsystem/pvsystem.py | 58 +++++++++++++++++++------ 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/solarfarmer/models/pvsystem/pvsystem.py b/solarfarmer/models/pvsystem/pvsystem.py index 50ca7be..32290c7 100644 --- a/solarfarmer/models/pvsystem/pvsystem.py +++ b/solarfarmer/models/pvsystem/pvsystem.py @@ -448,18 +448,33 @@ def pan_files(self, mapping: Mapping[str, PathLike] | Sequence[PathLike]) -> Non Keys are user-facing labels only. The spec ID sent to the API is derived from the filename via ``Path.stem`` (everything before the last dot), not from the dict key. + + Raises + ------ + ValueError + If *mapping* is empty, a module name is blank, or the sequence + contains paths whose stems collide (duplicate filenames). """ self._pan_files.clear() - if isinstance(mapping, (list, tuple)): + if isinstance(mapping, Mapping): + for name, p in mapping.items(): + key = str(name).strip() + if not key: + raise ValueError("Module name cannot be empty") + self._pan_files[key] = Path(p) + else: for p in mapping: path = Path(p) self._pan_files[path.stem] = path - return - for name, p in mapping.items(): - key = str(name).strip() - if not key: - raise ValueError("Module name cannot be empty") - self._pan_files[key] = Path(p) + + if not self._pan_files: + raise ValueError("pan_files cannot be empty") + + if len(self._pan_files) != len(mapping): + raise ValueError( + f"Duplicate file stems detected: received {len(mapping)} paths but " + f"only {len(self._pan_files)} have unique stems (filename without extension)" + ) def add_pan_files(self, mapping: Mapping[str, PathLike]) -> PVSystem: """Add PAN files without clearing existing mappings (supports method chaining). @@ -498,18 +513,33 @@ def ond_files(self, mapping: Mapping[str, PathLike] | Sequence[PathLike]) -> Non Keys are user-facing labels only. The spec ID sent to the API is derived from the filename via ``Path.stem`` (everything before the last dot), not from the dict key. + + Raises + ------ + ValueError + If *mapping* is empty, an inverter name is blank, or the sequence + contains paths whose stems collide (duplicate filenames). """ self._ond_files.clear() - if isinstance(mapping, (list, tuple)): + if isinstance(mapping, Mapping): + for name, p in mapping.items(): + key = str(name).strip() + if not key: + raise ValueError("Inverter name cannot be empty") + self._ond_files[key] = Path(p) + else: for p in mapping: path = Path(p) self._ond_files[path.stem] = path - return - for name, p in mapping.items(): - key = str(name).strip() - if not key: - raise ValueError("Inverter name cannot be empty") - self._ond_files[key] = Path(p) + + if not self._ond_files: + raise ValueError("ond_files cannot be empty") + + if len(self._ond_files) != len(mapping): + raise ValueError( + f"Duplicate file stems detected: received {len(mapping)} paths but " + f"only {len(self._ond_files)} have unique stems (filename without extension)" + ) def add_ond_files(self, mapping: Mapping[str, PathLike]) -> PVSystem: """Add OND files without clearing existing mappings (supports method chaining). From f88983a3df6603f165ea539a7a9988b9bc1ecbc0 Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Fri, 10 Apr 2026 11:32:10 -0700 Subject: [PATCH 10/18] Remove silent fallback when project_year is wrong --- solarfarmer/models/energy_calculation_results.py | 11 +++++------ tests/test_energy_calculation_results.py | 9 +++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/solarfarmer/models/energy_calculation_results.py b/solarfarmer/models/energy_calculation_results.py index ef7996a..21485ee 100644 --- a/solarfarmer/models/energy_calculation_results.py +++ b/solarfarmer/models/energy_calculation_results.py @@ -716,16 +716,15 @@ def get_performance(self, project_year: int = 1) -> dict[str, int | float]: project_year_index = project_year - 1 - try: - annual_results = self.AnnualData[project_year_index] - except (IndexError, KeyError): + if project_year_index < 0 or project_year_index >= len(self.AnnualData): _logger.warning( - "The annual results do not have an entry for year %d. " - "Returning the results for the first year of the project.", + "get_performance: project_year %d is out of range (1–%d).", project_year, + len(self.AnnualData), ) - annual_results = self.AnnualData[0] + return {} + annual_results = self.AnnualData[project_year_index] yield_results = annual_results[ANNUAL_ENERGY_YIELD_RESULTS_KEY] return { "project_year": project_year_index + 1, diff --git a/tests/test_energy_calculation_results.py b/tests/test_energy_calculation_results.py index 96756e4..cfb1198 100644 --- a/tests/test_energy_calculation_results.py +++ b/tests/test_energy_calculation_results.py @@ -421,10 +421,11 @@ def test_get_performance_year_values( assert perf["calendar_year"] == exp_calendar_year assert perf["net_energy"] == exp_net_energy - def test_get_performance_out_of_range_falls_back_to_year1(self, results): - """An out-of-range project_year should fall back to year 1 data.""" - perf = results.get_performance(project_year=99) - assert perf["calendar_year"] == 2022 + def test_get_performance_out_of_range_returns_empty(self, results): + """An out-of-range project_year should return an empty dict.""" + assert results.get_performance(project_year=99) == {} + assert results.get_performance(project_year=0) == {} + assert results.get_performance(project_year=-1) == {} def test_get_performance_no_data_returns_empty(self): """get_performance should return an empty dict when AnnualData is empty.""" From 27d5f441efe57f0c58e1728c8f61ff9691bc1ee9 Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Fri, 10 Apr 2026 11:47:36 -0700 Subject: [PATCH 11/18] Rename function and guard it to tsvs only --- solarfarmer/__init__.py | 4 ++-- solarfarmer/endpoint_modelchains_utils.py | 7 ++++--- solarfarmer/weather.py | 4 ++-- tests/test_weather.py | 18 +++++++++--------- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/solarfarmer/__init__.py b/solarfarmer/__init__.py index aa8a8a0..925b45e 100644 --- a/solarfarmer/__init__.py +++ b/solarfarmer/__init__.py @@ -58,7 +58,7 @@ TransformerSpecification, ValidationMessage, ) -from .weather import TSV_COLUMNS, from_dataframe, from_pvlib, validate_tsv_timestamps +from .weather import TSV_COLUMNS, check_single_year_timestamps, from_dataframe, from_pvlib __all__ = [ "__version__", @@ -122,5 +122,5 @@ "ValidationMessage", "from_dataframe", "from_pvlib", - "validate_tsv_timestamps", + "check_single_year_timestamps", ] diff --git a/solarfarmer/endpoint_modelchains_utils.py b/solarfarmer/endpoint_modelchains_utils.py index 8f981d2..ae3c168 100644 --- a/solarfarmer/endpoint_modelchains_utils.py +++ b/solarfarmer/endpoint_modelchains_utils.py @@ -7,7 +7,7 @@ from .config import MODELCHAIN_ASYNC_POLL_TIME from .logging import get_logger -from .weather import validate_tsv_timestamps +from .weather import check_single_year_timestamps _logger = get_logger("endpoint.modelchains.utils") @@ -97,7 +97,7 @@ def get_files(sample_data_folder: str | pathlib.Path) -> list[tuple[str, IO[byte ) if tsv_file_paths: _logger.debug("tmyFile = %s", tsv_file_paths[0]) - validate_tsv_timestamps(tsv_file_paths[0]) + check_single_year_timestamps(tsv_file_paths[0]) fh = pathlib.Path(tsv_file_paths[0]).open("rb") stack.callback(fh.close) files.append(("tmyFile", fh)) @@ -312,7 +312,8 @@ def parse_files_from_paths( extension_met_data = pathlib.Path(meteorological_data_file_path).suffix.lower() if extension_met_data in (".tsv", ".dat"): - validate_tsv_timestamps(meteorological_data_file_path) + if extension_met_data == ".tsv": + check_single_year_timestamps(meteorological_data_file_path) fh = pathlib.Path(meteorological_data_file_path).open("rb") stack.callback(fh.close) files.append(("tmyFile", fh)) diff --git a/solarfarmer/weather.py b/solarfarmer/weather.py index 459f67e..a802697 100644 --- a/solarfarmer/weather.py +++ b/solarfarmer/weather.py @@ -65,10 +65,10 @@ "pandas is required for this function. Install it with: pip install 'dnv-solarfarmer[weather]'" ) -__all__ = ["TSV_COLUMNS", "validate_tsv_timestamps", "from_dataframe", "from_pvlib"] +__all__ = ["TSV_COLUMNS", "check_single_year_timestamps", "from_dataframe", "from_pvlib"] -def validate_tsv_timestamps(file_path: str | pathlib.Path) -> None: +def check_single_year_timestamps(file_path: str | pathlib.Path) -> None: """Check that all timestamps in a TSV weather file belong to a single year. Parameters diff --git a/tests/test_weather.py b/tests/test_weather.py index 40d4d9b..a563b9c 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -7,14 +7,14 @@ from solarfarmer.weather import ( PVLIB_COLUMN_MAP, + check_single_year_timestamps, from_dataframe, from_pvlib, - validate_tsv_timestamps, ) -class TestValidateTsvTimestamps: - """Tests for validate_tsv_timestamps().""" +class TestCheckSingleYearTimestamps: + """Tests for check_single_year_timestamps().""" def test_single_year_passes(self, tmp_path): tsv = tmp_path / "good.tsv" @@ -26,7 +26,7 @@ def test_single_year_passes(self, tmp_path): 1990-12-31T23:00+00:00\t0\t3.0 """) ) - validate_tsv_timestamps(tsv) # should not raise + check_single_year_timestamps(tsv) # should not raise def test_mixed_years_raises(self, tmp_path): tsv = tmp_path / "bad.tsv" @@ -39,12 +39,12 @@ def test_mixed_years_raises(self, tmp_path): """) ) with pytest.raises(ValueError, match="multiple years"): - validate_tsv_timestamps(tsv) + check_single_year_timestamps(tsv) def test_header_only_passes(self, tmp_path): tsv = tmp_path / "header_only.tsv" tsv.write_text("DateTime\tGHI\tTAmb\n") - validate_tsv_timestamps(tsv) # no data lines, no error + check_single_year_timestamps(tsv) # no data lines, no error def test_string_path_accepted(self, tmp_path): tsv = tmp_path / "str_path.tsv" @@ -54,7 +54,7 @@ def test_string_path_accepted(self, tmp_path): 1990-01-01T00:00+00:00\t0\t5.0 """) ) - validate_tsv_timestamps(str(tsv)) # str path should work + check_single_year_timestamps(str(tsv)) # str path should work class TestFromDataframe: @@ -158,6 +158,6 @@ def test_custom_year(self, tmp_path, pvlib_df): assert first_data.startswith("2000-") def test_output_passes_validation(self, tmp_path, pvlib_df): - """TSV written by from_pvlib should pass validate_tsv_timestamps.""" + """TSV written by from_pvlib should pass check_single_year_timestamps.""" out = from_pvlib(pvlib_df, tmp_path / "out.tsv") - validate_tsv_timestamps(out) # should not raise + check_single_year_timestamps(out) # should not raise From 3e71b705faf2c4c6c02a917ab75fa22c09d8b1c2 Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Fri, 10 Apr 2026 12:02:29 -0700 Subject: [PATCH 12/18] Use isoformat to parse timestamps --- solarfarmer/weather.py | 4 +--- tests/test_weather.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/solarfarmer/weather.py b/solarfarmer/weather.py index a802697..1716bf8 100644 --- a/solarfarmer/weather.py +++ b/solarfarmer/weather.py @@ -172,9 +172,7 @@ def from_dataframe( if year is not None: out.index = out.index.map(lambda t: t.replace(year=year)) - out.index = out.index.map( - lambda t: t.strftime("%Y-%m-%dT%H:%M") + t.strftime("%z")[:3] + ":" + t.strftime("%z")[3:] - ) + out.index = out.index.map(lambda t: t.isoformat(timespec="minutes")) out.index.name = "DateTime" path = pathlib.Path(output_path) diff --git a/tests/test_weather.py b/tests/test_weather.py index a563b9c..d3e0f61 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -112,6 +112,19 @@ def test_timestamp_format_has_utc_offset(self, tmp_path, sample_df): # Should contain +00:00 UTC offset assert "+00:00" in first_data + def test_timestamp_format_non_utc_offset(self, tmp_path): + """Non-UTC timezone (e.g. +05:30) must produce correct ±HH:MM offset.""" + pd = pytest.importorskip("pandas") + import datetime as dt + + tz = dt.timezone(dt.timedelta(hours=5, minutes=30)) + idx = pd.date_range("2020-01-01", periods=2, freq="h", tz=tz) + df = pd.DataFrame({"GHI": [0, 100]}, index=idx) + out = from_dataframe(df, tmp_path / "out.tsv") + lines = out.read_text().splitlines() + assert "+05:30" in lines[1] + assert "+05:30" in lines[2] + def test_returns_path(self, tmp_path, sample_df): result = from_dataframe(sample_df, tmp_path / "weather.tsv") assert isinstance(result, Path) From c9673dd90e51e3bc9bc5e59288e91986d5182e7f Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Fri, 10 Apr 2026 12:08:31 -0700 Subject: [PATCH 13/18] Refactor pandas warning --- solarfarmer/config.py | 5 +++++ solarfarmer/models/energy_calculation_results.py | 13 +++++-------- solarfarmer/weather.py | 6 ++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/solarfarmer/config.py b/solarfarmer/config.py index d9721e1..73cc21e 100644 --- a/solarfarmer/config.py +++ b/solarfarmer/config.py @@ -21,6 +21,7 @@ "MODELCHAIN_ASYNC_TIMEOUT_CONNECTION", "MODELCHAIN_ASYNC_TIMEOUT_UPLOAD", "MODELCHAIN_ASYNC_POLL_TIME", + "PANDAS_INSTALL_MSG", ] BASE_API_URL = os.getenv( @@ -48,3 +49,7 @@ MODELCHAIN_ASYNC_TIMEOUT_CONNECTION = 7200 # 2 hours MODELCHAIN_ASYNC_TIMEOUT_UPLOAD = 60 MODELCHAIN_ASYNC_POLL_TIME = 4 # Polling frequency for the status of ModelChainAsync calculations + +PANDAS_INSTALL_MSG = ( + "pandas is required for this function. Install it with: pip install 'dnv-solarfarmer[weather]'" +) diff --git a/solarfarmer/models/energy_calculation_results.py b/solarfarmer/models/energy_calculation_results.py index 21485ee..c9192a3 100644 --- a/solarfarmer/models/energy_calculation_results.py +++ b/solarfarmer/models/energy_calculation_results.py @@ -19,6 +19,7 @@ DETAILED_TIMESERIES_FILENAME, LOSS_TREE_TIMESERIES_DATAFRAME_FILENAME, LOSS_TREE_TIMESERIES_FILENAME, + PANDAS_INSTALL_MSG, PVSYST_TIMESERIES_DATAFRAME_FILENAME, PVSYST_TIMESERIES_FILENAME, ) @@ -1726,8 +1727,7 @@ def _handle_losstree_results( return data else: warnings.warn( - "pandas is required to parse loss tree timeseries. " - "Install with: pip install 'dnv-solarfarmer[weather]'", + PANDAS_INSTALL_MSG, stacklevel=2, ) return None @@ -1782,8 +1782,7 @@ def _handle_pvsyst_results( return data else: warnings.warn( - "pandas is required to parse PVsyst-format timeseries. " - "Install with: pip install 'dnv-solarfarmer[weather]'", + PANDAS_INSTALL_MSG, stacklevel=2, ) return None @@ -1834,8 +1833,7 @@ def _handle_timeseries_results( return data else: warnings.warn( - "pandas is required to parse detailed timeseries. " - "Install with: pip install 'dnv-solarfarmer[weather]'", + PANDAS_INSTALL_MSG, stacklevel=2, ) return None @@ -1969,8 +1967,7 @@ def _read_dataframe_pandas_safe( return dataframe else: warnings.warn( - "pandas is required to read result files. " - "Install with: pip install 'dnv-solarfarmer[weather]'", + PANDAS_INSTALL_MSG, stacklevel=2, ) return None diff --git a/solarfarmer/weather.py b/solarfarmer/weather.py index 1716bf8..eb3035f 100644 --- a/solarfarmer/weather.py +++ b/solarfarmer/weather.py @@ -61,9 +61,7 @@ if TYPE_CHECKING: import pandas as pd -_PANDAS_INSTALL_MSG = ( - "pandas is required for this function. Install it with: pip install 'dnv-solarfarmer[weather]'" -) +from .config import PANDAS_INSTALL_MSG __all__ = ["TSV_COLUMNS", "check_single_year_timestamps", "from_dataframe", "from_pvlib"] @@ -153,7 +151,7 @@ def from_dataframe( try: import pandas as pd except ImportError: - raise ImportError(_PANDAS_INSTALL_MSG) from None + raise ImportError(PANDAS_INSTALL_MSG) from None if not isinstance(df.index, pd.DatetimeIndex): raise ValueError( From f8d5333b7a0e3277ae4838a2406ef7fc22cafe53 Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Fri, 10 Apr 2026 12:29:16 -0700 Subject: [PATCH 14/18] Replace single-year TSV check with sequential-year check to support multi-year and sub-year data --- solarfarmer/__init__.py | 4 +- solarfarmer/endpoint_modelchains_utils.py | 6 +-- solarfarmer/weather.py | 51 ++++++++++++++--------- tests/test_weather.py | 38 +++++++++++------ 4 files changed, 62 insertions(+), 37 deletions(-) diff --git a/solarfarmer/__init__.py b/solarfarmer/__init__.py index 925b45e..c15143a 100644 --- a/solarfarmer/__init__.py +++ b/solarfarmer/__init__.py @@ -58,7 +58,7 @@ TransformerSpecification, ValidationMessage, ) -from .weather import TSV_COLUMNS, check_single_year_timestamps, from_dataframe, from_pvlib +from .weather import TSV_COLUMNS, check_sequential_year_timestamps, from_dataframe, from_pvlib __all__ = [ "__version__", @@ -122,5 +122,5 @@ "ValidationMessage", "from_dataframe", "from_pvlib", - "check_single_year_timestamps", + "check_sequential_year_timestamps", ] diff --git a/solarfarmer/endpoint_modelchains_utils.py b/solarfarmer/endpoint_modelchains_utils.py index ae3c168..7420265 100644 --- a/solarfarmer/endpoint_modelchains_utils.py +++ b/solarfarmer/endpoint_modelchains_utils.py @@ -7,7 +7,7 @@ from .config import MODELCHAIN_ASYNC_POLL_TIME from .logging import get_logger -from .weather import check_single_year_timestamps +from .weather import check_sequential_year_timestamps _logger = get_logger("endpoint.modelchains.utils") @@ -97,7 +97,7 @@ def get_files(sample_data_folder: str | pathlib.Path) -> list[tuple[str, IO[byte ) if tsv_file_paths: _logger.debug("tmyFile = %s", tsv_file_paths[0]) - check_single_year_timestamps(tsv_file_paths[0]) + check_sequential_year_timestamps(tsv_file_paths[0]) fh = pathlib.Path(tsv_file_paths[0]).open("rb") stack.callback(fh.close) files.append(("tmyFile", fh)) @@ -313,7 +313,7 @@ def parse_files_from_paths( if extension_met_data in (".tsv", ".dat"): if extension_met_data == ".tsv": - check_single_year_timestamps(meteorological_data_file_path) + check_sequential_year_timestamps(meteorological_data_file_path) fh = pathlib.Path(meteorological_data_file_path).open("rb") stack.callback(fh.close) files.append(("tmyFile", fh)) diff --git a/solarfarmer/weather.py b/solarfarmer/weather.py index eb3035f..dd9be94 100644 --- a/solarfarmer/weather.py +++ b/solarfarmer/weather.py @@ -17,10 +17,13 @@ TMY Data Warning ~~~~~~~~~~~~~~~~ Typical Meteorological Year (TMY) datasets (e.g., from NSRDB PSM or PVGIS) -contain timestamps drawn from different source years. When writing a TSV file, -**all timestamps must belong to a single contiguous calendar year**. Remap -mixed-year timestamps to one year (e.g., 1990) before export; otherwise the -SolarFarmer API will return an HTTP 400 error with no field-level detail. +contain timestamps drawn from different source years. When writing a TSV file +from TMY data, **remap all timestamps to a single calendar year** (e.g., 1990) +before export; otherwise the shuffled years will be detected as non-sequential +and the SDK will raise a ``ValueError``. + +Multi-year and sub-year TSV files with **chronologically ordered** timestamps +are fully supported — only non-sequential (shuffled) years are rejected. pvlib Column Mapping ~~~~~~~~~~~~~~~~~~~~ @@ -63,11 +66,20 @@ from .config import PANDAS_INSTALL_MSG -__all__ = ["TSV_COLUMNS", "check_single_year_timestamps", "from_dataframe", "from_pvlib"] +__all__ = [ + "TSV_COLUMNS", + "check_sequential_year_timestamps", + "from_dataframe", + "from_pvlib", +] + +def check_sequential_year_timestamps(file_path: str | pathlib.Path) -> None: + """Check that timestamp years in a TSV weather file are chronologically ordered. -def check_single_year_timestamps(file_path: str | pathlib.Path) -> None: - """Check that all timestamps in a TSV weather file belong to a single year. + Allows single-year, sub-year, and multi-year continuous data. Rejects + files whose years go *backwards* — the hallmark of unprocessed TMY data + that mixes months from different source years. Parameters ---------- @@ -77,11 +89,12 @@ def check_single_year_timestamps(file_path: str | pathlib.Path) -> None: Raises ------ ValueError - If timestamps span more than one calendar year. + If any timestamp year is earlier than the preceding timestamp year + (i.e., years are not non-decreasing). """ path = pathlib.Path(file_path) year_pattern = re.compile(r"^(\d{4})-") - years: set[str] = set() + prev_year: int | None = None with path.open("r", encoding="utf-8") as f: for line in f: @@ -90,16 +103,16 @@ def check_single_year_timestamps(file_path: str | pathlib.Path) -> None: continue m = year_pattern.match(line) if m: - years.add(m.group(1)) - - if len(years) > 1: - sorted_years = sorted(years) - raise ValueError( - f"TSV weather file contains timestamps from multiple years: " - f"{sorted_years}. SolarFarmer requires all timestamps to belong " - f"to a single contiguous calendar year. Remap timestamps to one " - f"year (e.g., 1990) before submission." - ) + year = int(m.group(1)) + if prev_year is not None and year < prev_year: + raise ValueError( + f"TSV weather file contains non-sequential years: " + f"year {year} follows year {prev_year}. This usually " + f"indicates unprocessed TMY data with months from " + f"different source years. Remap all timestamps to a " + f"single year (e.g., 1990) before submission." + ) + prev_year = year PVLIB_COLUMN_MAP: dict[str, str] = { diff --git a/tests/test_weather.py b/tests/test_weather.py index d3e0f61..4a872e5 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -7,14 +7,14 @@ from solarfarmer.weather import ( PVLIB_COLUMN_MAP, - check_single_year_timestamps, + check_sequential_year_timestamps, from_dataframe, from_pvlib, ) -class TestCheckSingleYearTimestamps: - """Tests for check_single_year_timestamps().""" +class TestCheckSequentialYearTimestamps: + """Tests for check_sequential_year_timestamps().""" def test_single_year_passes(self, tmp_path): tsv = tmp_path / "good.tsv" @@ -26,25 +26,37 @@ def test_single_year_passes(self, tmp_path): 1990-12-31T23:00+00:00\t0\t3.0 """) ) - check_single_year_timestamps(tsv) # should not raise + check_sequential_year_timestamps(tsv) # should not raise - def test_mixed_years_raises(self, tmp_path): + def test_sequential_multi_year_passes(self, tmp_path): + tsv = tmp_path / "multi_year.tsv" + tsv.write_text( + textwrap.dedent("""\ + DateTime\tGHI\tTAmb + 2020-06-15T12:00+00:00\t800\t25.0 + 2021-06-15T12:00+00:00\t810\t26.0 + 2022-06-15T12:00+00:00\t820\t27.0 + """) + ) + check_sequential_year_timestamps(tsv) # should not raise + + def test_shuffled_tmy_years_raises(self, tmp_path): tsv = tmp_path / "bad.tsv" tsv.write_text( textwrap.dedent("""\ DateTime\tGHI\tTAmb - 2003-01-01T00:00+00:00\t0\t5.0 - 2010-06-15T12:00+00:00\t800\t25.0 + 2010-01-01T00:00+00:00\t0\t5.0 + 2003-06-15T12:00+00:00\t800\t25.0 2016-12-31T23:00+00:00\t0\t3.0 """) ) - with pytest.raises(ValueError, match="multiple years"): - check_single_year_timestamps(tsv) + with pytest.raises(ValueError, match="non-sequential years"): + check_sequential_year_timestamps(tsv) def test_header_only_passes(self, tmp_path): tsv = tmp_path / "header_only.tsv" tsv.write_text("DateTime\tGHI\tTAmb\n") - check_single_year_timestamps(tsv) # no data lines, no error + check_sequential_year_timestamps(tsv) # no data lines, no error def test_string_path_accepted(self, tmp_path): tsv = tmp_path / "str_path.tsv" @@ -54,7 +66,7 @@ def test_string_path_accepted(self, tmp_path): 1990-01-01T00:00+00:00\t0\t5.0 """) ) - check_single_year_timestamps(str(tsv)) # str path should work + check_sequential_year_timestamps(str(tsv)) # str path should work class TestFromDataframe: @@ -171,6 +183,6 @@ def test_custom_year(self, tmp_path, pvlib_df): assert first_data.startswith("2000-") def test_output_passes_validation(self, tmp_path, pvlib_df): - """TSV written by from_pvlib should pass check_single_year_timestamps.""" + """TSV written by from_pvlib should pass check_sequential_year_timestamps.""" out = from_pvlib(pvlib_df, tmp_path / "out.tsv") - check_single_year_timestamps(out) # should not raise + check_sequential_year_timestamps(out) # should not raise From 7acbd69a220a0fbc3ba4424dc3c59bc6811c16d7 Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Fri, 10 Apr 2026 12:37:36 -0700 Subject: [PATCH 15/18] Update docstrings re units --- solarfarmer/weather.py | 48 +++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/solarfarmer/weather.py b/solarfarmer/weather.py index dd9be94..d8d8497 100644 --- a/solarfarmer/weather.py +++ b/solarfarmer/weather.py @@ -27,27 +27,34 @@ pvlib Column Mapping ~~~~~~~~~~~~~~~~~~~~ -When converting a ``pvlib`` DataFrame to SolarFarmer TSV format, use the -following column name mapping and note the unit for Pressure: - -============== =========== ========================== -pvlib column SF column Notes -============== =========== ========================== -``ghi`` ``GHI`` W/m² -``dhi`` ``DHI`` W/m² -``temp_air`` ``TAmb`` °C -``wind_speed`` ``WS`` m/s -``pressure`` ``Pressure`` **Convert Pa → mbar** (÷ 100) -============== =========== ========================== - -Minimal conversion example:: +When converting a ``pvlib`` DataFrame to SolarFarmer TSV format, map the +pvlib variable names to SolarFarmer column names as shown below. pvlib does +not standardise units across data sources — verify that your source's units +match SolarFarmer requirements (see :data:`TSV_COLUMNS` for the full spec): + +============== =========== +pvlib column SF column +============== =========== +``ghi`` ``GHI`` +``dhi`` ``DHI`` +``temp_air`` ``TAmb`` +``wind_speed`` ``WS`` +``pressure`` ``Pressure`` +============== =========== + +For example, NSRDB PSM provides pressure in Pa and must be converted to +mbar (÷ 100); other sources may already be in mbar. Use +``pressure_pa_to_mbar=True`` in :func:`from_dataframe` when your source +delivers pressure in Pa. + +Minimal conversion example (NSRDB PSM, pressure in Pa):: import pandas as pd rename = {"ghi": "GHI", "dhi": "DHI", "temp_air": "TAmb", "wind_speed": "WS", "pressure": "Pressure"} df = pvlib_df.rename(columns=rename) - df["Pressure"] = df["Pressure"] / 100 # Pa → mbar + df["Pressure"] = df["Pressure"] / 100 # Pa → mbar (NSRDB PSM) df.index = df.index.map( lambda t: t.replace(year=1990).strftime("%Y-%m-%dT%H:%M+00:00") ) @@ -207,8 +214,15 @@ def from_pvlib( Parameters ---------- df : pandas.DataFrame - pvlib-style DataFrame (``ghi``, ``dhi``, ``temp_air``, - ``wind_speed``, ``pressure``) with a DatetimeIndex. + pvlib-style DataFrame (columns ``ghi``, ``dhi``, ``temp_air``, + ``wind_speed``, ``pressure``) with a DatetimeIndex. pvlib does not + standardise units across data sources, so check that the units from + your source match what SolarFarmer expects (see :data:`TSV_COLUMNS`). + This function applies ``pressure_pa_to_mbar=True``, dividing the + ``Pressure`` column by 100 (Pa → mbar). NSRDB PSM delivers pressure + in Pa so this conversion is correct for that source; if your source + already provides pressure in mbar, call :func:`from_dataframe` + directly with ``pressure_pa_to_mbar=False``. output_path : str or Path Destination file path. year : int, default 1990 From 5fddd42359077fdd88927a5aebe2cd313a982db2 Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Fri, 10 Apr 2026 12:43:51 -0700 Subject: [PATCH 16/18] Use protected_namespaces to silence warnings --- solarfarmer/models/_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/solarfarmer/models/_base.py b/solarfarmer/models/_base.py index 97398b2..58c44d5 100644 --- a/solarfarmer/models/_base.py +++ b/solarfarmer/models/_base.py @@ -10,4 +10,5 @@ class SolarFarmerBaseModel(BaseModel): populate_by_name=True, frozen=True, use_enum_values=True, + protected_namespaces=(), ) From 8e303ac76f02cdee7d329ed119f2999e5a7ca8f0 Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Fri, 10 Apr 2026 12:44:59 -0700 Subject: [PATCH 17/18] Move dev/test/docs to dependency-groups, fold pandas into all, drop weather extra --- pyproject.toml | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 914c69a..819d6f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,19 @@ dependencies = [ ] [project.optional-dependencies] +notebooks = [ + "ipykernel>=6.29,<8", + "jupyterlab>=4.0,<6", + "notebook>=7.0,<9" +] + +all = [ + "dnv-solarfarmer[notebooks]", + "pandas>=2.0", + "matplotlib>=3.5", +] + +[dependency-groups] docs = [ "zensical>=0.0.20", "mkdocstrings[python]>=0.26.1", @@ -46,26 +59,11 @@ test = [ ] dev = [ - "dnv-solarfarmer[test]", + {include-group = "test"}, "pre-commit>=3.0", "ruff>=0.5", ] -weather = [ - "pandas>=2.0", -] - -notebooks = [ - "ipykernel>=6.29,<8", - "jupyterlab>=4.0,<6", - "notebook>=7.0,<9" -] - -all = [ - "dnv-solarfarmer[docs,dev,notebooks,weather]", - "matplotlib>=3.5", -] - [project.urls] Homepage = "https://www.dnv.com/software/services/solarfarmer/" Repository = "https://github.com/dnv-opensource/solarfarmer-python-sdk" From 0ef7772dcf3319f55e2bfa4ed6db655cf0ebf518 Mon Sep 17 00:00:00 2001 From: Ian Tse Date: Fri, 10 Apr 2026 12:50:20 -0700 Subject: [PATCH 18/18] Update CICD workflows --- .github/workflows/docs.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4418d37..95018a2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -50,7 +50,7 @@ jobs: uv-solarfarmer- - name: Install documentation dependencies - run: uv sync --extra docs + run: uv sync --group docs - name: Determine version from git id: version diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8948153..6c7d97b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,7 +27,7 @@ jobs: cache-dependency-glob: "pyproject.toml" - name: Install dependencies - run: uv sync --extra dev + run: uv sync --group dev - name: Run Ruff linter run: uv run ruff check solarfarmer/ tests/ --output-format=github diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b122472..57f585e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: uv sync --extra test + run: uv sync --group test - name: Run tests run: uv run pytest tests/ -v