Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.0] - 2026-06-29

### Changed

- `PVSystem.lid_loss` default changed from `0.0` to `None`. When `None`, the LIDLoss value is read from the PAN file. If you relied on the old default to override the PAN file, set `plant.lid_loss = 0.0` explicitly.

### Fixed

- `PVSystem.lid_loss` now correctly overrides the PAN file's `LIDLoss` value when explicitly set (previously the PAN file always took precedence).

## [0.4.0] - 2026-06-08

### Added
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "dnv-solarfarmer"
version = "0.4.0"
version = "0.5.0"
description = "Python SDK for SolarFarmer, a bankable solar PV design and energy yield assessment tool by DNV."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
19 changes: 13 additions & 6 deletions solarfarmer/models/pvsystem/pvsystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ class PVSystem:
module_quality_factor: Optional[float] = None
Module quality factor (per unit) (default is 0.0, i.e., no quality loss).
lid_loss: Optional[float] = None
Loss due to light-induced degradation (LID) (per unit) (default is 0.0, i.e., no LID loss).
Loss due to light-induced degradation (LID) (per unit). When set, overrides the
LIDLoss value in the PAN file. When ``None`` (default), the value from the PAN
file is used. If the PAN file also has no LIDLoss key, the SF API defaults to 0.0.
module_iam_model_override: Optional[IAMModelTypeForOverride] = None
Override for the module IAM model used in the calculation (default is None,
which will use the values from the module's PAN file).
Expand Down Expand Up @@ -268,7 +270,7 @@ class PVSystem:
ac_ohmic_loss: float | None = None # Default depends on inverter type
module_mismatch: float | None = MODULE_MISMATCH_FACTOR
module_quality_factor: float | None = 0.0
lid_loss: float | None = 0.0
lid_loss: float | None = None
module_iam_model_override: str | None = None
constant_heat_coefficient: float | None = CONSTANT_HEAT_COEFFICIENT
convective_heat_coefficient: float | None = CONVECTIVE_HEAT_COEFFICIENT
Expand Down Expand Up @@ -1528,11 +1530,16 @@ def generate_pan_file_supplements(
plant: PVSystem, module_info: dict, bifaciality_factor: float
) -> dict[str, Any]:
"""Generate PAN file supplements based on the PVSystem data and module information."""
# LID loss value is taken from the PAN file if available, otherwise from the PVSystem default
try:
lid_loss = float(module_info["data"]["LIDLoss"]) / 100 # Convert percentage to per unit
except KeyError:
# plant.lid_loss takes priority when explicitly set (not None).
# Otherwise fall back to the LIDLoss key in the PAN file.
# If neither is present, lid_loss is None and the SF API defaults to 0.0.
if plant.lid_loss is not None:
lid_loss = plant.lid_loss
else:
try:
lid_loss = float(module_info["data"]["LIDLoss"]) / 100 # Convert percentage to per unit
except KeyError:
lid_loss = None

pan_file_supplements = {}
pan_file_supplements[module_info["pan_filename"]] = PanFileSupplements(
Expand Down
67 changes: 66 additions & 1 deletion tests/test_construct_plant.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@

from solarfarmer import PVSystem
from solarfarmer.models import PVPlant
from solarfarmer.models.pvsystem.pvsystem import construct_plant, design_plant
from solarfarmer.models.pvsystem.pvsystem import (
construct_plant,
design_plant,
generate_pan_file_supplements,
)


@pytest.fixture
Expand Down Expand Up @@ -170,6 +174,67 @@ def test_multi_dot_ond_filename_produces_correct_spec_id(self, bern_2d_racks_inp
multi_dot.unlink()


class TestGeneratePanFileSupplements:
"""Contract tests for generate_pan_file_supplements().

Priority rule: plant.lid_loss (when not None) > PAN file LIDLoss > None.
"""

@pytest.fixture
def module_info_with_lid(self, bern_2d_racks_inputs):
"""module_info dict built from CanadianSolar PAN (LIDLoss=3.00)."""
from solarfarmer.models.pvsystem.pvsystem import get_module_info_from_pan

p = PVSystem(latitude=46.95, longitude=7.44)
p.pan_files = {
"CanadianSolar_CS6U-330M_APP": f"{bern_2d_racks_inputs}/CanadianSolar_CS6U-330M_APP.PAN"
}
return get_module_info_from_pan(p)

@pytest.fixture
def module_info_without_lid(self, module_info_with_lid):
"""module_info dict built from a PAN file with the LIDLoss key stripped out."""
import copy

info = copy.deepcopy(module_info_with_lid)
info["data"].pop("LIDLoss", None)
return info

def test_plant_lid_loss_overrides_pan_file(self, module_info_with_lid):
"""When plant.lid_loss is set, it wins over the PAN file value."""
plant = PVSystem(latitude=0, longitude=0)
plant.lid_loss = 0.01 # PAN has LIDLoss=3.00 (0.03 per unit)
supplements = generate_pan_file_supplements(
plant, module_info_with_lid, bifaciality_factor=0.0
)
pan_key = module_info_with_lid["pan_filename"]
assert supplements[pan_key].lid_loss == pytest.approx(0.01)

def test_pan_lid_loss_used_when_plant_lid_loss_not_set(self, module_info_with_lid):
"""When plant.lid_loss is None, the PAN file LIDLoss is used."""
plant = PVSystem(latitude=0, longitude=0)
assert plant.lid_loss is None # verify default
supplements = generate_pan_file_supplements(
plant, module_info_with_lid, bifaciality_factor=0.0
)
pan_key = module_info_with_lid["pan_filename"]
assert supplements[pan_key].lid_loss == pytest.approx(0.03) # 3.00 / 100

def test_lid_loss_is_none_when_neither_plant_nor_pan_provides_value(
self, module_info_without_lid
):
"""When neither plant nor PAN file has LIDLoss, supplements.lid_loss is None.

The SF API then defaults to 0.0 via panData.LIDLoss (C# double default).
"""
plant = PVSystem(latitude=0, longitude=0)
supplements = generate_pan_file_supplements(
plant, module_info_without_lid, bifaciality_factor=0.0
)
pan_key = module_info_without_lid["pan_filename"]
assert supplements[pan_key].lid_loss is None


class TestListPathInput:
"""Verify pan_files and ond_files accept list[Path] in addition to dict."""

Expand Down
Loading