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..7881e89 --- /dev/null +++ b/docs/getting-started/rcl-component-library.md @@ -0,0 +1,426 @@ +--- +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.usage_percent > 80: + 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 + +!!! warning "Each Download Counts" + Each call to `download_file()` consumes one credit from your monthly quota, + even if you already downloaded the same file before. The SDK does not cache + files locally. If you want to avoid re-downloading, check whether the + destination path already exists yourself before calling `download_file()`. + +### 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 +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 +``` + +### Avoiding Duplicate Downloads + +Since the SDK does not cache files, check the destination yourself if you want +to skip files you already have: + +```python +from pathlib import Path + +dest = Path("./equipment/") / item["filename"] +if not dest.exists(): + sf.rcl.download_file(item["fileUuid"], item["filename"], directory_path="./equipment/") +``` + +--- + +## 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..e28e2d7 --- /dev/null +++ b/docs/notebooks/Example_RCL_Catalog.ipynb @@ -0,0 +1,1270 @@ +{ + "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": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 84 remaining (16.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Downloads remaining: 84/100\n", + "Usage: 16.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.usage_percent > 80:\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": 5, + "id": "e5ca740d", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 84 remaining (16.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 1197 modules found, retrieved 5.\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: 15.6 ms\n", + "Wall time: 1.48 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\"\\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": 6, + "id": "9268e3fc", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 84 remaining (16.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "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": 7, + "id": "8a9ca2bd", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 84 remaining (16.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "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": 8, + "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": 9, + "id": "a5e37e67", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 84 remaining (16.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "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": 10, + "id": "91384174", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 84 remaining (16.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "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": 11, + "id": "6832d9e3", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 84 remaining (16.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "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": 12, + "id": "dc65e197", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 84 remaining (16.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "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": 14, + "id": "4a26a0ca", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 84 remaining (16.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + } + ], + "source": [ + "# Check rate limit before downloading\n", + "status = sf.rcl.get_rate_limit_status(api_key=api_key)\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": null, + "id": "180f25cb", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 84 remaining (16.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "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: RCL download quota: 83 remaining (17.0% used). Resets 2026-09-04 00:00:00+00:00\n", + "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 Avoiding duplicate downloads\n", + "\n", + "`download_file` does **not** cache locally — every call consumes one credit from your monthly quota, even if you already downloaded the same file before.\n", + "\n", + "If you want to avoid re-downloading a file you already have (e.g., re-running a workflow in a loop), check whether the destination path already exists yourself before calling `download_file`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "06652656", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 83 remaining (17.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 1 modules found, retrieved 1.\n", + "1 modules found, retrieved 1.\n", + "Already have rcl_downloads\\CanadianSolar_CS7N-715TB-AG.PAN, skipping download to save quota.\n" + ] + } + ], + "source": [ + "# Example: Skip a download if the file already exists locally (DISABLED by default - remove 'if False:' to run)\n", + "if False:\n", + " from pathlib import Path\n", + "\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", + " dest = Path(\"./rcl_downloads/\") / item.filename\n", + "\n", + " if dest.exists():\n", + " print(f\"Already have {dest}, skipping download to save quota.\")\n", + " else:\n", + " print(f\"Downloading: {item.filename}\")\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 Handling 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": null, + "id": "ebbb419a", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 83 remaining (17.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "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 on every call — there is no local caching, so re-running with the same filters will download the file again.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "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": null, + "id": "adbb68fb", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 83 remaining (17.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 1 modules found, retrieved 1.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 82 remaining (18.0% used). Resets 2026-09-04 00:00:00+00:00\n", + "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": null, + "id": "53078f21", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 82 remaining (18.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 1 inverters found, retrieved 1.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 81 remaining (19.0% used). Resets 2026-09-04 00:00:00+00:00\n", + "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": 22, + "id": "06d915db", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 81 remaining (19.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "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": 23, + "id": "ce4d2533", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 81 remaining (19.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "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[23]\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:1147\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 1145\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 1146\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m strict:\n\u001b[32m-> \u001b[39m\u001b[32m1147\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(msg)\n\u001b[32m 1148\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 1149\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" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e9a70a33", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 81 remaining (19.0% used). Resets 2026-09-04 00:00:00+00:00\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO: 1 modules found, retrieved 1.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO: RCL download quota: 80 remaining (20.0% used). Resets 2026-09-04 00:00:00+00:00\n", + "INFO: Saved RCL file to rcl_downloads\\CanadianSolar_CS7N-715TB-AG.PAN\n", + "INFO: Module 'CanadianSolar_CS7N-715TB-AG' set from RCL (841 bytes)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "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)", + "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..247f070 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,87 @@ 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), + "User-Agent": "solarfarmer-api-sdk/" + __version__, + } + return requests.get(url, headers=headers, params=params, timeout=self.timeout) + + class Client: """Handles all API requests for the different endpoints.""" @@ -133,16 +216,8 @@ def _check_params(params: dict) -> tuple[dict, str]: params = copy.deepcopy(params) key = params.pop("api_key", API_TOKEN) - - if key is None: - 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(key) <= 1: - raise ValueError("API key is too short.") + # Shared validation rules with RCLClient + _build_auth_headers(key) return params, key @@ -230,7 +305,7 @@ def _make_request( params, key = self._check_params(params) timeout = self._get_timeout(params) headers = { - "Authorization": f"Bearer {key}", + **_build_auth_headers(key), # Content-Type intentionally omitted — requests sets this automatically # based on whether files are present (multipart) or not (form-encoded). # Forcing it here overrides the boundary parameter and breaks multipart uploads. diff --git a/solarfarmer/config.py b/solarfarmer/config.py index 73cc21e..fd003a7 100644 --- a/solarfarmer/config.py +++ b/solarfarmer/config.py @@ -22,6 +22,9 @@ "MODELCHAIN_ASYNC_TIMEOUT_UPLOAD", "MODELCHAIN_ASYNC_POLL_TIME", "PANDAS_INSTALL_MSG", + # RCL + "RCL_BASE_URL", + "RCL_TIMEOUT", ] BASE_API_URL = os.getenv( @@ -53,3 +56,8 @@ PANDAS_INSTALL_MSG = ( "pandas is required for this function. Install it with: pip install 'dnv-solarfarmer[weather]'" ) + +# RCL (Renewable Component Library) configuration +# Kept here (rather than in rcl.py) to avoid circular dependency between api.py's RCLClient and rcl.py. +RCL_BASE_URL = "https://solarfarmer.dnv.com/rcl" +RCL_TIMEOUT = 30 # seconds diff --git a/solarfarmer/models/pvsystem/pvsystem.py b/solarfarmer/models/pvsystem/pvsystem.py index 8d6eb9a..d5532d9 100644 --- a/solarfarmer/models/pvsystem/pvsystem.py +++ b/solarfarmer/models/pvsystem/pvsystem.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any +from ... import rcl from ...logging import get_logger from ..auxiliary_losses import AuxiliaryLosses from ..energy_calculation_inputs import EnergyCalculationInputs @@ -1020,6 +1021,308 @@ 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", + ... ) + """ + _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 (kW). 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", + ... ) + """ + _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:g}kW") + 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..c0b59a0 --- /dev/null +++ b/solarfarmer/rcl.py @@ -0,0 +1,857 @@ +""" +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 TYPE_CHECKING, TypedDict + +from .api import RCLClient, SolarFarmerAPIError, _parse_error_body, map_http_error_to_message +from .logging import get_logger + +if TYPE_CHECKING: + # Only needed for type hints - keeps the `requests` import specific to api.py + import requests + +_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 + + 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) + >>> 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: + """ + Parse rate limit headers from an RCL response and log the current quota. + + Parameters + ---------- + response : requests.Response + A completed HTTP response from the RCL API. + + Returns + ------- + RCLRateLimitInfo + Parsed rate limit info. + + Raises + ------ + SolarFarmerAPIError + If any rate-limit header is missing or cannot be parsed as an integer. + """ + required_headers = ("X-RateLimit-Remaining", "X-RateLimit-Limit", "X-RateLimit-Reset") + missing = [h for h in required_headers if h not in response.headers] + if missing: + raise SolarFarmerAPIError( + response.status_code, + f"RCL response is missing rate-limit header(s): {', '.join(missing)}", + ) + + try: + info = RCLRateLimitInfo( + remaining=int(response.headers["X-RateLimit-Remaining"]), + limit=int(response.headers["X-RateLimit-Limit"]), + reset_timestamp=int(response.headers["X-RateLimit-Reset"]), + ) + except (TypeError, ValueError) as err: + raise SolarFarmerAPIError( + response.status_code, + f"RCL response has invalid rate-limit header value(s): {dict(response.headers)}", + ) from err + + _logger.info( + "RCL download quota: %d remaining (%.1f%% used). Resets %s", + info.remaining, + info.usage_percent, + info.reset_datetime, + ) + return info + + +def _raise_for_status(response: requests.Response) -> None: + """ + Raise a ``SolarFarmerAPIError`` carrying the API's own error message. + + Mirrors the message/problem-details extraction done in + ``Client._make_request`` so RCL errors read the same as other endpoints + instead of a generic "HTTP " string. + + Parameters + ---------- + response : requests.Response + A non-OK HTTP response from the RCL API. + + Raises + ------ + SolarFarmerAPIError + Always - this function only runs when the response is not OK. + """ + text = response.text + message = map_http_error_to_message(response.status_code, text, _parse_error_body(text)) + try: + problem_details_json = response.json() + except Exception: + problem_details_json = None + raise SolarFarmerAPIError(response.status_code, message, problem_details_json) + + +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 + if "." in key: + # Raw dot-notation key (e.g. "filter.someNewField.gte") - a snake_case + # Python identifier can never contain a literal dot, so this can only be + # a pre-formatted key passed through **kwargs. Use it unchanged. + params[key] = value + 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_for_status(response) + + data = response.json() + try: + rate_limit = _extract_rate_limit(response) + except SolarFarmerAPIError as err: + # Rate-limit metadata is secondary to the catalog data itself - don't + # fail the whole search over it, but don't hide the problem either. + _logger.warning("Could not read RCL rate-limit headers: %s", err) + rate_limit = None + + 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 (kW). + p_nom_conv_lte : float, optional + Maximum rated AC power (kW). + 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=100, + ... 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, + api_key: str | None = None, +) -> bytes: + """ + Download a PAN or OND file from the Renewable Component Library. + + .. warning:: + Each call consumes one download against your monthly quota, even if + the file was already downloaded before. Check + :func:`get_rate_limit_status` before bulk downloads, and avoid calling + this function more than once for the same file (e.g. by checking + whether the destination path already exists yourself). + + 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``. + 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/", + ... ) + """ + 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 + + client = RCLClient() + response = client.get(f"catalog/{file_uuid}", api_key=api_key) + + if not response.ok: + _raise_for_status(response) + + content = response.content + try: + _extract_rate_limit(response) + except SolarFarmerAPIError as err: + # The file was downloaded successfully - don't fail the download + # just because the quota headers couldn't be read. + _logger.warning("Could not read RCL rate-limit headers: %s", err) + + 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.usage_percent > 80: + ... 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_for_status(response) + + return _extract_rate_limit(response) diff --git a/tests/test_rcl.py b/tests/test_rcl.py new file mode 100644 index 0000000..bb75e9d --- /dev/null +++ b/tests/test_rcl.py @@ -0,0 +1,645 @@ +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.someNewField.gte"] == 100 + + 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_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_raises_on_missing_headers(self): + from solarfarmer.api import SolarFarmerAPIError + + response = self._mock_response({}) + with pytest.raises(SolarFarmerAPIError, match="missing rate-limit header"): + _extract_rate_limit(response) + + def test_raises_on_invalid_header_value(self): + from solarfarmer.api import SolarFarmerAPIError + + response = self._mock_response( + { + "X-RateLimit-Remaining": "not-a-number", + "X-RateLimit-Limit": "100", + "X-RateLimit-Reset": "1755043200", + } + ) + with pytest.raises(SolarFarmerAPIError, match="invalid rate-limit header"): + _extract_rate_limit(response) + + def test_logs_quota_info(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.INFO): + _extract_rate_limit(response) + assert any("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