diff --git a/.gitignore b/.gitignore index 983da5c..940bfe5 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ site/ # OS junk .DS_Store +# RCL downloaded equipment files +rcl_downloads/ + public *.ipynb_checkpoints api_call_endpoints.py diff --git a/AGENTS.md b/AGENTS.md index 34a5b77..b72a490 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,46 @@ Use this workflow when the user has an existing data model in their own system a **API key**: Set env var `SF_API_KEY` (preferred) or pass `api_key=` to any function. +## Complementary: Search and Download Equipment from RCL + +Use this workflow when the user needs PV module (PAN) or inverter (OND) files from DNV's curated Renewable Component Library. The RCL provides access to validated equipment files without requiring users to source them manually. This workflow complements the use cases above — after downloading equipment files, proceed to Use case 1, 2, or 3. + +```python +import solarfarmer as sf +sf.configure_logging() + +# Search for high-power bifacial modules (zero-cost call) +result = sf.rcl.list_modules( + manufacturer_contains="LONGi", + p_nom_gte=550, + bifaciality_factor_gte=0.7, + top=10, +) +print(f"Found {result['total']} matching modules") + +# Check rate limit before downloading (zero-cost call) +status = sf.rcl.get_rate_limit_status() +print(f"{status.remaining}/{status.limit} downloads remaining") + +# Download a specific module file (costs 1 credit) +item = result["items"][0] +sf.rcl.download_file( + item["fileUuid"], + item["filename"], + directory_path="./equipment/", +) + +# Or integrate directly with PVSystem (downloads only if single match) +plant = sf.PVSystem(name="My Plant", latitude=45.5, longitude=10.3, ...) +plant.set_module_from_rcl( + manufacturer_contains="Canadian Solar", + model_contains="CS7N-715TB-AG", + directory_path="./equipment/", +) +``` + +**Rate limiting**: Downloads are capped monthly. Use `sf.rcl.get_rate_limit_status()` (zero-cost) to check remaining quota. + --- ## Core Principles @@ -73,6 +113,7 @@ Use this workflow when the user has an existing data model in their own system a - **Results models**: `CalculationResults` (in `energy_calculation_results.py`) wraps API outputs and provides convenience properties and accessors such as `performance_ratio_bifacial`, `get_performance()`, `print_annual_results()`, `loss_tree_timeseries()`, and `pvsyst_timeseries()`. - **`PVSystem`** (`@dataclass`, `solarfarmer/models/pvsystem/pvsystem.py`): **Mutable** high-level builder. Not a Pydantic model. Acts as an entry point for Use case 2; internally converts to `EnergyCalculationInputs` before the API call. Key utility methods: `describe()`, `make_copy()`, `produce_payload()`, `payload_to_file()`, `to_file()`, `from_file()`. - **config.py**: Configuration constants, environment variables, timeouts. Single source of truth for URLs and defaults. +- **rcl.py**: Renewable Component Library client. Exports `list_modules()`, `list_inverters()`, `download_file()`, `get_rate_limit_status()`. Uses `RCLClient` from `api.py`. Returns lightweight TypedDict responses (`RCLCatalogResponse`) and dataclass (`RCLRateLimitInfo`). ### Naming Conventions - Files: `endpoint_modelchains.py`, `test_endpoint_modelchain.py` (endpoint features use singular endpoint name in tests) @@ -190,6 +231,9 @@ The following are **named workflows** for structuring developer work. They are N | Add polling timeout logic | EndpointDev workflow | Requires config constants, async pattern | | Help SDK user run a calculation | Default referencing Quickstart | Refer to copilot-instructions.md quickstart | | Convert weather data to SF format | Default | Use `sf.from_dataframe()`, `sf.from_pvlib()`, or `sf.from_solcast()` | +| Search RCL for modules/inverters | Default | Use `sf.rcl.list_modules()`, `sf.rcl.list_inverters()` | +| Download equipment from RCL | Default | Use `sf.rcl.download_file()` | +| Integrate RCL with PVSystem | Default | Use `plant.set_module_from_rcl()`, `plant.set_inverter_from_rcl()` | ## Tool Restrictions diff --git a/docs/api.md b/docs/api.md index 7b2cb77..5e0d406 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,6 +18,7 @@ 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`) +- [**Renewable Component Library (RCL)**](#renewable-component-library-rcl): Search and download PV modules and inverters specification files ### Configuration & Design @@ -113,6 +114,71 @@ Data dictionary describing the SolarFarmer TSV weather file format: required and --- +## Renewable Component Library (RCL) + +Search and download PV modules (PAN files) and inverters (OND files) from DNV's curated component database. See the [RCL documentation](getting-started/rcl-component-library.md) for usage examples. + +!!! warning "Rate Limiting" + Each file download counts against your monthly quota. Use `get_rate_limit_status()` to check your remaining downloads. + +### `list_modules()` + +::: solarfarmer.rcl.list_modules + options: + extra: + show_root_toc_entry: false + show_root_members: true + +### `list_inverters()` + +::: solarfarmer.rcl.list_inverters + options: + extra: + show_root_toc_entry: false + show_root_members: true + +### `download_file()` + +::: solarfarmer.rcl.download_file + options: + extra: + show_root_toc_entry: false + show_root_members: true + +### `get_rate_limit_status()` + +::: solarfarmer.rcl.get_rate_limit_status + options: + extra: + show_root_toc_entry: false + show_root_members: true + +### `RCLRateLimitInfo` + +::: solarfarmer.rcl.RCLRateLimitInfo + options: + extra: + show_root_toc_entry: false + show_root_members: true + +### `RCLCatalogItem` + +::: solarfarmer.rcl.RCLCatalogItem + options: + extra: + show_root_toc_entry: false + show_root_members: true + +### `RCLCatalogResponse` + +::: solarfarmer.rcl.RCLCatalogResponse + options: + extra: + show_root_toc_entry: false + show_root_members: true + +--- + ## Main Classes The core classes handle the complete workflow from plant design to results analysis: diff --git a/docs/getting-started/end-to-end-examples.md b/docs/getting-started/end-to-end-examples.md index 1c1408b..e1b8083 100644 --- a/docs/getting-started/end-to-end-examples.md +++ b/docs/getting-started/end-to-end-examples.md @@ -16,6 +16,7 @@ These examples provide detailed explorations of specific API features and workfl | [Creating Plants with PVSystem](#creating-plants-with-pvsystem) | Master plant design with the PVSystem class | Workflow 2 | Designing new plants | | [Creating Plants with EnergyCalculationInputs](#creating-plants-with-energycalculationinputs) | Build flexible plant configurations with Pydantic model payloads | Workflow 3 | Advanced integration and batch processing | | [Terminating Asynchronous Calculations](#terminating-asynchronous-calculations) | Manage long-running 3D calculations | Workflow 3 | Advanced integration and async 3D workflows | +| [Using the Renewable Component Library](#using-the-renewable-component-library) | Search and download PV modules and inverters from DNV's catalog | All | Equipment sourcing | --- @@ -77,6 +78,22 @@ These examples provide detailed explorations of specific API features and workfl --- +## Using the Renewable Component Library + +**Notebook:** [Example_RCL_Catalog.ipynb](https://github.com/dnv-opensource/solarfarmer-python-sdk/blob/main/docs/notebooks/Example_RCL_Catalog.ipynb){ target="_blank" .external } + +**Topics Covered:** + +- Searching for PV modules and inverters in DNV's curated catalog +- Filtering by manufacturer, power, efficiency, and other specifications +- Managing rate limits and download quotas +- Downloading PAN and OND equipment files +- Integrating RCL with PVSystem for automatic equipment assignment + +**Use this when:** You need validated equipment files for energy calculations and want to source them from DNV's Renewable Component Library rather than managing files manually. + +--- + ## Terminating Asynchronous Calculations **Notebook:** [Example_TerminateAsync_endpoint.ipynb](https://github.com/dnv-opensource/solarfarmer-python-sdk/blob/main/docs/notebooks/Example_TerminateAsync_endpoint.ipynb){ target="_blank" .external } diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 58cae68..cab8639 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -90,6 +90,27 @@ Results from `PVSystem` are approximations based on simplified layout assumption --- +### [Renewable Component Library (RCL)](rcl-component-library.md) + +**For:** Users who need validated PAN/OND equipment files + +**Goal:** Search and download PV modules and inverters from DNV's curated component database + +**Key Functions:** + +- `sf.rcl.list_modules()` - Search the module catalog +- `sf.rcl.list_inverters()` - Search the inverter catalog +- `sf.rcl.download_file()` - Download PAN/OND files +- `sf.rcl.get_rate_limit_status()` - Check remaining download quota + +**Time to First Result:** 5 minutes + +!!! example + Search for bifacial modules from a specific manufacturer, download the PAN file, + and use it with any workflow above. + +--- + ## Integrated Class Examples Once you know your workflow, see how the classes work together in real-world scenarios. @@ -100,11 +121,12 @@ Once you know your workflow, see how the classes work together in real-world sce ## Need Help Deciding? -| I want to... | Go to Workflow | +| I want to... | Go to | |---|---| | Run calculations on existing API files | [Workflow 1](workflow-1-existing-api-files.md) | | Design a new plant from scratch | [Workflow 2](workflow-2-pvplant-builder.md) | | Integrate SolarFarmer into my software | [Workflow 3](workflow-3-plantbuilder-advanced.md) | +| Find and download equipment files | [RCL Component Library](rcl-component-library.md) | | See real code examples | [Quick Start Examples](quick-start-examples.md) | --- diff --git a/docs/getting-started/rcl-component-library.md b/docs/getting-started/rcl-component-library.md new file mode 100644 index 0000000..4dc70ab --- /dev/null +++ b/docs/getting-started/rcl-component-library.md @@ -0,0 +1,425 @@ +--- +title: Renewable Component Library (RCL) +description: Search and download PV modules and inverters from DNV's curated component database +--- + +# Renewable Component Library (RCL) + +**Best for:** Users who need validated PAN/OND equipment files for energy calculations. + +**Scenario:** You want to find and download PV module or inverter files from DNV's curated component database without sourcing files manually. + +--- + +## Overview + +The Renewable Component Library (RCL) provides access to a catalog of validated PV modules (PAN files) and inverters (OND files). The SDK includes functions to search, filter, and download equipment files directly from the RCL API. + +!!! warning "Monthly Download Limit" + RCL downloads are rate-limited. Each file download counts against your monthly quota. Use `get_rate_limit_status()` to check your remaining downloads before bulk operations. + +--- + +## Prerequisites + +- SolarFarmer API key (same `SF_API_KEY` as for energy calculations) +- Active subscription with RCL access + +```python +import solarfarmer as sf +sf.configure_logging() +api_key = os.getenv("SF_API_KEY") +``` + +--- + +## Searching the Catalog + +### List Modules + +Search for PV modules using manufacturer, model, power, and other filters: + +```python +# Basic search - first 10 modules +result = sf.rcl.list_modules(top=10) +print(f"Found {result['total']} modules total") + +# Filtered search with ordering +result = sf.rcl.list_modules( + manufacturer_contains="Canadian", + p_nom_gte=600, + order_by="pNom", + order_dir="DESC", + top=10, + api_key=api_key +) + +# Access items as dicts +for item in result["items"]: + print(f"{item['manufacturer']} {item['model']}: {item.get('pNom')}W") +``` + +#### Common Module Filters + +| Parameter | Description | Example | +|-----------|-------------|---------| +| `manufacturer` | Exact manufacturer match | `"Canadian Solar Inc."` | +| `manufacturer_contains` | Manufacturer contains substring | `"Canadian"` | +| `model` | Exact model match | `"CS7N-715TB-AG"` | +| `model_contains` | Model contains substring | `"715TB"` | +| `p_nom_gte` | Minimum nominal power (W) | `600` | +| `p_nom_lte` | Maximum nominal power (W) | `800` | +| `bifaciality_factor_gte` | Minimum bifaciality factor | `0.7` | +| `technol` | Technology type | `"mtSi"` | +| `lifecycle_status` | Lifecycle status | `"active"` | + +### List Inverters + +Search for inverters using similar filters: + +```python +result = sf.rcl.list_inverters( + manufacturer_contains="Sungrow", + p_nom_conv_gte=100, # Min 100 kW rated power + effic_max_gte=98.5, # Min 98.5% efficiency + top=10, +) + +for item in result["items"]: + print(f"{item['manufacturer']} {item['model']}: {item.get('pNomConv')} kW") +``` + +#### Common Inverter Filters + +| Parameter | Description | Example | +|-----------|-------------|---------| +| `manufacturer` | Exact manufacturer match | `"Sungrow"` | +| `manufacturer_contains` | Manufacturer contains substring | `"Sungrow"` | +| `model` | Exact model match | `"SG250HX"` | +| `model_contains` | Model contains substring | `"HX"` | +| `p_nom_conv_gte` | Min rated AC power (kW) | `100` | +| `effic_max_gte` | Min max efficiency (%) | `98.5` | +| `v_mpp_min_lte` | Max lower MPPT voltage (V) | `500` | +| `v_mpp_max_gte` | Min upper MPPT voltage (V) | `1100` | +| `nb_mppt_gte` | Min number of MPPTs | `12` | + +### Reducing Response Size + +Use `output_parameter` to request only specific fields: + +```python +result = sf.rcl.list_modules( + manufacturer_contains="LONGi", + output_parameter=["pNom", "bifacialityFactor", "technol"], + top=20, +) +``` + +
+Available Fields for output_parameter + +- Module: `manufacturer`, `model`, `pNom`, `isc`, `voc`, `imp`, `vmp`, `muPmpReq`, `nCelS`, `nCelP`, `bifacialityFactor`, `technol`, `lifecycleStatus`
+- Inverter `manufacturer`, `model`, `pNomConv`, `pMaxOut`, `efficMax`, `efficEuro`, `vMppMin`, `vMppMax`, `vAbsMax`, `nbMppt`, `transfo` + +
+ +### Pagination + +For large result sets, use `top` and `skip` for pagination: + +```python +all_items = [] +skip = 0 +page_size = 100 + +while True: + result = sf.rcl.list_modules( + manufacturer_contains="Trina", + top=page_size, + skip=skip, + ) + all_items.extend(result["items"]) + + if len(result["items"]) < page_size: + break # Last page + skip += page_size + +print(f"Retrieved {len(all_items)} modules") +``` + +--- + +## Rate Limit Management + +### Check Your Status (Zero-Cost) + +Check remaining downloads without consuming quota: + +```python +status = sf.rcl.get_rate_limit_status() +print(f"{status.remaining}/{status.limit} downloads remaining") +print(f"Resets: {status.reset_datetime}") + +if status.is_low: + print("⚠️ Running low on downloads!") +``` + +### Rate Limit from Responses + +Every catalog response includes rate limit info: + +```python +result = sf.rcl.list_modules(top=5) +if result["rate_limit"]: + print(f"Remaining: {result['rate_limit'].remaining}") +``` + +--- + +## Downloading Files + +!!! tip "Automatic Local Caching" + If the file already exists at the target location, `download_file()` returns the cached file without making an API call. This saves your monthly quota when re-running workflows. + +!!! warning "Each New Download Counts" + Each **new** download consumes one credit from your monthly quota. Cached files are free. + +### Save to Directory + +```python +# Search for a specific module by manufacturer and model +result = sf.rcl.list_modules( + manufacturer="Canadian Solar", # Exact manufacturer name + model="CS7N-715TB-AG", # Exact model name + top=1, # Only need one result +) + +# Get the first (and only) result item +item = result["items"][0] + +# Download the PAN file to a directory +# - Uses original filename from the catalog +# - Automatically caches: re-running returns local file +content = sf.rcl.download_file( + item["fileUuid"], # Unique file identifier from catalog + item["filename"], # Original filename (e.g., "CS7N-715TB-AG.PAN") + directory_path="./equipment/modules/" # Where to save +) +print(f"Saved: {item['filename']} ({len(content)} bytes)") +``` + +### Save with Custom Filename + +```python +content = sf.rcl.download_file( + item["fileUuid"], + item["filename"], + file_path="./my_module.PAN" # Custom path overrides directory_path +) +``` + +### Memory Only (No Save) + +```python +content = sf.rcl.download_file( + item["fileUuid"], + item["filename"], + save_to_file=False, # Don't write to disk +) +# content is bytes - process in memory +``` + +### Force Re-Download + +```python +content = sf.rcl.download_file( + item["fileUuid"], + item["filename"], + directory_path="./equipment/", + use_cache=False, # Force download even if file exists locally +) +``` + +--- + +## Typed Catalog Items (IDE Support) + +When accessing catalog results, use `RCLCatalogItem` for IDE autocompletion and type safety: + +```python +from solarfarmer import RCLCatalogItem + +result = sf.rcl.list_modules(manufacturer_contains="Canadian", top=1) + +# Wrap the raw dict in RCLCatalogItem +item = RCLCatalogItem(result["items"][0]) + +# Now you get IDE autocomplete for common fields: +print(item.file_uuid) # snake_case (preferred) +print(item.fileUuid) # camelCase alias also works +print(item.manufacturer) +print(item.model) +print(item.p_nom) # Module power (W) +print(item.bifaciality_factor) + +# Use with download_file: +content = sf.rcl.download_file(item.file_uuid, item.filename) + +# Still works as dict for raw field access: +print(item["fileUuid"]) # Dict-style access +print(item.get("pNom")) # .get() method +print(item.raw) # Original dict with all fields +``` + +--- + +## Integration with PVSystem + +The `PVSystem` class provides methods to search RCL and automatically assign equipment files. + +### Set Module from RCL + +```python +plant = sf.PVSystem( + name="My Plant", + latitude=35.0, + longitude=-120.0, + dc_capacity_MW=10.0, + ac_capacity_MW=8.0, + mounting="Fixed", +) + +# Search and download if exactly one match +module = plant.set_module_from_rcl( + manufacturer_contains="Canadian Solar", + model_contains="CS7N-715TB-AG", + directory_path="./equipment/" +) +print(f"Assigned: {module['filename']}") +print(plant.pan_files) # {'CS7N-715TB-AG': Path('./equipment/...')} +``` + +### Set Inverter from RCL + +```python +inverter = plant.set_inverter_from_rcl( + manufacturer_contains="Sungrow", + model_contains="SG250HX", + directory_path="./equipment/" +) +print(f"Assigned: {inverter['filename']}") +``` + +### Handling Multiple Matches + +By default (`strict=True`), the method raises a `ValueError` with suggestions when multiple results are found: + +```python +try: + plant.set_module_from_rcl( + manufacturer_contains="Canadian Solar", + model_contains="CS6w", # Too broad + ) +except ValueError as e: + print(e) + # INFO: 20 modules found, retrieved 20. + # 20 modules found. Narrow your search: + + # 1. Canadian Solar - CS6W-565TB-AG (565W bifacial) + # → Add: model="CS6W-565TB-AG" or p_nom=565 or bifaciality_factor_gte=0.7 + + # 2. Canadian Solar - CS6W-570TB-AG (570W bifacial) + # → Add: model="CS6W-570TB-AG" or p_nom=570 or bifaciality_factor_gte=0.7 + # ... +``` + +For exploratory use, set `strict=False` to print the message and return `None` instead of raising: + +```python +# Returns None and prints suggestions (no exception) +result = plant.set_module_from_rcl( + manufacturer_contains="Canadian Solar", + model_contains="CS6W", + strict=False, +) +if result is None: + print("Refine your search criteria") +``` + +--- + +## Complete Example + +```python +import solarfarmer as sf +from pathlib import Path + +sf.configure_logging() + +# 1. Check rate limit before downloading +status = sf.rcl.get_rate_limit_status() +print(f"Downloads available: {status.remaining}/{status.limit}") + +if status.remaining < 5: + print("Low on downloads - consider waiting until reset") + print(f"Resets: {status.reset_datetime}") + +# 2. Search for equipment +modules = sf.rcl.list_modules( + manufacturer_contains="LONGi", + p_nom_gte=550, + bifaciality_factor_gte=0.7, + output_parameter=["pNom", "bifacialityFactor"], + top=5, +) +print(f"Found {modules['total']} matching modules") + +inverters = sf.rcl.list_inverters( + manufacturer_contains="Huawei", + p_nom_conv_gte=200, + top=5, +) +print(f"Found {inverters['total']} matching inverters") + +# 3. Create plant and assign equipment +plant = sf.PVSystem( + name="RCL Demo Plant", + latitude=33.45, + longitude=-112.07, + dc_capacity_MW=50.0, + ac_capacity_MW=40.0, + mounting="Tracker", +) + +# Direct assignment from specific search result +if modules["items"]: + item = modules["items"][0] + content = sf.rcl.download_file( + item["fileUuid"], + item["filename"], + directory_path="./equipment/" + ) + plant.pan_files = {item["model"]: Path(f"./equipment/{item['filename']}")} + +# Or use the integrated method for automatic search + download +plant.set_inverter_from_rcl( + manufacturer_contains="Huawei", + model_contains="SUN2000-215KTL", + directory_path="./equipment/" +) + +# 4. Continue with energy calculation... +print(f"Plant configured with: {list(plant.pan_files.keys())}") +print(f"Inverter: {list(plant.ond_files.keys())}") +``` + +--- + +## API Reference + +See the full API documentation for detailed parameter descriptions: + +- [`sf.rcl.list_modules()`](../api.md#solarfarmer.rcl.list_modules) +- [`sf.rcl.list_inverters()`](../api.md#solarfarmer.rcl.list_inverters) +- [`sf.rcl.download_file()`](../api.md#solarfarmer.rcl.download_file) +- [`sf.rcl.get_rate_limit_status()`](../api.md#solarfarmer.rcl.get_rate_limit_status) diff --git a/docs/notebooks/Example_RCL_Catalog.ipynb b/docs/notebooks/Example_RCL_Catalog.ipynb new file mode 100644 index 0000000..11835ac --- /dev/null +++ b/docs/notebooks/Example_RCL_Catalog.ipynb @@ -0,0 +1,1145 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "7a9b6166", + "metadata": {}, + "source": [ + "# Using the Renewable Component Library (RCL)\n", + "\n", + "This notebook demonstrates how to search and download PV modules and inverters from DNV's Renewable Component Library using the SolarFarmer Python SDK.\n", + "\n", + "See details in SolarFarmer's public documentation:\n", + "- [RCL Endpoint](https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/WebApi/RclApi/RclEndpoint.html)\n", + "- [RCL Tutorial](https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/WebApi/RclApi/RclEndpointTutorial.html)" + ] + }, + { + "cell_type": "markdown", + "id": "bc69359b", + "metadata": {}, + "source": [ + "## 0. Prerequisites\n", + "\n", + "**Notebook Information:**\n", + "- **Last Updated:** August 2026\n", + "- **Written for:** SolarFarmer SDK v0.5.0+\n", + "- [View latest version in repository](https://github.com/dnv-opensource/solarfarmer-python-sdk/blob/main/docs/notebooks/Example_RCL_Catalog.ipynb)\n", + "\n", + "### 0.1 Install the SolarFarmer Python SDK\n", + "\n", + "This notebook requires the SolarFarmer Python SDK to be installed. Install it via pip:\n", + "\n", + "```bash\n", + "pip install dnv-solarfarmer\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "e1a37ec9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "SolarFarmer Python SDK v0.5.0\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import os\n", + "import solarfarmer as sf\n", + "\n", + "# Check SDK version compatibility\n", + "NOTEBOOK_MIN_SDK_VERSION = \"0.5.0\"\n", + "\n", + "print(f\"SolarFarmer Python SDK v{sf.__version__}\")\n", + "\n", + "# Parse versions for comparison\n", + "def parse_version(v):\n", + " \"\"\"Simple version parser for x.y.z format\"\"\"\n", + " return tuple(map(int, v.split('.')))\n", + "\n", + "try:\n", + " if parse_version(sf.__version__) < parse_version(NOTEBOOK_MIN_SDK_VERSION):\n", + " print(f\"\\n WARNING: This notebook requires SDK v{NOTEBOOK_MIN_SDK_VERSION} or later.\")\n", + " print(f\" Your version: {sf.__version__}\")\n", + " print(f\" Some examples may not work correctly.\")\n", + " print(f\" Upgrade with: pip install --upgrade dnv-solarfarmer\\n\")\n", + "except Exception:\n", + " pass\n", + "\n", + "sf.configure_logging()" + ] + }, + { + "cell_type": "markdown", + "id": "6b2d7228", + "metadata": {}, + "source": [ + "### 0.2 API Key Required\n", + "\n", + "You need a SolarFarmer API key to access the RCL. Instructions for acquiring one is [HERE](https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/WebApi/Introduction/ApiKey.html)\n", + "\n", + "**Important:** Avoid hardcoding your API key directly in notebook cells.\n", + "\n", + "- **Use environment variables (Recommended):**\n", + "\n", + " The SDK automatically uses the `SF_API_KEY` environment variable. Set it in your terminal before starting Jupyter:\n", + "\n", + " **Linux/Mac:**\n", + " ```bash\n", + " export SF_API_KEY=\"your-key-here\"\n", + " ```\n", + "\n", + " **Windows:**\n", + " ```bash\n", + " set SF_API_KEY=your-key-here\n", + " ```\n", + "\n", + "- **Entering your API key (Alternative):**\n", + "\n", + " This notebook will prompt you to enter your API key and keep it hidden from view." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "fd1e26be", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Using API key from environment variable `SF_API_KEY`\n" + ] + } + ], + "source": [ + "if os.getenv(\"SF_API_KEY\") is None:\n", + " print(\"WARNING: `SF_API_KEY` environment variable not set.\")\n", + " import getpass\n", + " api_key = getpass.getpass(\"Enter your SolarFarmer API key: \")\n", + " print(\"Using API key entered by user.\\n\")\n", + "else:\n", + " api_key = os.getenv(\"SF_API_KEY\")\n", + " print(\"Using API key from environment variable `SF_API_KEY`\")" + ] + }, + { + "cell_type": "markdown", + "id": "de3e1dab", + "metadata": {}, + "source": [ + "## 1. Rate Limit Status\n", + "\n", + "Before downloading files, check your remaining monthly quota. This call is **free** - it doesn't consume any downloads." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "fecfb226", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Downloads remaining: 93/100\n", + "Usage: 7.0%\n", + "Resets: 2026-09-04 00:00:00+00:00\n" + ] + } + ], + "source": [ + "status = sf.rcl.get_rate_limit_status(api_key=api_key)\n", + "\n", + "print(f\"Downloads remaining: {status.remaining}/{status.limit}\")\n", + "print(f\"Usage: {status.usage_percent:.1f}%\")\n", + "print(f\"Resets: {status.reset_datetime}\")\n", + "\n", + "if status.is_low:\n", + " print(\"\\n⚠️ Warning: Running low on downloads!\")" + ] + }, + { + "cell_type": "markdown", + "id": "15f74fd2", + "metadata": {}, + "source": [ + "## 2. Searching for PV Modules\n", + "\n", + "Use `sf.rcl.list_modules()` to search the module catalog. You can filter by manufacturer, model, power, and other attributes. \n", + "\n", + "See [Section 7](#filter-reference) for a complete list of available module filters." + ] + }, + { + "cell_type": "markdown", + "id": "39fc678e", + "metadata": {}, + "source": [ + "### 2.1 Basic Module Search\n", + "\n", + "Just filtering a desired number of modules (via `top` parameter). Default is 25 records. The maximum value is 10,000 records." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "e5ca740d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 1197 modules found, retrieved 5.\n", + "Total modules in catalog: 1197\n", + "\n", + "First 5 modules:\n", + " - AE Solar AE MD-144 530\n", + " - AE Solar AE MD-144 535\n", + " - AE Solar AE MD-144 540\n", + " - AE Solar AE MD-144 545\n", + " - AE Solar AE MD-144 550\n", + "CPU times: total: 31.2 ms\n", + "Wall time: 1.34 s\n" + ] + } + ], + "source": [ + "%%time\n", + "# Get first 5 modules from the catalog\n", + "result = sf.rcl.list_modules(top=5, api_key=api_key)\n", + "\n", + "print(f\"Total modules in catalog: {result['total']}\")\n", + "print(f\"\\nFirst {len(result['items'])} modules:\")\n", + "\n", + "for item in result[\"items\"]:\n", + " print(f\" - {item['manufacturer']} {item['model']}\")" + ] + }, + { + "cell_type": "markdown", + "id": "29e16851", + "metadata": {}, + "source": [ + "### 2.2 Filtered Module Search\n", + "\n", + "Filter modules by manufacturer, power range, and other attributes.\n", + "\n", + "See [Section 7](#filter-reference) for a complete list of available inverter filters.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "9268e3fc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 45 modules found, retrieved 10.\n", + "\n", + "Top 10 by power:\n", + " - Canadian Solar CS7N-730TB-AG: 730.0W\n", + " - Canadian Solar CS7N-725TB-AG: 725.0W\n", + " - Canadian Solar CS7N-720TB-AG: 720.0W\n", + " - Canadian Solar CS7N-715TB-AG: 715.0W\n", + " - Canadian Solar CS7N-710TB-AG: 710.0W\n", + " - Canadian Solar CS7N-705TB-AG: 705.0W\n", + " - Canadian Solar CS7N-700TB-AG: 700.0W\n", + " - Canadian Solar CS7N-695TB-AG: 695.0W\n", + " - Canadian Solar CS7N-690TB-AG: 690.0W\n", + " - Canadian Solar CS7N-685TB-AG: 685.0W\n" + ] + } + ], + "source": [ + "# Search for high-power modules from Canadian Solar\n", + "result = sf.rcl.list_modules(\n", + " manufacturer_contains=\"Canadian\",\n", + " p_nom_gte=600, # Minimum 600W\n", + " order_by=\"pNom\",\n", + " order_dir=\"DESC\",\n", + " top=10,\n", + " api_key=api_key,\n", + ")\n", + "\n", + "print(f\"\\nTop 10 by power:\")\n", + "\n", + "for item in result[\"items\"]:\n", + " power = item.get('pNom', 'N/A')\n", + " print(f\" - {item['manufacturer']} {item['model']}: {power}W\")" + ] + }, + { + "cell_type": "markdown", + "id": "45f567a2", + "metadata": {}, + "source": [ + "### 2.3 Search for Bifacial Modules\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "8a9ca2bd", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 58 modules found, retrieved 5.\n" + ] + } + ], + "source": [ + "# Search for bifacial modules (bifaciality factor >= 0.7)\n", + "result = sf.rcl.list_modules(\n", + " manufacturer_contains=\"LONGi\",\n", + " bifaciality_factor_gte=0.7,\n", + " p_nom_gte=550,\n", + " output_parameter=[\"pNom\", \"bifacialityFactor\"],\n", + " top=5,\n", + " api_key=api_key,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "25f821e0", + "metadata": {}, + "source": [ + "To get information about each of the resulting components, note that these can be queried either via typed properties with the `RCLCatalogItem` class or via their dictionary key." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cabae49b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Using RCLCatalogItem (useful for IDE autocomplete):\n", + " - LONGi LR5-72HGD-560M: 560.0W, BF=0.8\n", + " - LONGi LR5-72HGD-565M: 565.0W, BF=0.8\n", + " - LONGi LR5-72HGD-570M: 570.0W, BF=0.8\n", + " - LONGi LR5-72HGD-575M: 575.0W, BF=0.8\n", + " - LONGi LR5-72HGD-580M: 580.0W, BF=0.8\n", + "\n", + "Using dict-style access:\n", + " - LONGi LR5-72HGD-560M: 560.0W, BF=0.8\n", + " - LONGi LR5-72HGD-565M: 565.0W, BF=0.8\n", + " - LONGi LR5-72HGD-570M: 570.0W, BF=0.8\n", + " - LONGi LR5-72HGD-575M: 575.0W, BF=0.8\n", + " - LONGi LR5-72HGD-580M: 580.0W, BF=0.8\n" + ] + } + ], + "source": [ + "# Using RCLCatalogItem for better IDE support\n", + "from solarfarmer import RCLCatalogItem\n", + "\n", + "print(\"Using RCLCatalogItem (useful for IDE autocomplete):\")\n", + "for raw_item in result[\"items\"]:\n", + " item = RCLCatalogItem(raw_item) # Wrap in typed class\n", + " print(f\" - {item.manufacturer} {item.model}: {item.p_nom}W, BF={item.bifaciality_factor}\")\n", + "\n", + "print(\"\\nUsing dict-style access:\")\n", + "for item in result[\"items\"]:\n", + " print(f\" - {item['manufacturer']} {item['model']}: {item.get('pNom')}W, BF={item.get('bifacialityFactor')}\")" + ] + }, + { + "cell_type": "markdown", + "id": "87d5e38b", + "metadata": {}, + "source": [ + "## 3. Searching for Inverters\n", + "\n", + "Use `sf.rcl.list_inverters()` to search the inverter catalog. You can filter by power, efficiency, MPPT voltage range, and more. \n", + "\n", + "See [Section 7](#filter-reference) for a complete list of available inverter filters." + ] + }, + { + "cell_type": "markdown", + "id": "3fc3f9ef", + "metadata": {}, + "source": [ + "### 3.1 Basic Inverter Search\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "a5e37e67", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 318 inverters found, retrieved 5.\n", + "\n", + "First 5 inverters:\n", + " - ABB Proteus PV 4100\n", + " - ABB Proteus PV 4300\n", + " - ABB Proteus PV 4500\n", + " - ABB Proteus PV 4700\n", + " - Chint CPS SCA100K-T-US-480\n" + ] + } + ], + "source": [ + "# Get first 5 inverters from the complete catalog\n", + "result = sf.rcl.list_inverters(top=5, api_key=api_key)\n", + "\n", + "print(f\"\\nFirst {len(result['items'])} inverters:\")\n", + "\n", + "for item in result[\"items\"]:\n", + " print(f\" - {item['manufacturer']} {item['model']}\")" + ] + }, + { + "cell_type": "markdown", + "id": "c581d1f8", + "metadata": {}, + "source": [ + "### 3.2 Filtered Inverter Search\n", + "\n", + "Note the INFO message about the query can be turned off via the parameter `verbose=False`" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "91384174", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Found 5 inverters matching criteria:\n", + " - Sungrow SC2750UD-MV-US: 2750.0kW, 99.0% max eff\n", + " - Sungrow SC3150UD-MV-US: 3150.0kW, 99.0% max eff\n", + " - Sungrow SC3450UD-MV-US: 3450.0kW, 99.0% max eff\n", + " - Sungrow SG320HX: 320.0kW, 99.02% max eff\n", + " - Sungrow SG320HX-20: 320.0kW, 99.02% max eff\n" + ] + } + ], + "source": [ + "# Search for high-efficiency utility-scale inverters\n", + "result = sf.rcl.list_inverters(\n", + " manufacturer_contains=\"Sungrow\",\n", + " p_nom_conv_gte=200, # Min 200 kW\n", + " effic_max_gte=98.5, # Min 98.5% efficiency\n", + " top=10,\n", + " api_key=api_key,\n", + " verbose=False, # Set to True to print total number of items and those retrieved up to top pagination limit.\n", + ")\n", + "\n", + "print(f\"\\nFound {result['total']} inverters matching criteria:\")\n", + "\n", + "for item in result[\"items\"]:\n", + " power = item.get('pNomConv', 'N/A')\n", + " eff = item.get('efficMax', 'N/A')\n", + " print(f\" - {item['manufacturer']} {item['model']}: {power}kW, {eff}% max eff\")" + ] + }, + { + "cell_type": "markdown", + "id": "1035257b", + "metadata": {}, + "source": [ + "### 3.3 Filter by MPPT Voltage Range" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "6832d9e3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Found 22 high-voltage inverters\n", + " - Chint CPS SCH275KTL-DO/US-800-24 V2: 900-1300V, 12 MPPTs\n", + " - Chint CPS SCH275KTL-DO/US-800-36 V2: 900-1300V, 12 MPPTs\n", + " - Chint SCH333K-T-EU: 500-1500V, 15 MPPTs\n", + " - Chint SCH350K-T-EU: 500-1500V, 15 MPPTs\n", + " - Chint SCH350KTL-DO/US-800: 880-1300V, 15 MPPTs\n" + ] + } + ], + "source": [ + "# Find inverters compatible with high-voltage strings\n", + "result = sf.rcl.list_inverters(\n", + " v_mpp_max_gte=1100, # MPPT range extends to at least 1100V\n", + " nb_mppt_gte=10, # At least 10 MPPTs\n", + " output_parameter=[\"pNomConv\", \"vMppMin\", \"vMppMax\", \"nbMppt\"],\n", + " top=5,\n", + " api_key=api_key,\n", + " verbose=False, # Set to True to print total number of items and those retrieved up to top pagination limit.\n", + ")\n", + "\n", + "print(f\"Found {result['total']} high-voltage inverters\")\n", + "\n", + "for item in result[\"items\"]:\n", + " vmin = item.get('vMppMin', 'N/A')\n", + " vmax = item.get('vMppMax', 'N/A')\n", + " mppts = item.get('nbMppt', 'N/A')\n", + " print(f\" - {item['manufacturer']} {item['model']}: {vmin}-{vmax}V, {mppts} MPPTs\")" + ] + }, + { + "cell_type": "markdown", + "id": "6b1ef659", + "metadata": {}, + "source": [ + "## 4. Pagination\n", + "\n", + "For large result sets, use `top` and `skip` to paginate through results." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "dc65e197", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 91 modules found, retrieved 91.\n", + "Page 1: Retrieved 91 items (skip=0)\n", + "\n", + "Total retrieved: 91 modules\n" + ] + } + ], + "source": [ + "# Example: Iterate through all Trina modules\n", + "all_items = []\n", + "skip = 0\n", + "page_size = 100\n", + "max_pages = 3 # Limit for demo purposes\n", + "\n", + "for page in range(max_pages):\n", + " result = sf.rcl.list_modules(\n", + " manufacturer_contains=\"Trina\",\n", + " top=page_size,\n", + " skip=skip,\n", + " api_key=api_key,\n", + " )\n", + " all_items.extend(result[\"items\"])\n", + " \n", + " print(f\"Page {page + 1}: Retrieved {len(result['items'])} items (skip={skip})\")\n", + " \n", + " if len(result[\"items\"]) < page_size:\n", + " break # Last page\n", + " skip += page_size\n", + "\n", + "print(f\"\\nTotal retrieved: {len(all_items)} modules\")" + ] + }, + { + "cell_type": "markdown", + "id": "230e88e3", + "metadata": {}, + "source": [ + "## 5. Downloading Files\n", + "\n", + "⚠️ **Warning:** Each download counts against your monthly quota. Check your rate limit status before downloading.\n", + "\n", + "The cells below are set to not run by default to preserve your download quota. Remove the `if False:` guard to execute them." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "4a26a0ca", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Downloads remaining: 93/100\n" + ] + } + ], + "source": [ + "# Check rate limit before downloading\n", + "status = sf.rcl.get_rate_limit_status(api_key=api_key)\n", + "print(f\"Downloads remaining: {status.remaining}/{status.limit}\")\n", + "\n", + "if status.remaining < 5:\n", + " print(\"\\n⚠️ Low on downloads. Consider waiting until reset.\")\n", + " print(f\" Resets: {status.reset_datetime}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "180f25cb", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 1 modules found, retrieved 1.\n", + "Downloading: CanadianSolar_CS7N-715TB-AG.PAN\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: Saved RCL file to rcl_downloads\\CanadianSolar_CS7N-715TB-AG.PAN\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Downloaded 841 bytes\n", + "File saved as: CanadianSolar_CS7N-715TB-AG.PAN\n" + ] + } + ], + "source": [ + "# Example: Download a module file (DISABLED by default - remove 'if False:' to run)\n", + "if False:\n", + " # First, find a specific module\n", + " result = sf.rcl.list_modules(\n", + " manufacturer=\"Canadian Solar\",\n", + " model_contains=\"CS7N-715\",\n", + " top=1,\n", + " api_key=api_key,\n", + " )\n", + "\n", + " if result[\"items\"]:\n", + " item = RCLCatalogItem(result[\"items\"][0])\n", + " print(f\"Downloading: {item.filename}\")\n", + " \n", + " # Download to current directory\n", + " content = sf.rcl.download_file(\n", + " item.file_uuid,\n", + " item.filename,\n", + " directory_path=\"./rcl_downloads/\",\n", + " api_key=api_key,\n", + " )\n", + " \n", + " print(f\"Downloaded {len(content)} bytes\")\n", + " print(f\"File saved as: {item.filename}\")" + ] + }, + { + "cell_type": "markdown", + "id": "828519c6", + "metadata": {}, + "source": [ + "### 5.1 Handling duplicated download queries: cached files\n", + "\n", + "If you run the same download (same file name and directory), the `download_file` function will first check that the file does not exist in the folder. If it exists, the download will be skipped to protect you from wasting your quota to download the same file again (e.g., running a workflow in a loop).\n", + "\n", + "You can force the download by setting the parameter `use_cache=False`, this property's default is `True`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "06652656", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: Using cached file: rcl_downloads\\CanadianSolar_CS7N-715TB-AG.PAN (skipping download)\n" + ] + } + ], + "source": [ + "# Example: Download a module file (DISABLED by default - remove 'if False:' to run)\n", + "if False:\n", + " # First, find a specific module\n", + " result = sf.rcl.list_modules(\n", + " manufacturer=\"Canadian Solar\",\n", + " model_contains=\"CS7N-715\",\n", + " top=1,\n", + " api_key=api_key,\n", + " )\n", + " print(f\"{result['total']} modules found, retrieved {len(result['items'])}.\")\n", + "\n", + " if result[\"items\"]:\n", + " item = RCLCatalogItem(result[\"items\"][0])\n", + " print(f\"Downloading: {item.filename}\")\n", + " # Download to current directory the same module as above\n", + " content = sf.rcl.download_file(\n", + " item.file_uuid,\n", + " item.filename,\n", + " directory_path=\"./rcl_downloads/\",\n", + " api_key=api_key,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "b2715d79", + "metadata": {}, + "source": [ + "### 5.2 Hanlding incorrect download queries\n", + "\n", + "Note the typo below (i.e., *Canad**ai**n* instead of *Canad**ia**n*) in the manufacturer name. \n", + "\n", + "When there are no matches, an info message will indicate that the 0 modules were found." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "ebbb419a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 0 modules found, retrieved 0.\n" + ] + } + ], + "source": [ + "# Example: Download a module file (DISABLED by default - remove 'if False:' to run)\n", + "if False:\n", + " # First, find a specific module\n", + " result = sf.rcl.list_modules(\n", + " manufacturer=\"Canadain Solar\", # Note the typo here, this will return no results\n", + " model_contains=\"CS7N-715\",\n", + " top=1,\n", + " api_key=api_key,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "389d4067", + "metadata": {}, + "source": [ + "### 6. PVSystem Integration\n", + "\n", + "`PVSystem` provides convenience methods `set_module_from_rcl()` and `set_inverter_from_rcl()` that combine the search and download steps into a single call. If the search returns exactly one match, the file is downloaded and assigned to the plant; if multiple matches are found, a `ValueError` is raised listing the candidates so you can refine your filters.\n", + "\n", + "⚠️ **Note:** These methods download files and consume your monthly quota. Caching applies — re-running with the same filters and directory will reuse the local file.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "d57f70c0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Created plant: RCL Demo Plant\n", + "Current PAN files: {}\n", + "Current OND files: {}\n" + ] + } + ], + "source": [ + "# Create a PVSystem instance\n", + "plant = sf.PVSystem(\n", + " name=\"RCL Demo Plant\",\n", + " latitude=33.45,\n", + " longitude=-112.07,\n", + " dc_capacity_MW=10.0,\n", + " ac_capacity_MW=8.0,\n", + " mounting=\"Fixed\",\n", + ")\n", + "\n", + "print(f\"Created plant: {plant.name}\")\n", + "print(f\"Current PAN files: {plant.pan_files}\")\n", + "print(f\"Current OND files: {plant.ond_files}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "adbb68fb", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 1 modules found, retrieved 1.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: Saved RCL file to rcl_downloads\\CanadianSolar_CS7N-725TB-AG.PAN\n", + "INFO: Module 'CanadianSolar_CS7N-725TB-AG' set from RCL (841 bytes)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Module assigned: CanadianSolar_CS7N-725TB-AG.PAN\n", + "PAN files: {'CanadianSolar_CS7N-725TB-AG': WindowsPath('rcl_downloads/CanadianSolar_CS7N-725TB-AG.PAN')}\n" + ] + } + ], + "source": [ + "# Example: Set module from RCL (DISABLED by default - remove 'if False:' to run)\n", + "if False:\n", + " module = plant.set_module_from_rcl(\n", + " manufacturer_contains=\"Canadian Solar\",\n", + " model_contains=\"CS7N-725TB-AG\",\n", + " directory_path=\"./rcl_downloads/\",\n", + " api_key=api_key,\n", + " strict=False, # prints suggestions instead of raising on no/multiple matches\n", + " )\n", + " if module:\n", + " print(f\"Module assigned: {module['filename']}\")\n", + " print(f\"PAN files: {plant.pan_files}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "53078f21", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 1 inverters found, retrieved 1.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: Saved RCL file to rcl_downloads\\Sungrow_SG250HX_800V.OND\n", + "INFO: Inverter 'Sungrow_SG250HX_800V' set from RCL (2157 bytes)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Inverter assigned: Sungrow_SG250HX_800V.OND\n", + "OND files: {'Sungrow_SG250HX_800V': WindowsPath('rcl_downloads/Sungrow_SG250HX_800V.OND')}\n" + ] + } + ], + "source": [ + "# Example: Set inverter from RCL (DISABLED by default - remove 'if False:' to run)\n", + "if False:\n", + " inverter = plant.set_inverter_from_rcl(\n", + " manufacturer_contains=\"Sungrow\",\n", + " model_contains=\"SG250HX\",\n", + " directory_path=\"./rcl_downloads/\",\n", + " api_key=api_key,\n", + " strict=False, # prints suggestions instead of raising on no/multiple matches\n", + " )\n", + " if inverter:\n", + " print(f\"Inverter assigned: {inverter['filename']}\")\n", + " print(f\"OND files: {plant.ond_files}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "786bff1b", + "metadata": {}, + "source": [ + "### 6.1 Handling Multiple Component Matches\n", + "\n", + "When `set_module_from_rcl()` finds more than one result, it raises a `ValueError` listing the candidates instead of silently picking one (default `strict=True`). Pass `strict=False` to print the suggestions and return `None` instead — useful during exploration or for users who prefer not to handle exceptions explicitly.\n", + "\n", + "**Example with suggestions printed out**" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "06d915db", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 12 modules found, retrieved 12.\n", + "INFO: 12 modules found. Narrow your search:\n", + "\n", + " 1. Canadian Solar - CS7N-675TB-AG (675W bifacial)\n", + " → Add: model=\"CS7N-675TB-AG\" or p_nom=675 or bifaciality_factor_gte=0.7\n", + "\n", + " 2. Canadian Solar - CS7N-680TB-AG (680W bifacial)\n", + " → Add: model=\"CS7N-680TB-AG\" or p_nom=680 or bifaciality_factor_gte=0.7\n", + "\n", + " 3. Canadian Solar - CS7N-685TB-AG (685W bifacial)\n", + " → Add: model=\"CS7N-685TB-AG\" or p_nom=685 or bifaciality_factor_gte=0.7\n", + "\n", + " 4. Canadian Solar - CS7N-690TB-AG (690W bifacial)\n", + " → Add: model=\"CS7N-690TB-AG\" or p_nom=690 or bifaciality_factor_gte=0.7\n", + "\n", + " 5. Canadian Solar - CS7N-695TB-AG (695W bifacial)\n", + " → Add: model=\"CS7N-695TB-AG\" or p_nom=695 or bifaciality_factor_gte=0.7\n", + "\n", + " 6. Canadian Solar - CS7N-700TB-AG (700W bifacial)\n", + " → Add: model=\"CS7N-700TB-AG\" or p_nom=700 or bifaciality_factor_gte=0.7\n", + "\n", + " 7. Canadian Solar - CS7N-705TB-AG (705W bifacial)\n", + " → Add: model=\"CS7N-705TB-AG\" or p_nom=705 or bifaciality_factor_gte=0.7\n", + "\n", + " 8. Canadian Solar - CS7N-710TB-AG (710W bifacial)\n", + " → Add: model=\"CS7N-710TB-AG\" or p_nom=710 or bifaciality_factor_gte=0.7\n", + "\n", + " 9. Canadian Solar - CS7N-715TB-AG (715W bifacial)\n", + " → Add: model=\"CS7N-715TB-AG\" or p_nom=715 or bifaciality_factor_gte=0.7\n", + "\n", + " 10. Canadian Solar - CS7N-720TB-AG (720W bifacial)\n", + " → Add: model=\"CS7N-720TB-AG\" or p_nom=720 or bifaciality_factor_gte=0.7\n", + "\n", + " 11. Canadian Solar - CS7N-725TB-AG (725W bifacial)\n", + " → Add: model=\"CS7N-725TB-AG\" or p_nom=725 or bifaciality_factor_gte=0.7\n", + "\n", + " 12. Canadian Solar - CS7N-730TB-AG (730W bifacial)\n", + " → Add: model=\"CS7N-730TB-AG\" or p_nom=730 or bifaciality_factor_gte=0.7\n", + "\n" + ] + } + ], + "source": [ + "# Broad search — strict=False prints candidates instead of raising ValueError\n", + "module = plant.set_module_from_rcl(\n", + " manufacturer_contains=\"Canadian Solar\",\n", + " model_contains=\"CS7N\", # too broad: matches many models\n", + " directory_path=\"./rcl_downloads/\",\n", + " api_key=api_key,\n", + " strict=False, # prints suggestions instead of raising an error on no/multiple matches\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "f39445b9", + "metadata": {}, + "source": [ + "**Example with `ValueError` raised**\n", + "\n", + "This is the default behaviour when the parameter `strict` is not passed." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "ce4d2533", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 12 modules found, retrieved 12.\n" + ] + }, + { + "ename": "ValueError", + "evalue": "12 modules found. Narrow your search:\n\n 1. Canadian Solar - CS7N-675TB-AG (675W bifacial)\n → Add: model=\"CS7N-675TB-AG\" or p_nom=675 or bifaciality_factor_gte=0.7\n\n 2. Canadian Solar - CS7N-680TB-AG (680W bifacial)\n → Add: model=\"CS7N-680TB-AG\" or p_nom=680 or bifaciality_factor_gte=0.7\n\n 3. Canadian Solar - CS7N-685TB-AG (685W bifacial)\n → Add: model=\"CS7N-685TB-AG\" or p_nom=685 or bifaciality_factor_gte=0.7\n\n 4. Canadian Solar - CS7N-690TB-AG (690W bifacial)\n → Add: model=\"CS7N-690TB-AG\" or p_nom=690 or bifaciality_factor_gte=0.7\n\n 5. Canadian Solar - CS7N-695TB-AG (695W bifacial)\n → Add: model=\"CS7N-695TB-AG\" or p_nom=695 or bifaciality_factor_gte=0.7\n\n 6. Canadian Solar - CS7N-700TB-AG (700W bifacial)\n → Add: model=\"CS7N-700TB-AG\" or p_nom=700 or bifaciality_factor_gte=0.7\n\n 7. Canadian Solar - CS7N-705TB-AG (705W bifacial)\n → Add: model=\"CS7N-705TB-AG\" or p_nom=705 or bifaciality_factor_gte=0.7\n\n 8. Canadian Solar - CS7N-710TB-AG (710W bifacial)\n → Add: model=\"CS7N-710TB-AG\" or p_nom=710 or bifaciality_factor_gte=0.7\n\n 9. Canadian Solar - CS7N-715TB-AG (715W bifacial)\n → Add: model=\"CS7N-715TB-AG\" or p_nom=715 or bifaciality_factor_gte=0.7\n\n 10. Canadian Solar - CS7N-720TB-AG (720W bifacial)\n → Add: model=\"CS7N-720TB-AG\" or p_nom=720 or bifaciality_factor_gte=0.7\n\n 11. Canadian Solar - CS7N-725TB-AG (725W bifacial)\n → Add: model=\"CS7N-725TB-AG\" or p_nom=725 or bifaciality_factor_gte=0.7\n\n 12. Canadian Solar - CS7N-730TB-AG (730W bifacial)\n → Add: model=\"CS7N-730TB-AG\" or p_nom=730 or bifaciality_factor_gte=0.7\n", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mValueError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[20]\u001b[39m\u001b[32m, line 2\u001b[39m\n\u001b[32m 1\u001b[39m \u001b[38;5;66;03m# Broad search — strict=True to raise ValueError\u001b[39;00m\n\u001b[32m----> \u001b[39m\u001b[32m2\u001b[39m module = \u001b[43mplant\u001b[49m\u001b[43m.\u001b[49m\u001b[43mset_module_from_rcl\u001b[49m\u001b[43m(\u001b[49m\n\u001b[32m 3\u001b[39m \u001b[43m \u001b[49m\u001b[43mmanufacturer_contains\u001b[49m\u001b[43m=\u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43mCanadian Solar\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m,\u001b[49m\n\u001b[32m 4\u001b[39m \u001b[43m \u001b[49m\u001b[43mmodel_contains\u001b[49m\u001b[43m=\u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43mCS7N\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;66;43;03m# too broad: matches many models\u001b[39;49;00m\n\u001b[32m 5\u001b[39m \u001b[43m \u001b[49m\u001b[43mdirectory_path\u001b[49m\u001b[43m=\u001b[49m\u001b[33;43m\"\u001b[39;49m\u001b[33;43m./rcl_downloads/\u001b[39;49m\u001b[33;43m\"\u001b[39;49m\u001b[43m,\u001b[49m\n\u001b[32m 6\u001b[39m \u001b[43m \u001b[49m\u001b[43mapi_key\u001b[49m\u001b[43m=\u001b[49m\u001b[43mapi_key\u001b[49m\u001b[43m,\u001b[49m\n\u001b[32m 7\u001b[39m \u001b[43m \u001b[49m\u001b[43mstrict\u001b[49m\u001b[43m=\u001b[49m\u001b[38;5;28;43;01mTrue\u001b[39;49;00m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;66;43;03m# raises an error on no/multiple matches\u001b[39;49;00m\n\u001b[32m 8\u001b[39m \u001b[43m)\u001b[49m\n", + "\u001b[36mFile \u001b[39m\u001b[32mC:\\repos\\solarfarmer-python-sdk-github\\solarfarmer\\models\\pvsystem\\pvsystem.py:1148\u001b[39m, in \u001b[36mPVSystem.set_module_from_rcl\u001b[39m\u001b[34m(self, manufacturer, manufacturer_contains, model, model_contains, p_nom, directory_path, strict, api_key, **kwargs)\u001b[39m\n\u001b[32m 1146\u001b[39m msg = \u001b[33m\"\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m\"\u001b[39m.join(lines)\n\u001b[32m 1147\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m strict:\n\u001b[32m-> \u001b[39m\u001b[32m1148\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(msg)\n\u001b[32m 1149\u001b[39m \u001b[38;5;28mprint\u001b[39m(\u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mINFO: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mmsg\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m)\n\u001b[32m 1150\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m\n", + "\u001b[31mValueError\u001b[39m: 12 modules found. Narrow your search:\n\n 1. Canadian Solar - CS7N-675TB-AG (675W bifacial)\n → Add: model=\"CS7N-675TB-AG\" or p_nom=675 or bifaciality_factor_gte=0.7\n\n 2. Canadian Solar - CS7N-680TB-AG (680W bifacial)\n → Add: model=\"CS7N-680TB-AG\" or p_nom=680 or bifaciality_factor_gte=0.7\n\n 3. Canadian Solar - CS7N-685TB-AG (685W bifacial)\n → Add: model=\"CS7N-685TB-AG\" or p_nom=685 or bifaciality_factor_gte=0.7\n\n 4. Canadian Solar - CS7N-690TB-AG (690W bifacial)\n → Add: model=\"CS7N-690TB-AG\" or p_nom=690 or bifaciality_factor_gte=0.7\n\n 5. Canadian Solar - CS7N-695TB-AG (695W bifacial)\n → Add: model=\"CS7N-695TB-AG\" or p_nom=695 or bifaciality_factor_gte=0.7\n\n 6. Canadian Solar - CS7N-700TB-AG (700W bifacial)\n → Add: model=\"CS7N-700TB-AG\" or p_nom=700 or bifaciality_factor_gte=0.7\n\n 7. Canadian Solar - CS7N-705TB-AG (705W bifacial)\n → Add: model=\"CS7N-705TB-AG\" or p_nom=705 or bifaciality_factor_gte=0.7\n\n 8. Canadian Solar - CS7N-710TB-AG (710W bifacial)\n → Add: model=\"CS7N-710TB-AG\" or p_nom=710 or bifaciality_factor_gte=0.7\n\n 9. Canadian Solar - CS7N-715TB-AG (715W bifacial)\n → Add: model=\"CS7N-715TB-AG\" or p_nom=715 or bifaciality_factor_gte=0.7\n\n 10. Canadian Solar - CS7N-720TB-AG (720W bifacial)\n → Add: model=\"CS7N-720TB-AG\" or p_nom=720 or bifaciality_factor_gte=0.7\n\n 11. Canadian Solar - CS7N-725TB-AG (725W bifacial)\n → Add: model=\"CS7N-725TB-AG\" or p_nom=725 or bifaciality_factor_gte=0.7\n\n 12. Canadian Solar - CS7N-730TB-AG (730W bifacial)\n → Add: model=\"CS7N-730TB-AG\" or p_nom=730 or bifaciality_factor_gte=0.7\n" + ] + } + ], + "source": [ + "# Broad search — strict=True to raise ValueError\n", + "module = plant.set_module_from_rcl(\n", + " manufacturer_contains=\"Canadian Solar\",\n", + " model_contains=\"CS7N\", # too broad: matches many models\n", + " directory_path=\"./rcl_downloads/\",\n", + " api_key=api_key,\n", + " strict=True, # raises an error on no/multiple matches\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "9fa0ab34", + "metadata": {}, + "source": [ + "**Example with correct (refined / narrowed down) search**\n", + "\n", + "Provide a specific model to get a desired instance. \n", + "\n", + "*Note: cached from local disk*" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e9a70a33", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: Using cached file: rcl_downloads\\CanadianSolar_CS7N-715TB-AG.PAN (skipping download)\n", + "INFO: Module 'CanadianSolar_CS7N-715TB-AG' set from RCL (841 bytes)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 1 modules found, retrieved 1.\n", + "Module assigned: CanadianSolar_CS7N-715TB-AG.PAN\n", + "PAN files: {'CanadianSolar_CS7N-725TB-AG': WindowsPath('rcl_downloads/CanadianSolar_CS7N-725TB-AG.PAN'), 'CanadianSolar_CS7N-715TB-AG': WindowsPath('rcl_downloads/CanadianSolar_CS7N-715TB-AG.PAN')}\n" + ] + } + ], + "source": [ + "# Refined search — specific enough to resolve to a single match (DISABLED by default - remove 'if False:' to run)\n", + "if False:\n", + " module = plant.set_module_from_rcl(\n", + " manufacturer_contains=\"Canadian Solar\",\n", + " model_contains=\"CS7N-715TB-AG\", # specific enough for a single result\n", + " directory_path=\"./rcl_downloads/\",\n", + " api_key=api_key,\n", + " strict=False,\n", + " )\n", + " if module:\n", + " print(f\"Module assigned: {module['filename']}\")\n", + " print(f\"PAN files: {plant.pan_files}\")" + ] + }, + { + "cell_type": "markdown", + "id": "af47d651", + "metadata": {}, + "source": [ + "## 7. Filter Reference\n", + "\n", + "### Module Filters\n", + "\n", + "| Parameter | Description | Example |\n", + "|-----------|-------------|---------|\n", + "| `manufacturer` | Exact manufacturer match | `\"Canadian Solar\"` |\n", + "| `manufacturer_contains` | Manufacturer contains substring | `\"Canadian\"` |\n", + "| `model` | Exact model match | `\"CS7N-715TB-AG\"` |\n", + "| `model_contains` | Model contains substring | `\"715TB\"` |\n", + "| `p_nom_gte` | Minimum nominal power (W) | `600` |\n", + "| `p_nom_lte` | Maximum nominal power (W) | `800` |\n", + "| `bifaciality_factor_gte` | Minimum bifaciality factor | `0.7` |\n", + "| `technol` | Technology type | `\"mtSi\"` |\n", + "| `lifecycle_status` | Lifecycle status | `\"active\"` |\n", + "\n", + "### Inverter Filters\n", + "\n", + "| Parameter | Description | Example |\n", + "|-----------|-------------|---------|\n", + "| `manufacturer` | Exact manufacturer match | `\"Sungrow\"` |\n", + "| `manufacturer_contains` | Manufacturer contains substring | `\"Sungrow\"` |\n", + "| `model` | Exact model match | `\"SG250HX\"` |\n", + "| `model_contains` | Model contains substring | `\"HX\"` |\n", + "| `p_nom_conv_gte` | Min rated AC power (kW) | `100` |\n", + "| `effic_max_gte` | Min max efficiency (%) | `98.5` |\n", + "| `v_mpp_min_lte` | Max lower MPPT voltage (V) | `500` |\n", + "| `v_mpp_max_gte` | Min upper MPPT voltage (V) | `1100` |\n", + "| `nb_mppt_gte` | Min number of MPPTs | `12` |\n", + "\n", + "### Pagination & Ordering\n", + "\n", + "| Parameter | Default | Description |\n", + "|-----------|---------|-------------|\n", + "| `top` | 25 | Page size (max 10000) |\n", + "| `skip` | 0 | Offset for pagination |\n", + "| `order_by` | - | Field to sort by |\n", + "| `order_dir` | ASC | Sort direction: ASC or DESC |\n", + "| `output_parameter` | all | List of fields to return |" + ] + }, + { + "cell_type": "markdown", + "id": "cd353b22", + "metadata": {}, + "source": [ + "## Next Steps\n", + "\n", + "- See [RCL Documentation](../getting-started/rcl-component-library.md) for detailed usage via the SDK\n", + "- Explore [Workflow 2](../getting-started/workflow-2-pvplant-builder.md) to use downloaded files with PVSystem\n", + "- Check the [API Reference](../api.md#renewable-component-library-rcl) for full function signatures\n", + "- If you need details on the API endpoint, visit [rcl endpoint](https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/WebApi/RclApi/RclEndpoint.html) in the SolarFarmer public documentation\n", + "- There is also a basic endpoint tutorial in [rcl endpoint tutorial](https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/WebApi/RclApi/RclEndpointTutorial.html)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv (3.11.2.final.0)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/mkdocs.yml b/mkdocs.yml index 0309551..f3ea6ed 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -89,6 +89,7 @@ nav: - Workflow 1 - Load Existing API Files: getting-started/workflow-1-existing-api-files.md - Workflow 2 - Design Plants with PVSystem: getting-started/workflow-2-pvplant-builder.md - Workflow 3 - Advanced Integration: getting-started/workflow-3-plantbuilder-advanced.md + - Renewable Component Library (RCL): getting-started/rcl-component-library.md - Quick Start Examples: getting-started/quick-start-examples.md - End-to-End Examples: getting-started/end-to-end-examples.md - API Reference: api.md @@ -99,9 +100,11 @@ nav: - ModelChainAsync: https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/WebApi/Endpoints/ModelChainAsyncEndpoint.html - Service: https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/WebApi/Endpoints/ServiceEndpoint.html - TerminateModelChainAsync: https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/WebApi/Endpoints/TerminateModelChainAsyncEndpoint.html + - Renewable Component Library (RCL): https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/WebApi/RclApi/RclEndpoint.html - Tutorials in SolarFarmer docs: - ModelChain endpoint tutorial: https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/WebApi/Tutorials/ModelChainEndpointTutorial.html - ModelChainAsync endpoint tutorial: https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/WebApi/Tutorials/ModelChainAsyncEndpointTutorial.html + - RCL endpoint tutorial: https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/WebApi/RclApi/RclEndpointTutorial.html - Troubleshooting: - Validation service: https://mysoftware.dnv.com/download/public/renewables/solarfarmer/manuals/latest/WebApi/Troubleshooting/ValidationService.html - License: license.md diff --git a/solarfarmer/__init__.py b/solarfarmer/__init__.py index c1a786c..9d0bdec 100644 --- a/solarfarmer/__init__.py +++ b/solarfarmer/__init__.py @@ -1,3 +1,4 @@ +from . import rcl from .__version__ import __version__ from .api import SolarFarmerAPIError from .config import ( @@ -74,6 +75,15 @@ ValidationMessage, Vector3Double, ) +from .rcl import ( + RCLCatalogItem, + RCLCatalogResponse, + RCLRateLimitInfo, + download_file, + get_rate_limit_status, + list_inverters, + list_modules, +) from .weather import ( TSV_COLUMNS, check_sequential_year_timestamps, @@ -162,4 +172,13 @@ "from_pvlib", "from_solcast", "check_sequential_year_timestamps", + # RCL + "rcl", + "list_modules", + "list_inverters", + "download_file", + "get_rate_limit_status", + "RCLCatalogItem", + "RCLCatalogResponse", + "RCLRateLimitInfo", ] diff --git a/solarfarmer/api.py b/solarfarmer/api.py index e322c69..c9c10af 100644 --- a/solarfarmer/api.py +++ b/solarfarmer/api.py @@ -11,6 +11,8 @@ API_TOKEN, BASE_API_URL, GENERAL_TIMEOUT, + RCL_BASE_URL, + RCL_TIMEOUT, SF_PORTAL_URL, ) @@ -74,6 +76,84 @@ def __str__(self) -> str: return base +def _build_auth_headers(api_key: str | None = None) -> dict[str, str]: + """ + Build Authorization headers for SolarFarmer API requests. + + Parameters + ---------- + api_key : str, optional + API token. Falls back to ``SF_API_KEY`` environment variable. + + Returns + ------- + dict[str, str] + Headers dict with ``Authorization`` key. + + Raises + ------ + ValueError + If no API key is found or the key is too short. + """ + token = api_key or API_TOKEN + if not token: + raise ValueError( + "no API key provided. Either set it as an environment " + "variable `SF_API_KEY`, or provide `api_key` " + "as an argument. Visit https://solarfarmer.dnv.com/ to get an API key." + ) + if len(token) <= 1: + raise ValueError("API key is too short.") + return {"Authorization": f"Bearer {token}"} + + +class RCLClient: + """HTTP client for RCL (Renewable Component Library) endpoints. GET-only.""" + + def __init__( + self, + base_url: str = RCL_BASE_URL, + timeout: int = RCL_TIMEOUT, + ) -> None: + """ + Parameters + ---------- + base_url : str + Base URL for the RCL API. Defaults to ``RCL_BASE_URL``. + timeout : int + Request timeout in seconds. Defaults to ``RCL_TIMEOUT``. + """ + self.base_url = base_url + self.timeout = timeout + + def get( + self, + endpoint: str, + params: dict | None = None, + api_key: str | None = None, + ) -> requests.Response: + """ + Execute a GET request to an RCL endpoint. + + Parameters + ---------- + endpoint : str + Endpoint path relative to ``base_url`` (e.g. ``"catalog/modules"``). + params : dict, optional + Query parameters to include in the request. + api_key : str, optional + API token. Falls back to ``SF_API_KEY`` environment variable. + + Returns + ------- + requests.Response + The raw HTTP response (caller is responsible for status checking). + """ + url = f"{self.base_url}/{endpoint}" + headers = _build_auth_headers(api_key) + return requests.get(url, headers=headers, params=params, timeout=self.timeout) + + class Client: """Handles all API requests for the different endpoints.""" diff --git a/solarfarmer/config.py b/solarfarmer/config.py index 73cc21e..b760eab 100644 --- a/solarfarmer/config.py +++ b/solarfarmer/config.py @@ -22,6 +22,13 @@ "MODELCHAIN_ASYNC_TIMEOUT_UPLOAD", "MODELCHAIN_ASYNC_POLL_TIME", "PANDAS_INSTALL_MSG", + # RCL + "RCL_BASE_URL", + "RCL_CATALOG_URL", + "RCL_MODULES_URL", + "RCL_INVERTERS_URL", + "RCL_TIMEOUT", + "RCL_RATE_LIMIT_WARNING_THRESHOLD", ] BASE_API_URL = os.getenv( @@ -53,3 +60,11 @@ PANDAS_INSTALL_MSG = ( "pandas is required for this function. Install it with: pip install 'dnv-solarfarmer[weather]'" ) + +# RCL (Renewable Component Library) configuration +RCL_BASE_URL = "https://solarfarmer.dnv.com/rcl" +RCL_CATALOG_URL = f"{RCL_BASE_URL}/catalog" +RCL_MODULES_URL = f"{RCL_CATALOG_URL}/modules" +RCL_INVERTERS_URL = f"{RCL_CATALOG_URL}/inverters" +RCL_TIMEOUT = 30 # seconds +RCL_RATE_LIMIT_WARNING_THRESHOLD = 0.20 # Warn when < 20% remaining diff --git a/solarfarmer/models/pvsystem/pvsystem.py b/solarfarmer/models/pvsystem/pvsystem.py index 8d6eb9a..53d252f 100644 --- a/solarfarmer/models/pvsystem/pvsystem.py +++ b/solarfarmer/models/pvsystem/pvsystem.py @@ -1020,6 +1020,312 @@ def payload_to_file(self, file_path: PathLike) -> None: _logger.debug("PVSystem payload saved to %s", path) return None + def set_module_from_rcl( + self, + *, + manufacturer: str | None = None, + manufacturer_contains: str | None = None, + model: str | None = None, + model_contains: str | None = None, + p_nom: float | None = None, + directory_path: str | Path | None = None, + strict: bool = True, + api_key: str | None = None, + **kwargs: object, + ) -> dict | None: + """Search RCL for a PV module and assign it to this PVSystem. + + Downloads and assigns the PAN file only when the search returns exactly one + match. If multiple matches are found, raises ``ValueError`` with a numbered + list of options and suggested filter values so the user can narrow the search. + + Parameters + ---------- + manufacturer : str, optional + Exact manufacturer name match. + manufacturer_contains : str, optional + Manufacturer name contains substring. + model : str, optional + Exact model name match. + model_contains : str, optional + Model name contains substring. + p_nom : float, optional + Exact nominal power (W). Useful for disambiguating power-tier variants. + directory_path : str or Path, optional + Directory where the downloaded PAN file is saved. Defaults to the + current working directory. + strict : bool + If ``True`` (default), raises ``ValueError`` when zero or multiple + matches are found. If ``False``, prints the diagnostic message and + returns ``None``, which is friendlier for exploratory use. + api_key : str, optional + API token. Defaults to the ``SF_API_KEY`` environment variable. + **kwargs + Additional RCL filter parameters (forward compatibility). + + Returns + ------- + dict or None + The matched catalog item dict. Also updates ``self.pan_files``. + Returns ``None`` when ``strict=False`` and no unique match is found. + + Raises + ------ + ValueError + If no modules match, or if multiple modules match (includes option + list). Only raised when ``strict=True``. + + Examples + -------- + >>> plant.set_module_from_rcl( + ... manufacturer_contains="Canadian Solar", + ... model_contains="CS7N-715TB-AG", + ... ) + """ + from solarfarmer import rcl # lazy import to avoid circular dependency + + _OUTPUT_FIELDS = [ + "pNom", + "bifacialityFactor", + "fileUuid", + "filename", + "manufacturer", + "model", + ] + + result = rcl.list_modules( + manufacturer=manufacturer, + manufacturer_contains=manufacturer_contains, + model=model, + model_contains=model_contains, + p_nom=p_nom, + output_parameter=_OUTPUT_FIELDS, + top=10000, + api_key=api_key, + **kwargs, + ) + items = result["items"] + + if len(items) == 0: + criteria = { + k: v + for k, v in { + "manufacturer": manufacturer, + "manufacturer_contains": manufacturer_contains, + "model": model, + "model_contains": model_contains, + "p_nom": p_nom, + **kwargs, + }.items() + if v is not None + } + msg = f"No modules found matching criteria: {criteria}" + if strict: + raise ValueError(msg) + print(f"INFO: {msg}") + return None + + if len(items) > 1: + lines = [f"{len(items)} modules found. Narrow your search:\n"] + for i, item in enumerate(items, start=1): + mfr = item.get("manufacturer", "") + mdl = item.get("model", "") + pnom = item.get("pNom") + bif = item.get("bifacialityFactor") + label = f"{mfr} - {mdl}" + if pnom is not None: + label += f" ({int(pnom)}W" + label += " bifacial)" if bif else ")" + lines.append(f" {i}. {label}") + suggestions = [f'model="{mdl}"'] + if pnom is not None: + suggestions.append(f"p_nom={int(pnom)}") + if bif: + suggestions.append("bifaciality_factor_gte=0.7") + lines.append(f" \u2192 Add: {' or '.join(suggestions)}\n") + msg = "\n".join(lines) + if strict: + raise ValueError(msg) + print(f"INFO: {msg}") + return None + + item = items[0] + content = rcl.download_file( + item["fileUuid"], + item["filename"], + directory_path=directory_path, + api_key=api_key, + ) + pan_path = Path(directory_path or ".") / item["filename"] + module_name = Path(item["filename"]).stem + self.pan_files = {**self._pan_files, module_name: pan_path} + _logger.info("Module '%s' set from RCL (%d bytes)", module_name, len(content)) + return item + + def set_inverter_from_rcl( + self, + *, + manufacturer: str | None = None, + manufacturer_contains: str | None = None, + model: str | None = None, + model_contains: str | None = None, + p_nom_conv: float | None = None, + effic_max_gte: float | None = None, + nb_mppt: int | None = None, + transfo: str | None = None, + directory_path: str | Path | None = None, + strict: bool = True, + api_key: str | None = None, + **kwargs: object, + ) -> dict | None: + """Search RCL for an inverter and assign it to this PVSystem. + + Downloads and assigns the OND file only when the search returns exactly one + match. If multiple matches are found, raises ``ValueError`` with a numbered + list of options and suggested filter values so the user can narrow the search. + + Parameters + ---------- + manufacturer : str, optional + Exact manufacturer name match. + manufacturer_contains : str, optional + Manufacturer name contains substring. + model : str, optional + Exact model name match. + model_contains : str, optional + Model name contains substring. + p_nom_conv : float, optional + Exact rated AC power (W). Useful for disambiguating power-tier variants. + effic_max_gte : float, optional + Minimum maximum efficiency (fraction, e.g. ``0.98``). + nb_mppt : int, optional + Exact number of MPPT inputs. + transfo : str, optional + Transformer type (e.g. ``"transformerless"``). + directory_path : str or Path, optional + Directory where the downloaded OND file is saved. Defaults to the + current working directory. + strict : bool + If ``True`` (default), raises ``ValueError`` when zero or multiple + matches are found. If ``False``, prints the diagnostic message and + returns ``None``, which is friendlier for exploratory use. + api_key : str, optional + API token. Defaults to the ``SF_API_KEY`` environment variable. + **kwargs + Additional RCL filter parameters (forward compatibility). + + Returns + ------- + dict or None + The matched catalog item dict. Also updates ``self.ond_files``. + Returns ``None`` when ``strict=False`` and no unique match is found. + + Raises + ------ + ValueError + If no inverters match, or if multiple inverters match (includes option + list). Only raised when ``strict=True``. + + Examples + -------- + >>> plant.set_inverter_from_rcl( + ... manufacturer_contains="SMA", + ... model_contains="STP 110-60", + ... ) + """ + from solarfarmer import rcl # lazy import to avoid circular dependency + + _OUTPUT_FIELDS = [ + "pNomConv", + "efficMax", + "nbMppt", + "fileUuid", + "filename", + "manufacturer", + "model", + ] + + result = rcl.list_inverters( + manufacturer=manufacturer, + manufacturer_contains=manufacturer_contains, + model=model, + model_contains=model_contains, + p_nom_conv_gte=p_nom_conv, + p_nom_conv_lte=p_nom_conv, + effic_max_gte=effic_max_gte, + nb_mppt_gte=nb_mppt, + transfo=transfo, + output_parameter=_OUTPUT_FIELDS, + top=10000, + api_key=api_key, + **kwargs, + ) + items = result["items"] + + if len(items) == 0: + criteria = { + k: v + for k, v in { + "manufacturer": manufacturer, + "manufacturer_contains": manufacturer_contains, + "model": model, + "model_contains": model_contains, + "p_nom_conv": p_nom_conv, + "effic_max_gte": effic_max_gte, + "nb_mppt": nb_mppt, + "transfo": transfo, + **kwargs, + }.items() + if v is not None + } + msg = f"No inverters found matching criteria: {criteria}" + if strict: + raise ValueError(msg) + print(f"INFO: {msg}") + return None + + if len(items) > 1: + lines = [f"{len(items)} inverters found. Narrow your search:\n"] + for i, item in enumerate(items, start=1): + mfr = item.get("manufacturer", "") + mdl = item.get("model", "") + pnom = item.get("pNomConv") + eff = item.get("efficMax") + mppt = item.get("nbMppt") + label = f"{mfr} - {mdl}" + extras = [] + if pnom is not None: + extras.append(f"{pnom / 1000:.1f}kW" if pnom >= 1000 else f"{pnom}W") + if eff is not None: + extras.append(f"eff={eff * 100:.1f}%") + if mppt is not None: + extras.append(f"{mppt} MPPT") + if extras: + label += f" ({', '.join(extras)})" + lines.append(f" {i}. {label}") + suggestions = [f'model="{mdl}"'] + if pnom is not None: + suggestions.append(f"p_nom_conv={pnom}") + lines.append(f" \u2192 Add: {' or '.join(suggestions)}\n") + msg = "\n".join(lines) + if strict: + raise ValueError(msg) + print(f"INFO: {msg}") + return None + + item = items[0] + content = rcl.download_file( + item["fileUuid"], + item["filename"], + directory_path=directory_path, + api_key=api_key, + ) + ond_path = Path(directory_path or ".") / item["filename"] + inverter_name = Path(item["filename"]).stem + self.ond_files = {**self._ond_files, inverter_name: ond_path} + _logger.info("Inverter '%s' set from RCL (%d bytes)", inverter_name, len(content)) + return item + def produce_payload(self) -> dict[str, Any]: """Construct and return the payload dictionary for the SolarFarmer API based on the current PVSystem configuration. diff --git a/solarfarmer/rcl.py b/solarfarmer/rcl.py new file mode 100644 index 0000000..acfda94 --- /dev/null +++ b/solarfarmer/rcl.py @@ -0,0 +1,826 @@ +""" +RCL (Renewable Component Library) — catalog search and file download functions. + +Provides access to PV module (PAN) and inverter (OND) files hosted in the +DNV Renewable Component Library, using the same ``SF_API_KEY`` as the main +SolarFarmer API. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import TypedDict + +import requests + +from .api import RCLClient, SolarFarmerAPIError +from .config import RCL_RATE_LIMIT_WARNING_THRESHOLD +from .logging import get_logger + +_logger = get_logger(__name__) + +# Map snake_case Python params to camelCase API field names +_FIELD_MAP = { + "p_nom": "pNom", + "bifaciality_factor": "bifacialityFactor", + "p_nom_conv": "pNomConv", + "effic_max": "efficMax", + "v_mpp_min": "vMppMin", + "v_mpp_max": "vMppMax", + "nb_mppt": "nbMppt", + "lifecycle_status": "lifecycleStatus", +} + +__all__ = [ + "RCLRateLimitInfo", + "RCLCatalogItem", + "RCLCatalogResponse", + "list_modules", + "list_inverters", + "download_file", + "get_rate_limit_status", +] + + +# --------------------------------------------------------------------------- +# Response types +# --------------------------------------------------------------------------- + + +@dataclass +class RCLRateLimitInfo: + """Rate limit information parsed from RCL API response headers. + + Attributes + ---------- + remaining : int + Number of downloads remaining in the current period. + limit : int + Total download allowance for the current period. + reset_timestamp : int + Unix timestamp when the quota resets. + """ + + remaining: int + limit: int + reset_timestamp: int + + @property + def reset_datetime(self) -> datetime: + """Reset time as a UTC-aware datetime.""" + return datetime.fromtimestamp(self.reset_timestamp, tz=timezone.utc) + + @property + def usage_percent(self) -> float: + """Percentage of the limit already consumed.""" + if self.limit == 0: + return 100.0 + return ((self.limit - self.remaining) / self.limit) * 100 + + @property + def is_low(self) -> bool: + """``True`` if remaining downloads are below the warning threshold.""" + return self.remaining < (self.limit * RCL_RATE_LIMIT_WARNING_THRESHOLD) + + def __str__(self) -> str: + return f"{self.remaining}/{self.limit} downloads remaining (resets {self.reset_datetime})" + + +@dataclass +class RCLCatalogItem: + """Typed wrapper for an RCL catalog item providing IDE-friendly attribute access. + + This class wraps the raw dict returned by the RCL API, providing typed + properties with consistent snake_case naming. The original dict is accessible + via the ``raw`` attribute for fields not explicitly mapped. + + Attributes + ---------- + raw : dict + The original API response dict with all fields. + + Properties (always available) + ----------------------------- + file_uuid : str + Unique identifier for downloading the file. Aliases: ``fileUuid``. + filename : str + Original filename (e.g., ``"CS7N-715TB-AG.PAN"``). + manufacturer : str + Equipment manufacturer name. + model : str + Equipment model name. + component_id : str + RCL component identifier. Aliases: ``componentId``. + + Properties (modules, if requested) + ---------------------------------- + p_nom : float | None + Nominal power in watts. Aliases: ``pNom``. + bifaciality_factor : float | None + Bifaciality factor (0-1). Aliases: ``bifacialityFactor``. + technol : str | None + Technology type (e.g., ``"monoSi"``). + + Properties (inverters, if requested) + ------------------------------------ + p_nom_conv : float | None + Rated AC power in kW. Aliases: ``pNomConv``. + effic_max : float | None + Maximum efficiency. Aliases: ``efficMax``. + v_mpp_min : float | None + Minimum MPPT voltage in V. Aliases: ``vMppMin``. + v_mpp_max : float | None + Maximum MPPT voltage in V. Aliases: ``vMppMax``. + nb_mppt : int | None + Number of MPPT inputs. Aliases: ``nbMppt``. + + Examples + -------- + >>> result = sf.rcl.list_modules(manufacturer_contains="Canadian", top=1) + >>> item = RCLCatalogItem(result["items"][0]) + >>> print(item.file_uuid) # IDE autocomplete works + >>> print(item.manufacturer) + >>> content = sf.rcl.download_file(item.file_uuid, item.filename) + """ + + raw: dict + + # --- Always available --- + + @property + def file_uuid(self) -> str: + """File UUID for downloading. Aliases: ``fileUuid``.""" + return self.raw.get("fileUuid", "") + + @property + def fileUuid(self) -> str: + """Alias for :attr:`file_uuid` (camelCase).""" + return self.file_uuid + + @property + def filename(self) -> str: + """Original filename (e.g., ``"module.PAN"``).""" + return self.raw.get("filename", "") + + @property + def manufacturer(self) -> str: + """Equipment manufacturer name.""" + return self.raw.get("manufacturer", "") + + @property + def model(self) -> str: + """Equipment model name.""" + return self.raw.get("model", "") + + @property + def component_id(self) -> str: + """RCL component identifier. Aliases: ``componentId``.""" + return self.raw.get("componentId", "") + + @property + def componentId(self) -> str: + """Alias for :attr:`component_id` (camelCase).""" + return self.component_id + + # --- Module fields --- + + @property + def p_nom(self) -> float | None: + """Nominal power in watts. Aliases: ``pNom``.""" + return self.raw.get("pNom") + + @property + def pNom(self) -> float | None: + """Alias for :attr:`p_nom` (camelCase).""" + return self.p_nom + + @property + def bifaciality_factor(self) -> float | None: + """Bifaciality factor (0-1). Aliases: ``bifacialityFactor``.""" + return self.raw.get("bifacialityFactor") + + @property + def bifacialityFactor(self) -> float | None: + """Alias for :attr:`bifaciality_factor` (camelCase).""" + return self.bifaciality_factor + + @property + def technol(self) -> str | None: + """Technology type (e.g., ``"monoSi"``).""" + return self.raw.get("technol") + + # --- Inverter fields --- + + @property + def p_nom_conv(self) -> float | None: + """Rated AC power in kW. Aliases: ``pNomConv``.""" + return self.raw.get("pNomConv") + + @property + def pNomConv(self) -> float | None: + """Alias for :attr:`p_nom_conv` (camelCase).""" + return self.p_nom_conv + + @property + def effic_max(self) -> float | None: + """Maximum efficiency. Aliases: ``efficMax``.""" + return self.raw.get("efficMax") + + @property + def efficMax(self) -> float | None: + """Alias for :attr:`effic_max` (camelCase).""" + return self.effic_max + + @property + def v_mpp_min(self) -> float | None: + """Minimum MPPT voltage in V. Aliases: ``vMppMin``.""" + return self.raw.get("vMppMin") + + @property + def vMppMin(self) -> float | None: + """Alias for :attr:`v_mpp_min` (camelCase).""" + return self.v_mpp_min + + @property + def v_mpp_max(self) -> float | None: + """Maximum MPPT voltage in V. Aliases: ``vMppMax``.""" + return self.raw.get("vMppMax") + + @property + def vMppMax(self) -> float | None: + """Alias for :attr:`v_mpp_max` (camelCase).""" + return self.v_mpp_max + + @property + def nb_mppt(self) -> int | None: + """Number of MPPT inputs. Aliases: ``nbMppt``.""" + return self.raw.get("nbMppt") + + @property + def nbMppt(self) -> int | None: + """Alias for :attr:`nb_mppt` (camelCase).""" + return self.nb_mppt + + # --- Dict-like access --- + + def __getitem__(self, key: str) -> object: + """Allow dict-style access: ``item["fileUuid"]``.""" + return self.raw[key] + + def get(self, key: str, default: object = None) -> object: + """Allow dict-style get: ``item.get("pNom")``.""" + return self.raw.get(key, default) + + def __contains__(self, key: str) -> bool: + """Allow ``"fileUuid" in item``.""" + return key in self.raw + + def keys(self): + """Return dict keys.""" + return self.raw.keys() + + def values(self): + """Return dict values.""" + return self.raw.values() + + def items(self): + """Return dict items.""" + return self.raw.items() + + +class RCLCatalogResponse(TypedDict): + """Paginated response from an RCL catalog query. + + Keys + ---- + items : list[dict] + Raw item dicts returned by the API. Fields vary by query and user + permissions. Common fields include ``componentId``, ``fileUuid``, + ``filename``, ``manufacturer``, ``model``. + total : int + Total number of records matching the query (before pagination). + skip : int + Number of records skipped (pagination offset used). + top : int + Page size used in the request. + rate_limit : RCLRateLimitInfo or None + Rate limit status parsed from response headers, or ``None`` if headers + were absent or unparseable. + """ + + items: list[dict] + total: int + skip: int + top: int + rate_limit: RCLRateLimitInfo | None + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + + +def _extract_rate_limit(response: requests.Response) -> RCLRateLimitInfo | None: + """ + Parse rate limit headers from an RCL response and warn if quota is low. + + Parameters + ---------- + response : requests.Response + A completed HTTP response from the RCL API. + + Returns + ------- + RCLRateLimitInfo or None + Parsed rate limit info, or ``None`` if headers are missing or invalid. + """ + try: + info = RCLRateLimitInfo( + remaining=int(response.headers.get("X-RateLimit-Remaining", 0)), + limit=int(response.headers.get("X-RateLimit-Limit", 0)), + reset_timestamp=int(response.headers.get("X-RateLimit-Reset", 0)), + ) + if info.is_low: + _logger.warning( + "RCL download quota low: %d/%d remaining (%.1f%% used). Resets %s", + info.remaining, + info.limit, + info.usage_percent, + info.reset_datetime, + ) + return info + except (TypeError, ValueError): + return None + + +def _build_query_params( + top: int = 25, + skip: int = 0, + order_by: str | None = None, + order_dir: str = "ASC", + output_parameter: list[str] | None = None, + **filters: object, +) -> dict: + """ + Build a query-parameter dict for an RCL catalog request. + + Filter keyword arguments use the pattern ``field=value``, + ``field_contains=value``, ``field_gte=value``, etc., which are + converted to the dot-notation form expected by the RCL API + (e.g. ``filter.pNom.gte=400``). + + Parameters + ---------- + top : int + Page size. Default 25. + skip : int + Pagination offset. Default 0. + order_by : str, optional + Field name to sort by. + order_dir : str + Sort direction: ``"ASC"`` or ``"DESC"``. Default ``"ASC"``. + output_parameter : list[str], optional + Specific fields to return, reducing response payload size. + **filters + Filter keyword arguments. Supported operator suffixes: + ``contains``, ``gt``, ``gte``, ``lt``, ``lte``. A bare field name + (no suffix) is treated as an equality filter. + + Returns + ------- + dict + Query parameters dict ready to pass to ``requests``. + """ + params: dict = {"top": top, "skip": skip} + + if order_by: + params["orderBy"] = order_by + params["orderDir"] = order_dir + + if output_parameter: + params["outputParameter"] = ",".join(output_parameter) + + _operators = {"contains", "gt", "gte", "lt", "lte"} + for key, value in filters.items(): + if value is None: + continue + parts = key.rsplit("_", 1) + if len(parts) == 2 and parts[1] in _operators: + field, op = parts + # Convert snake_case field to camelCase for API + api_field = _FIELD_MAP.get(field, field) + params[f"filter.{api_field}.{op}"] = value + else: + # Equality filter - also map field name + api_field = _FIELD_MAP.get(key, key) + params[f"filter.{api_field}"] = value + + return params + + +def _catalog_request( + endpoint: str, + query_params: dict, + api_key: str | None, +) -> RCLCatalogResponse: + """Execute an RCL catalog GET request and return a typed response dict.""" + client = RCLClient() + response = client.get(endpoint, params=query_params, api_key=api_key) + + if not response.ok: + raise SolarFarmerAPIError( + response.status_code, + f"RCL catalog request failed: HTTP {response.status_code}", + ) + + data = response.json() + rate_limit = _extract_rate_limit(response) + + return RCLCatalogResponse( + items=data.get("items", []), + total=data.get("total", 0), + skip=data.get("skip", query_params.get("skip", 0)), + top=data.get("top", query_params.get("top", 25)), + rate_limit=rate_limit, + ) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def list_modules( + *, + api_key: str | None = None, + top: int = 25, + skip: int = 0, + order_by: str | None = None, + order_dir: str = "ASC", + output_parameter: list[str] | None = None, + manufacturer: str | None = None, + manufacturer_contains: str | None = None, + model: str | None = None, + model_contains: str | None = None, + p_nom_gte: float | None = None, + p_nom_lte: float | None = None, + bifaciality_factor_gte: float | None = None, + technol: str | None = None, + lifecycle_status: str | None = None, + verbose: bool = True, + **kwargs: object, +) -> RCLCatalogResponse: + """ + List PV modules from the Renewable Component Library. + + Parameters + ---------- + api_key : str, optional + API token. Defaults to the ``SF_API_KEY`` environment variable. + top : int + Page size (maximum 10000). Default 25. + skip : int + Pagination offset. Default 0. + order_by : str, optional + Field to sort by (e.g. ``"pNom"``, ``"manufacturer"``). + order_dir : str + Sort direction: ``"ASC"`` or ``"DESC"``. Default ``"ASC"``. + output_parameter : list[str], optional + Fields to include in the response. Reduces payload size. + manufacturer : str, optional + Exact manufacturer name match. + manufacturer_contains : str, optional + Manufacturer name contains substring. + model : str, optional + Exact model name match. + model_contains : str, optional + Model name contains substring. + p_nom_gte : float, optional + Minimum nominal power (W). + p_nom_lte : float, optional + Maximum nominal power (W). + bifaciality_factor_gte : float, optional + Minimum bifaciality factor (use to filter for bifacial modules). + technol : str, optional + Technology type (e.g. ``"monoSi"``). + lifecycle_status : str, optional + Lifecycle status (e.g. ``"active"``). + verbose : bool + If ``True`` (default), prints a summary line after the request + (e.g. ``"INFO: 42 modules found, retrieved 10."``). Set to ``False`` to suppress. + **kwargs + Additional filter parameters for forward compatibility. + Use ``field_operator=value`` syntax or raw ``filter.field.op=value`` keys. + + Returns + ------- + RCLCatalogResponse + Dict with keys ``items``, ``total``, ``skip``, ``top``, ``rate_limit``. + + Raises + ------ + SolarFarmerAPIError + If the API returns a non-2xx response. + ValueError + If no API key is found. + + Examples + -------- + >>> result = sf.rcl.list_modules( + ... manufacturer_contains="Canadian", + ... p_nom_gte=600, + ... output_parameter=["pNom", "bifacialityFactor"], + ... top=10, + ... order_by="pNom", + ... order_dir="DESC", + ... ) + >>> for item in result["items"]: + ... print(f"{item['manufacturer']} {item['model']}: {item.get('pNom')}W") + """ + params = _build_query_params( + top=top, + skip=skip, + order_by=order_by, + order_dir=order_dir, + output_parameter=output_parameter, + manufacturer=manufacturer, + manufacturer_contains=manufacturer_contains, + model=model, + model_contains=model_contains, + p_nom_gte=p_nom_gte, + p_nom_lte=p_nom_lte, + bifaciality_factor_gte=bifaciality_factor_gte, + technol=technol, + lifecycle_status=lifecycle_status, + **kwargs, + ) + result = _catalog_request("catalog/modules", params, api_key) + if verbose: + print(f"INFO: {result['total']} modules found, retrieved {len(result['items'])}.") + return result + + +def list_inverters( + *, + api_key: str | None = None, + top: int = 25, + skip: int = 0, + order_by: str | None = None, + order_dir: str = "ASC", + output_parameter: list[str] | None = None, + manufacturer: str | None = None, + manufacturer_contains: str | None = None, + model: str | None = None, + model_contains: str | None = None, + p_nom_conv_gte: float | None = None, + p_nom_conv_lte: float | None = None, + effic_max_gte: float | None = None, + v_mpp_min_lte: float | None = None, + v_mpp_max_gte: float | None = None, + nb_mppt_gte: int | None = None, + transfo: str | None = None, + lifecycle_status: str | None = None, + verbose: bool = True, + **kwargs: object, +) -> RCLCatalogResponse: + """ + List inverters from the Renewable Component Library. + + Parameters + ---------- + api_key : str, optional + API token. Defaults to the ``SF_API_KEY`` environment variable. + top : int + Page size (maximum 10000). Default 25. + skip : int + Pagination offset. Default 0. + order_by : str, optional + Field to sort by (e.g. ``"pNomConv"``, ``"manufacturer"``). + order_dir : str + Sort direction: ``"ASC"`` or ``"DESC"``. Default ``"ASC"``. + output_parameter : list[str], optional + Fields to include in the response. Reduces payload size. + manufacturer : str, optional + Exact manufacturer name match. + manufacturer_contains : str, optional + Manufacturer name contains substring. + model : str, optional + Exact model name match. + model_contains : str, optional + Model name contains substring. + p_nom_conv_gte : float, optional + Minimum rated AC power (W). + p_nom_conv_lte : float, optional + Maximum rated AC power (W). + effic_max_gte : float, optional + Minimum maximum efficiency (fraction, e.g. ``0.98``). + v_mpp_min_lte : float, optional + Maximum lower MPPT voltage bound (V). + v_mpp_max_gte : float, optional + Minimum upper MPPT voltage bound (V). + nb_mppt_gte : int, optional + Minimum number of MPPT inputs. + transfo : str, optional + Transformer type (e.g. ``"transformerless"``). + lifecycle_status : str, optional + Lifecycle status (e.g. ``"active"``). + verbose : bool + If ``True`` (default), prints a summary line after the request + (e.g. ``"INFO: 42 inverters found, retrieved 10."``). Set to ``False`` to suppress. + **kwargs + Additional filter parameters for forward compatibility. + + Returns + ------- + RCLCatalogResponse + Dict with keys ``items``, ``total``, ``skip``, ``top``, ``rate_limit``. + + Raises + ------ + SolarFarmerAPIError + If the API returns a non-2xx response. + ValueError + If no API key is found. + + Examples + -------- + >>> result = sf.rcl.list_inverters( + ... manufacturer_contains="SMA", + ... p_nom_conv_gte=100000, + ... nb_mppt_gte=2, + ... top=10, + ... ) + >>> for item in result["items"]: + ... print(f"{item['manufacturer']} {item['model']}") + """ + params = _build_query_params( + top=top, + skip=skip, + order_by=order_by, + order_dir=order_dir, + output_parameter=output_parameter, + manufacturer=manufacturer, + manufacturer_contains=manufacturer_contains, + model=model, + model_contains=model_contains, + p_nom_conv_gte=p_nom_conv_gte, + p_nom_conv_lte=p_nom_conv_lte, + effic_max_gte=effic_max_gte, + v_mpp_min_lte=v_mpp_min_lte, + v_mpp_max_gte=v_mpp_max_gte, + nb_mppt_gte=nb_mppt_gte, + transfo=transfo, + lifecycle_status=lifecycle_status, + **kwargs, + ) + result = _catalog_request("catalog/inverters", params, api_key) + if verbose: + print(f"INFO: {result['total']} inverters found, retrieved {len(result['items'])}.") + return result + + +def download_file( + file_uuid: str, + filename: str, + *, + save_to_file: bool = True, + directory_path: str | Path | None = None, + file_path: str | Path | None = None, + use_cache: bool = True, + api_key: str | None = None, +) -> bytes: + """ + Download a PAN or OND file from the Renewable Component Library. + + If the file already exists at the target location and ``use_cache=True``, + the local file is returned without making an API call (saving your quota). + + .. warning:: + Each download counts against your monthly quota. Check + :func:`get_rate_limit_status` before bulk downloads. + + Parameters + ---------- + file_uuid : str + The ``fileUuid`` value from a catalog query result item. + filename : str + Original filename (used when saving to a directory). + save_to_file : bool + Whether to write the content to disk. Default ``True``. + directory_path : str or Path, optional + Directory in which to save the file using ``filename``. + Defaults to the current working directory when ``save_to_file=True`` + and ``file_path`` is not given. + file_path : str or Path, optional + Full destination path including filename. Overrides ``directory_path``. + use_cache : bool + If ``True`` (default) and the file already exists at the target path, + return its contents without downloading. Set to ``False`` to force + re-download. + api_key : str, optional + API token. Defaults to the ``SF_API_KEY`` environment variable. + + Returns + ------- + bytes + Raw file content, regardless of whether it was saved to disk. + + Raises + ------ + SolarFarmerAPIError + If the API returns a non-2xx response. + ValueError + If no API key is found. + + Examples + -------- + >>> item = result["items"][0] + >>> content = sf.rcl.download_file( + ... item["fileUuid"], + ... item["filename"], + ... directory_path="./equipment/", + ... ) + """ + # Determine destination path for cache check + if file_path is not None: + dest = Path(file_path) + elif directory_path is not None: + dest = Path(directory_path) / filename + elif save_to_file: + dest = Path.cwd() / filename + else: + dest = None + + # Check local cache + if use_cache and dest is not None and dest.exists(): + _logger.info("Using cached file: %s (skipping download)", dest) + return dest.read_bytes() + + # Download from API + client = RCLClient() + response = client.get(f"catalog/{file_uuid}", api_key=api_key) + + if not response.ok: + raise SolarFarmerAPIError( + response.status_code, + f"RCL file download failed: HTTP {response.status_code}", + ) + + content = response.content + _extract_rate_limit(response) + + if save_to_file and dest is not None: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(content) + _logger.info("Saved RCL file to %s", dest) + + return content + + +def get_rate_limit_status(api_key: str | None = None) -> RCLRateLimitInfo: + """ + Return current RCL rate limit status without consuming a download. + + Uses ``GET /catalog/modules?top=0``, which returns an empty page but + includes the rate-limit headers. + + Parameters + ---------- + api_key : str, optional + API token. Defaults to the ``SF_API_KEY`` environment variable. + + Returns + ------- + RCLRateLimitInfo + Current rate limit status. + + Raises + ------ + SolarFarmerAPIError + If the API returns a non-2xx response or rate limit headers are missing. + ValueError + If no API key is found. + + Examples + -------- + >>> status = sf.rcl.get_rate_limit_status() + >>> print(f"{status.remaining}/{status.limit} downloads remaining") + >>> print(f"Resets: {status.reset_datetime}") + >>> if status.is_low: + ... print("Warning: running low on downloads!") + """ + client = RCLClient() + response = client.get("catalog/modules", params={"top": 0}, api_key=api_key) + + if not response.ok: + raise SolarFarmerAPIError( + response.status_code, + f"RCL rate limit check failed: HTTP {response.status_code}", + ) + + rate_limit = _extract_rate_limit(response) + if rate_limit is None: + raise SolarFarmerAPIError(500, "Could not parse rate limit headers from RCL response") + return rate_limit diff --git a/tests/test_rcl.py b/tests/test_rcl.py new file mode 100644 index 0000000..5f5270b --- /dev/null +++ b/tests/test_rcl.py @@ -0,0 +1,658 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +import solarfarmer.rcl as rcl +from solarfarmer.rcl import ( + RCLCatalogItem, + RCLCatalogResponse, + RCLRateLimitInfo, + _build_query_params, + _extract_rate_limit, +) + +# --------------------------------------------------------------------------- +# Unit tests +# --------------------------------------------------------------------------- + + +class TestQueryBuilder: + """Test _build_query_params query parameter construction.""" + + def test_simple_filter(self): + params = _build_query_params(manufacturer="Canadian Solar") + assert params["filter.manufacturer"] == "Canadian Solar" + + def test_contains_operator(self): + params = _build_query_params(model_contains="715TB") + assert params["filter.model.contains"] == "715TB" + + def test_gte_operator(self): + params = _build_query_params(p_nom_gte=400) + assert params["filter.pNom.gte"] == 400 + + def test_lte_operator(self): + params = _build_query_params(p_nom_lte=800) + assert params["filter.pNom.lte"] == 800 + + def test_gt_lt_operators(self): + params = _build_query_params(effic_max_gt=0.97, effic_max_lt=0.99) + assert params["filter.efficMax.gt"] == 0.97 + assert params["filter.efficMax.lt"] == 0.99 + + def test_multiple_filters_combined(self): + params = _build_query_params( + manufacturer_contains="SMA", + p_nom_conv_gte=100000, + p_nom_conv_lte=200000, + ) + assert params["filter.manufacturer.contains"] == "SMA" + assert params["filter.pNomConv.gte"] == 100000 + assert params["filter.pNomConv.lte"] == 200000 + + def test_none_filters_omitted(self): + params = _build_query_params(manufacturer=None, model_contains="X") + assert "filter.manufacturer" not in params + assert params["filter.model.contains"] == "X" + + def test_pagination_defaults(self): + params = _build_query_params() + assert params["top"] == 25 + assert params["skip"] == 0 + + def test_pagination_custom(self): + params = _build_query_params(top=100, skip=50) + assert params["top"] == 100 + assert params["skip"] == 50 + + def test_ordering(self): + params = _build_query_params(order_by="pNom", order_dir="DESC") + assert params["orderBy"] == "pNom" + assert params["orderDir"] == "DESC" + + def test_ordering_omitted_when_no_field(self): + params = _build_query_params() + assert "orderBy" not in params + assert "orderDir" not in params + + def test_output_parameter_joined(self): + params = _build_query_params(output_parameter=["pNom", "voc", "isc"]) + assert params["outputParameter"] == "pNom,voc,isc" + + def test_output_parameter_single(self): + params = _build_query_params(output_parameter=["pNom"]) + assert params["outputParameter"] == "pNom" + + def test_output_parameter_omitted_when_none(self): + params = _build_query_params(output_parameter=None) + assert "outputParameter" not in params + + def test_kwargs_passthrough_raw_key(self): + # Raw dot-notation keys (e.g. future RCL filters) pass through unchanged + params = _build_query_params(**{"filter.someNewField.gte": 100}) + assert params["filter.filter.someNewField.gte"] == 100 # wrapped because no operator suffix + + def test_kwargs_passthrough_operator_suffix(self): + params = _build_query_params(someNewField_gte=42) + assert params["filter.someNewField.gte"] == 42 + + +class TestRateLimitInfo: + """Test RCLRateLimitInfo dataclass properties.""" + + RESET_TS = 1755043200 # a fixed future Unix timestamp + + def _make(self, remaining=85, limit=100, reset=None) -> RCLRateLimitInfo: + return RCLRateLimitInfo( + remaining=remaining, + limit=limit, + reset_timestamp=reset or self.RESET_TS, + ) + + def test_reset_datetime_is_utc(self): + info = self._make() + dt = info.reset_datetime + assert isinstance(dt, datetime) + assert dt.tzinfo == timezone.utc + + def test_reset_datetime_value(self): + # Unix epoch 0 should map to 1970-01-01T00:00:00Z + info = RCLRateLimitInfo(remaining=50, limit=100, reset_timestamp=0) + assert info.reset_datetime == datetime(1970, 1, 1, tzinfo=timezone.utc) + + def test_usage_percent_normal(self): + info = self._make(remaining=75, limit=100) + assert info.usage_percent == pytest.approx(25.0) + + def test_usage_percent_full(self): + info = self._make(remaining=0, limit=100) + assert info.usage_percent == pytest.approx(100.0) + + def test_usage_percent_zero_limit(self): + info = self._make(remaining=0, limit=0) + assert info.usage_percent == 100.0 + + def test_is_low_false_above_threshold(self): + info = self._make(remaining=20, limit=100) # 20% remaining > 15% threshold + assert info.is_low is False + + def test_is_low_true_at_threshold(self): + info = self._make(remaining=14, limit=100) # 14% remaining < 15% threshold + assert info.is_low is True + + def test_is_low_true_just_below_boundary(self): + # 14 < 15.0 is True + info = self._make(remaining=14, limit=100) + assert info.is_low is True + + def test_str_shows_remaining_and_limit(self): + info = self._make(remaining=42, limit=100) + s = str(info) + assert "42/100" in s + + def test_str_shows_reset_datetime(self): + info = self._make() + s = str(info) + assert "resets" in s.lower() + + +class TestExtractRateLimit: + """Test _extract_rate_limit header parsing.""" + + def _mock_response(self, headers: dict) -> MagicMock: + response = MagicMock() + response.headers = headers + return response + + def test_parses_all_headers(self): + response = self._mock_response( + { + "X-RateLimit-Remaining": "85", + "X-RateLimit-Limit": "100", + "X-RateLimit-Reset": "1755043200", + } + ) + info = _extract_rate_limit(response) + assert info is not None + assert info.remaining == 85 + assert info.limit == 100 + assert info.reset_timestamp == 1755043200 + + def test_returns_none_on_missing_headers(self): + response = self._mock_response({}) + # Missing headers default to "0" strings — this should parse to 0/0/0, not None + info = _extract_rate_limit(response) + assert info is not None + assert info.remaining == 0 + + def test_returns_none_on_invalid_header_value(self): + response = self._mock_response( + { + "X-RateLimit-Remaining": "not-a-number", + "X-RateLimit-Limit": "100", + "X-RateLimit-Reset": "1755043200", + } + ) + info = _extract_rate_limit(response) + assert info is None + + def test_warns_when_quota_low(self, caplog): + import logging + + response = self._mock_response( + { + "X-RateLimit-Remaining": "5", + "X-RateLimit-Limit": "100", + "X-RateLimit-Reset": "1755043200", + } + ) + with caplog.at_level(logging.WARNING): + _extract_rate_limit(response) + assert any( + "low" in r.message.lower() or "quota" in r.message.lower() for r in caplog.records + ) + + +class TestCatalogResponse: + """Test RCLCatalogResponse TypedDict shape (structural, not strict).""" + + def _make_response(self, n_items=2) -> RCLCatalogResponse: + return RCLCatalogResponse( + items=[{"manufacturer": "A", "model": f"M{i}"} for i in range(n_items)], + total=n_items, + skip=0, + top=25, + rate_limit=None, + ) + + def test_response_has_required_keys(self): + r = self._make_response() + assert "items" in r + assert "total" in r + assert "skip" in r + assert "top" in r + assert "rate_limit" in r + + def test_items_are_plain_dicts(self): + r = self._make_response(n_items=3) + assert isinstance(r["items"], list) + for item in r["items"]: + assert isinstance(item, dict) + + def test_rate_limit_can_be_none(self): + r = self._make_response() + assert r["rate_limit"] is None + + def test_rate_limit_can_be_info_object(self): + info = RCLRateLimitInfo(remaining=90, limit=100, reset_timestamp=1755043200) + r = self._make_response() + r["rate_limit"] = info + assert isinstance(r["rate_limit"], RCLRateLimitInfo) + + def test_total_reflects_item_count(self): + r = self._make_response(n_items=5) + assert r["total"] == 5 + assert len(r["items"]) == 5 + + +class TestCatalogItem: + """Test RCLCatalogItem typed wrapper for catalog items.""" + + @pytest.fixture + def module_dict(self) -> dict: + """Sample module item dict as returned by the API.""" + return { + "componentId": "MOD-12345", + "fileUuid": "abc-123-def-456", + "filename": "CS7N-715TB-AG.PAN", + "manufacturer": "Canadian Solar Inc.", + "model": "CS7N-715TB-AG", + "pNom": 715, + "bifacialityFactor": 0.7, + "technol": "monoSi", + } + + @pytest.fixture + def inverter_dict(self) -> dict: + """Sample inverter item dict as returned by the API.""" + return { + "componentId": "INV-67890", + "fileUuid": "xyz-789-uvw-012", + "filename": "SG250HX.OND", + "manufacturer": "Sungrow", + "model": "SG250HX", + "pNomConv": 250, + "efficMax": 98.7, + "vMppMin": 500, + "vMppMax": 1500, + "nbMppt": 12, + } + + def test_snake_case_properties_module(self, module_dict): + item = RCLCatalogItem(module_dict) + assert item.file_uuid == "abc-123-def-456" + assert item.filename == "CS7N-715TB-AG.PAN" + assert item.manufacturer == "Canadian Solar Inc." + assert item.model == "CS7N-715TB-AG" + assert item.component_id == "MOD-12345" + assert item.p_nom == 715 + assert item.bifaciality_factor == 0.7 + assert item.technol == "monoSi" + + def test_camelcase_aliases_module(self, module_dict): + item = RCLCatalogItem(module_dict) + assert item.fileUuid == item.file_uuid + assert item.componentId == item.component_id + assert item.pNom == item.p_nom + assert item.bifacialityFactor == item.bifaciality_factor + + def test_snake_case_properties_inverter(self, inverter_dict): + item = RCLCatalogItem(inverter_dict) + assert item.p_nom_conv == 250 + assert item.effic_max == 98.7 + assert item.v_mpp_min == 500 + assert item.v_mpp_max == 1500 + assert item.nb_mppt == 12 + + def test_camelcase_aliases_inverter(self, inverter_dict): + item = RCLCatalogItem(inverter_dict) + assert item.pNomConv == item.p_nom_conv + assert item.efficMax == item.effic_max + assert item.vMppMin == item.v_mpp_min + assert item.vMppMax == item.v_mpp_max + assert item.nbMppt == item.nb_mppt + + def test_dict_style_getitem(self, module_dict): + item = RCLCatalogItem(module_dict) + assert item["fileUuid"] == "abc-123-def-456" + assert item["pNom"] == 715 + + def test_dict_style_get(self, module_dict): + item = RCLCatalogItem(module_dict) + assert item.get("fileUuid") == "abc-123-def-456" + assert item.get("missing", "default") == "default" + + def test_dict_style_contains(self, module_dict): + item = RCLCatalogItem(module_dict) + assert "fileUuid" in item + assert "missing" not in item + + def test_dict_style_keys_values_items(self, module_dict): + item = RCLCatalogItem(module_dict) + assert "fileUuid" in item.keys() + assert "abc-123-def-456" in item.values() + assert ("fileUuid", "abc-123-def-456") in item.items() + + def test_raw_attribute(self, module_dict): + item = RCLCatalogItem(module_dict) + assert item.raw is module_dict + + def test_missing_optional_fields_return_none(self): + minimal_dict = { + "fileUuid": "uuid-1", + "filename": "test.PAN", + "manufacturer": "Test", + "model": "Model1", + } + item = RCLCatalogItem(minimal_dict) + assert item.p_nom is None + assert item.bifaciality_factor is None + assert item.p_nom_conv is None + assert item.effic_max is None + + def test_empty_string_for_missing_required(self): + empty_dict = {} + item = RCLCatalogItem(empty_dict) + assert item.file_uuid == "" + assert item.filename == "" + assert item.manufacturer == "" + assert item.model == "" + + +# --------------------------------------------------------------------------- +# Unit tests for list_modules / list_inverters (mocked HTTP) +# --------------------------------------------------------------------------- + + +def _fake_catalog_response(items: list[dict], total: int | None = None) -> MagicMock: + """Return a mock requests.Response that mimics an RCL catalog JSON response.""" + response = MagicMock() + response.ok = True + response.json.return_value = { + "items": items, + "total": total if total is not None else len(items), + "skip": 0, + "top": 25, + } + response.headers = { + "X-RateLimit-Remaining": "90", + "X-RateLimit-Limit": "100", + "X-RateLimit-Reset": "1755043200", + } + return response + + +class TestListModulesUnit: + """Unit tests for list_modules with mocked RCLClient.""" + + @patch("solarfarmer.rcl.RCLClient") + def test_returns_catalog_response(self, MockClient): + MockClient.return_value.get.return_value = _fake_catalog_response( + [{"manufacturer": "LONGi", "model": "Hi-MO X6", "pNom": 620}] + ) + result = rcl.list_modules(manufacturer_contains="LONGi") + assert result["total"] == 1 + assert result["items"][0]["manufacturer"] == "LONGi" + assert isinstance(result["rate_limit"], RCLRateLimitInfo) + + @patch("solarfarmer.rcl.RCLClient") + def test_passes_filter_params(self, MockClient): + mock_get = MockClient.return_value.get + mock_get.return_value = _fake_catalog_response([]) + rcl.list_modules(manufacturer_contains="SMA", p_nom_gte=400, top=10) + _, kwargs = mock_get.call_args + params = kwargs["params"] + assert params["filter.manufacturer.contains"] == "SMA" + assert params["filter.pNom.gte"] == 400 + assert params["top"] == 10 + + @patch("solarfarmer.rcl.RCLClient") + def test_raises_on_non_2xx(self, MockClient): + from solarfarmer.api import SolarFarmerAPIError + + response = MagicMock() + response.ok = False + response.status_code = 401 + MockClient.return_value.get.return_value = response + with pytest.raises(SolarFarmerAPIError): + rcl.list_modules() + + +class TestListInvertersUnit: + """Unit tests for list_inverters with mocked RCLClient.""" + + @patch("solarfarmer.rcl.RCLClient") + def test_returns_catalog_response(self, MockClient): + MockClient.return_value.get.return_value = _fake_catalog_response( + [{"manufacturer": "SMA", "model": "STP 110-60", "pNomConv": 110000}] + ) + result = rcl.list_inverters(manufacturer_contains="SMA") + assert result["total"] == 1 + assert result["items"][0]["model"] == "STP 110-60" + + @patch("solarfarmer.rcl.RCLClient") + def test_passes_inverter_filter_params(self, MockClient): + mock_get = MockClient.return_value.get + mock_get.return_value = _fake_catalog_response([]) + rcl.list_inverters(effic_max_gte=0.98, nb_mppt_gte=2, top=5) + _, kwargs = mock_get.call_args + params = kwargs["params"] + assert params["filter.efficMax.gte"] == 0.98 + assert params["filter.nbMppt.gte"] == 2 + + +class TestDownloadFileUnit: + """Unit tests for download_file with mocked RCLClient.""" + + @patch("solarfarmer.rcl.RCLClient") + def test_returns_bytes(self, MockClient, tmp_path): + response = MagicMock() + response.ok = True + response.content = b"FAKE_PAN_CONTENT" + response.headers = {} + MockClient.return_value.get.return_value = response + + content = rcl.download_file( + "some-uuid", + "module.PAN", + directory_path=tmp_path, + ) + assert content == b"FAKE_PAN_CONTENT" + + @patch("solarfarmer.rcl.RCLClient") + def test_saves_to_directory(self, MockClient, tmp_path): + response = MagicMock() + response.ok = True + response.content = b"DATA" + response.headers = {} + MockClient.return_value.get.return_value = response + + rcl.download_file("uuid", "module.PAN", directory_path=tmp_path) + assert (tmp_path / "module.PAN").exists() + + @patch("solarfarmer.rcl.RCLClient") + def test_saves_to_file_path(self, MockClient, tmp_path): + response = MagicMock() + response.ok = True + response.content = b"DATA" + response.headers = {} + MockClient.return_value.get.return_value = response + + dest = tmp_path / "custom_name.PAN" + rcl.download_file("uuid", "original.PAN", file_path=dest) + assert dest.exists() + + @patch("solarfarmer.rcl.RCLClient") + def test_no_disk_write_when_save_false(self, MockClient, tmp_path): + response = MagicMock() + response.ok = True + response.content = b"DATA" + response.headers = {} + MockClient.return_value.get.return_value = response + + content = rcl.download_file("uuid", "module.PAN", save_to_file=False) + assert content == b"DATA" + assert not any(tmp_path.iterdir()) # nothing written + + +class TestGetRateLimitStatusUnit: + """Unit tests for get_rate_limit_status with mocked RCLClient.""" + + @patch("solarfarmer.rcl.RCLClient") + def test_uses_top_zero(self, MockClient): + mock_get = MockClient.return_value.get + response = MagicMock() + response.ok = True + response.headers = { + "X-RateLimit-Remaining": "80", + "X-RateLimit-Limit": "100", + "X-RateLimit-Reset": "1755043200", + } + mock_get.return_value = response + + status = rcl.get_rate_limit_status() + _, kwargs = mock_get.call_args + assert kwargs["params"] == {"top": 0} + assert status.remaining == 80 + assert status.limit == 100 + + @patch("solarfarmer.rcl.RCLClient") + def test_raises_when_headers_missing(self, MockClient): + from solarfarmer.api import SolarFarmerAPIError + + response = MagicMock() + response.ok = True + # Simulate completely absent rate-limit headers with invalid values + response.headers = { + "X-RateLimit-Remaining": "bad", + "X-RateLimit-Limit": "100", + "X-RateLimit-Reset": "1755043200", + } + MockClient.return_value.get.return_value = response + with pytest.raises(SolarFarmerAPIError): + rcl.get_rate_limit_status() + + +# --------------------------------------------------------------------------- +# Integration tests — require SF_API_KEY, auto-skip when absent +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestRCLIntegration: + """Integration tests that call the real RCL API.""" + + def test_list_modules_basic(self, api_key): + result = rcl.list_modules(top=5, api_key=api_key) + assert "items" in result + assert isinstance(result["items"], list) + assert "total" in result + assert result["total"] >= 0 + + def test_list_modules_with_manufacturer_filter(self, api_key): + result = rcl.list_modules( + manufacturer_contains="LONGi", + top=5, + api_key=api_key, + ) + for item in result["items"]: + assert "longi" in item.get("manufacturer", "").lower() + + def test_list_modules_with_power_range(self, api_key): + result = rcl.list_modules( + p_nom_gte=600, + p_nom_lte=650, + output_parameter=["pNom", "manufacturer", "model"], + top=10, + api_key=api_key, + ) + for item in result["items"]: + pnom = item.get("pNom") + if pnom is not None: + assert 600 <= pnom <= 650 + + def test_list_modules_output_parameter_filters_fields(self, api_key): + result = rcl.list_modules( + output_parameter=["pNom", "manufacturer"], + top=3, + api_key=api_key, + ) + for item in result["items"]: + # Only requested fields should be present (plus always-present identity fields) + assert "pNom" in item or "manufacturer" in item + + def test_list_inverters_basic(self, api_key): + result = rcl.list_inverters(top=5, api_key=api_key) + assert "items" in result + assert isinstance(result["items"], list) + + def test_list_inverters_with_filter(self, api_key): + result = rcl.list_inverters( + manufacturer_contains="SMA", + top=5, + api_key=api_key, + ) + for item in result["items"]: + assert "sma" in item.get("manufacturer", "").lower() + + def test_get_rate_limit_status(self, api_key): + status = rcl.get_rate_limit_status(api_key=api_key) + assert isinstance(status, RCLRateLimitInfo) + assert status.limit >= 0 + assert status.remaining >= 0 + assert status.remaining <= status.limit + + def test_kwargs_passthrough(self, api_key): + # Unknown kwargs should not cause a crash — API ignores unrecognised params + result = rcl.list_modules( + top=1, + api_key=api_key, + lifecycle_status="active", + ) + assert "items" in result + + @pytest.mark.skip(reason="Preserves monthly download quota") + def test_download_file(self, api_key, tmp_path): + result = rcl.list_modules(top=1, api_key=api_key) + items = result["items"] + if not items: + pytest.skip("No modules returned by catalog") + item = items[0] + content = rcl.download_file( + item["fileUuid"], + item["filename"], + directory_path=tmp_path, + api_key=api_key, + ) + assert isinstance(content, bytes) + assert len(content) > 0 + assert (tmp_path / item["filename"]).exists() + + @pytest.mark.skip(reason="Preserves monthly download quota") + def test_download_file_memory_only(self, api_key): + result = rcl.list_modules(top=1, api_key=api_key) + items = result["items"] + if not items: + pytest.skip("No modules returned by catalog") + item = items[0] + content = rcl.download_file( + item["fileUuid"], + item["filename"], + save_to_file=False, + api_key=api_key, + ) + assert isinstance(content, bytes) + assert len(content) > 0