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 diff --git a/README.md b/README.md index 25c965d..482cdf7 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 [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 -- **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. 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 (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()`. ## 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 @@ -38,9 +37,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: @@ -67,24 +67,35 @@ 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`) does not depend on `pandas`. +Install the `weather` extra for DataFrame-based features: + +```bash +pip install "dnv-solarfarmer[weather]" +``` + +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 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 | +| 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 | Solar engineers designing new plants programmatically with automatic payload generation | `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. ## 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/** 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/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/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'] diff --git a/pyproject.toml b/pyproject.toml index 9081d5d..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,23 +59,11 @@ test = [ ] dev = [ - "dnv-solarfarmer[test]", + {include-group = "test"}, "pre-commit>=3.0", "ruff>=0.5", ] -notebooks = [ - "ipykernel>=6.29,<8", - "jupyterlab>=4.0,<6", - "notebook>=7.0,<9" -] - -all = [ - "dnv-solarfarmer[docs,dev,notebooks]", - "pandas>=2.0", - "matplotlib>=3.5", -] - [project.urls] Homepage = "https://www.dnv.com/software/services/solarfarmer/" Repository = "https://github.com/dnv-opensource/solarfarmer-python-sdk" diff --git a/solarfarmer/__init__.py b/solarfarmer/__init__.py index 14038c2..c15143a 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, @@ -55,7 +58,7 @@ TransformerSpecification, ValidationMessage, ) -from .weather import TSV_COLUMNS +from .weather import TSV_COLUMNS, check_sequential_year_timestamps, from_dataframe, from_pvlib __all__ = [ "__version__", @@ -92,15 +95,18 @@ "IAMModelTypeForOverride", "Inverter", "InverterOverPowerShutdownMode", + "InverterType", "Layout", "Location", "MeteoFileFormat", "MissingMetDataMethod", "ModelChainResponse", "MonthlyAlbedo", + "MountingType", "MountingTypeSpecification", "OndFileSupplements", "OrderColumnsPvSystFormatTimeSeries", + "OrientationType", "PanFileSupplements", "PVPlant", "PVSystem", @@ -114,4 +120,7 @@ "TransformerLossModelTypes", "TransformerSpecification", "ValidationMessage", + "from_dataframe", + "from_pvlib", + "check_sequential_year_timestamps", ] 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/endpoint_modelchains_utils.py b/solarfarmer/endpoint_modelchains_utils.py index cfe17d2..7420265 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 check_sequential_year_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]) + 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)) @@ -310,6 +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"): + if extension_met_data == ".tsv": + 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/models/__init__.py b/solarfarmer/models/__init__.py index 5c9c6cd..f45ce6e 100644 --- a/solarfarmer/models/__init__.py +++ b/solarfarmer/models/__init__.py @@ -25,6 +25,7 @@ from .ond_supplements import OndFileSupplements from .pan_supplements import PanFileSupplements from .pv_plant import PVPlant +from .pvsystem.plant_defaults import InverterType, MountingType, OrientationType from .pvsystem.pvsystem import PVSystem from .pvsystem.validation import ValidationMessage from .tracker_system import TrackerSystem @@ -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/_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=(), ) diff --git a/solarfarmer/models/energy_calculation_results.py b/solarfarmer/models/energy_calculation_results.py index c088a51..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, ) @@ -30,10 +31,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" @@ -127,6 +128,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 @@ -138,6 +157,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 @@ -681,16 +717,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, @@ -1692,9 +1727,7 @@ 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_INSTALL_MSG, stacklevel=2, ) return None @@ -1749,9 +1782,7 @@ 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_INSTALL_MSG, stacklevel=2, ) return None @@ -1802,9 +1833,7 @@ 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_INSTALL_MSG, stacklevel=2, ) return None @@ -1938,8 +1967,7 @@ 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_INSTALL_MSG, stacklevel=2, ) return None 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..32290c7 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 @@ -421,20 +436,45 @@ 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. + + 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() - 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 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 + + 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). @@ -461,20 +501,45 @@ 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. + + 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() - 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 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 + + 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). @@ -1707,7 +1772,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 +1799,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..d8d8497 100644 --- a/solarfarmer/weather.py +++ b/solarfarmer/weather.py @@ -13,13 +13,235 @@ 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 +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 +~~~~~~~~~~~~~~~~~~~~ +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 (NSRDB PSM) + 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"] +from __future__ import annotations + +import pathlib +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pandas as pd + +from .config import PANDAS_INSTALL_MSG + +__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. + + 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 + ---------- + file_path : str or Path + Path to the TSV weather file. + + Raises + ------ + ValueError + 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})-") + prev_year: int | None = None + + 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: + 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] = { + "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.isoformat(timespec="minutes")) + 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 (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 + 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", @@ -42,7 +264,6 @@ "aliases": ["TAmb", "Temp", "T"], }, ], - # Optional columns — omitting them is accepted. "optional": [ { "name": "DHI", @@ -95,7 +316,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 f16f272..149ed7b 100644 --- a/tests/test_construct_plant.py +++ b/tests/test_construct_plant.py @@ -106,3 +106,96 @@ 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() + + +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..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.""" @@ -741,3 +742,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..4a872e5 --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,188 @@ +"""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, + check_sequential_year_timestamps, + from_dataframe, + from_pvlib, +) + + +class TestCheckSequentialYearTimestamps: + """Tests for check_sequential_year_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 + """) + ) + check_sequential_year_timestamps(tsv) # should not raise + + 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 + 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="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_sequential_year_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 + """) + ) + check_sequential_year_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_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) + + +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 check_sequential_year_timestamps.""" + out = from_pvlib(pvlib_df, tmp_path / "out.tsv") + check_sequential_year_timestamps(out) # should not raise