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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.11", "3.12", "3.13"]

steps:
- name: Checkout repository
Expand Down
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Use this workflow when the user already has a complete set of SolarFarmer API in
Below is an example mapping to existing API payload files (folders with `.json`, PAN, OND, met data)
```python
import solarfarmer as sf

sf.configure_logging() # SDK is silent by default — call this to see progress

result = sf.run_energy_calculation(
Expand All @@ -27,6 +28,7 @@ Build a plant from scratch with `PVSystem`:
```python
import solarfarmer as sf
from pathlib import Path

sf.configure_logging()

plant = sf.PVSystem(
Expand All @@ -35,7 +37,7 @@ plant = sf.PVSystem(
longitude=10.3,
dc_capacity_MW=10.0,
ac_capacity_MW=10.0,
mounting="Fixed", # or "Tracker"
mounting="Fixed", # or "Tracker"
bifacial=False,
)
plant.pan_files = {"MyModule": Path("module.PAN")}
Expand Down Expand Up @@ -140,8 +142,9 @@ Polling frequency and connection timeout are controlled by `MODELCHAIN_ASYNC_POL
The SDK uses a `NullHandler` by default (no output). Always call `configure_logging()` before running:
```python
import solarfarmer as sf
sf.configure_logging() # INFO level
sf.configure_logging(verbose=True) # DEBUG level

sf.configure_logging() # INFO level
sf.configure_logging(verbose=True) # DEBUG level
sf.configure_logging(level="WARNING") # Minimal output
```

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ The official Python SDK for [SolarFarmer](https://www.dnv.com/software/services/

## Requirements

- Python >= 3.10 (tested on 3.10, 3.11, 3.12, 3.13)
- Python >= 3.11 (tested on 3.11, 3.12, 3.13)
- A SolarFarmer API key (commercial licence required; see [API Key](#api-key))

For compatibility with Python 3.10, use version 0.7.0 or prior.

## Installation

Install from PyPI:
Expand Down
12 changes: 4 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "dnv-solarfarmer"
version = "0.7.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"
requires-python = ">=3.11"
license = { text = "Apache-2.0" }
authors = [{ name = "DNV", email = "solarfarmer@dnv.com" }]
keywords = ["solar", "pv", "photovoltaic", "solarfarmer", "dnv", "sdk", "api"]
Expand All @@ -16,7 +16,6 @@ classifiers = [
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand Down Expand Up @@ -52,11 +51,8 @@ all = [

[dependency-groups]
docs = [
# Python 3.10 cannot use the latest docs toolchain; pin to the last 3.10-compatible releases.
"zensical>=0.0.20,<0.0.68; python_version < '3.11'",
"zensical>=0.0.68; python_version >= '3.11'",
"mkdocstrings[python]>=1.0.6,<1.0.7; python_version < '3.11'",
"mkdocstrings[python]>=1.0.8; python_version >= '3.11'",
"zensical>=0.0.68",
"mkdocstrings[python]>=1.0.8",
]

test = [
Expand Down Expand Up @@ -84,7 +80,7 @@ solarfarmer = ["py.typed"]

[tool.ruff]
# Target Python 3.10+
target-version = "py310"
target-version = "py311"
line-length = 100

# Exclude common directories
Expand Down
22 changes: 11 additions & 11 deletions solarfarmer/models/enums.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from enum import Enum
from enum import StrEnum


class DiffuseModel(str, Enum):
class DiffuseModel(StrEnum):
"""Diffuse transposition model for converting horizontal to tilted irradiance."""

ISOTROPIC = "Isotropic"
Expand All @@ -10,14 +10,14 @@ class DiffuseModel(str, Enum):
PEREZ_EMULATE_PVSYST = "PerezEmulatePVsyst"


class MissingMetDataMethod(str, Enum):
class MissingMetDataMethod(StrEnum):
"""Behaviour when required meteorological variables have missing data."""

FAIL_ON_VALIDATION = "FailOnValidation"
REMOVE_TIMESTAMP = "RemoveTimestamp"


class HorizonType(str, Enum):
class HorizonType(StrEnum):
"""Azimuth convention of the horizon file.

Different data providers use different azimuth conventions.
Expand All @@ -30,28 +30,28 @@ class HorizonType(str, Enum):
PVSYST_HEMISPHERE_AWARE = "PVsystHemisphereAware"


class OrderColumnsPvSystFormatTimeSeries(str, Enum):
class OrderColumnsPvSystFormatTimeSeries(StrEnum):
"""Column ordering for PVsyst-format time-series output."""

ALPHABETIC = "Alphabetic"
CALCULATION = "Calculation"


class TransformerLossModelTypes(str, Enum):
class TransformerLossModelTypes(StrEnum):
"""Transformer loss model type."""

SIMPLE_LOSS_FACTOR = "SimpleLossFactor"
NO_LOAD_AND_OHMIC = "NoLoadAndOhmic"


class InverterOverPowerShutdownMode(str, Enum):
class InverterOverPowerShutdownMode(StrEnum):
"""Inverter behaviour when over-power shutdown conditions occur."""

USE_MAXIMUM_MPPT_VOLTAGE = "UseMaximumMpptVoltage"
USE_VOLTAGE_DERATE_PROFILE = "UseVoltageDerateProfile"


class PowerOptimizerOperationType(str, Enum):
class PowerOptimizerOperationType(StrEnum):
"""Connection configuration for DC power optimizers (3D calculations only).

Defines how many modules share a single power optimizer and how they
Expand All @@ -66,7 +66,7 @@ class PowerOptimizerOperationType(str, Enum):
"""One optimizer shared by two modules connected in series."""


class IAMModelTypeForOverride(str, Enum):
class IAMModelTypeForOverride(StrEnum):
"""IAM model types available for overriding the PAN file default."""

FRESNEL_NORMAL = "FresnelNormal"
Expand All @@ -76,7 +76,7 @@ class IAMModelTypeForOverride(str, Enum):
CUSTOM = "Custom"


class TrackerAlgorithm(str, Enum):
class TrackerAlgorithm(StrEnum):
"""Rotation algorithm used by a single-axis tracker system.

Determines how the tracker computes its rotation angle at each time step.
Expand All @@ -92,7 +92,7 @@ class TrackerAlgorithm(str, Enum):
"""Pure astronomical sun-tracking with no backtracking."""


class MeteoFileFormat(str, Enum):
class MeteoFileFormat(StrEnum):
"""Meteorological file format.

The SDK maps file extensions to the correct multipart upload field
Expand Down
4 changes: 2 additions & 2 deletions solarfarmer/models/pvsystem/plant_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
the SDK PVSystem class when constructing the API JSON payload.
"""

from enum import Enum
from enum import StrEnum

# ------------------------------------------------------------------------------
# Enum definitions for inverter types, mounting types, and orientation types.
# ------------------------------------------------------------------------------


class _CaseInsensitiveStrEnum(str, Enum):
class _CaseInsensitiveStrEnum(StrEnum):
"""String enum whose lookup by value ignores case (e.g. ``"fixed"`` -> ``FIXED``)."""

@classmethod
Expand Down
4 changes: 2 additions & 2 deletions solarfarmer/rcl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, TypedDict

Expand Down Expand Up @@ -74,7 +74,7 @@ class RCLRateLimitInfo:
@property
def reset_datetime(self) -> datetime:
"""Reset time as a UTC-aware datetime."""
return datetime.fromtimestamp(self.reset_timestamp, tz=timezone.utc)
return datetime.fromtimestamp(self.reset_timestamp, tz=UTC)

@property
def usage_percent(self) -> float:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_custom_rotations.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests for custom tracker rotations CSV parsing and protobuf export."""

import json
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta, timezone

import pytest

Expand Down Expand Up @@ -567,7 +567,7 @@ def test_files_sorted_chronologically_not_by_name(self, tmp_path) -> None:

# January data must come first regardless of filename order.
first_ts = dataset.data[0].start_of_period
assert first_ts == datetime(2025, 1, 1, 8, 0, tzinfo=timezone.utc)
assert first_ts == datetime(2025, 1, 1, 8, 0, tzinfo=UTC)
assert len(dataset.data) == 6

def test_single_file_folder(self, tmp_path) -> None:
Expand Down
21 changes: 11 additions & 10 deletions tests/test_models/test_serialization.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
from datetime import UTC

import pytest

Expand Down Expand Up @@ -229,26 +230,26 @@ def test_transformer_spec_round_trip(self) -> None:
assert rebuilt == spec

def test_tracker_condition_round_trip(self) -> None:
from datetime import datetime, timezone
from datetime import datetime

# Mirrors the first data row in EnergyCalcInputsTrackerTest.json:
# all trackers flat (angle = 0) early morning.
cond = TrackerCondition(
period_in_minutes=5.0,
start_of_period=datetime(2018, 1, 1, 8, 0, tzinfo=timezone.utc),
start_of_period=datetime(2018, 1, 1, 8, 0, tzinfo=UTC),
tracker_rotation_unique_value=0,
)
rebuilt = TrackerCondition.model_validate(cond.model_dump(by_alias=True))
assert rebuilt == cond

def test_tracker_condition_array_round_trip(self) -> None:
from datetime import datetime, timezone
from datetime import datetime

# Mirrors the second data row in EnergyCalcInputsTrackerTest.json:
# trackers have different angles, encoded as degrees × 100.
cond = TrackerCondition(
period_in_minutes=5.0,
start_of_period=datetime(2018, 1, 1, 8, 5, tzinfo=timezone.utc),
start_of_period=datetime(2018, 1, 1, 8, 5, tzinfo=UTC),
tracker_rotations_array_values=[-1570, -780, -1050],
)
d = cond.model_dump(by_alias=True)
Expand All @@ -258,7 +259,7 @@ def test_tracker_condition_array_round_trip(self) -> None:
assert rebuilt == cond

def test_trackers_conditions_dataset_round_trip(self) -> None:
from datetime import datetime, timezone
from datetime import datetime

# Small 3-tracker / 2-timestep dataset representative of the JSON file.
dataset = TrackersConditionsDataset(
Expand All @@ -268,12 +269,12 @@ def test_trackers_conditions_dataset_round_trip(self) -> None:
data=[
TrackerCondition(
period_in_minutes=5.0,
start_of_period=datetime(2018, 1, 1, 8, 0, tzinfo=timezone.utc),
start_of_period=datetime(2018, 1, 1, 8, 0, tzinfo=UTC),
tracker_rotation_unique_value=0,
),
TrackerCondition(
period_in_minutes=5.0,
start_of_period=datetime(2018, 1, 1, 8, 5, tzinfo=timezone.utc),
start_of_period=datetime(2018, 1, 1, 8, 5, tzinfo=UTC),
tracker_rotations_array_values=[-1570, -780, -1050],
),
],
Expand Down Expand Up @@ -305,7 +306,7 @@ def test_nested_json(self, transformer: Transformer) -> None:

def test_parse_tracker_conditions_large_dataset(self) -> None:
"""TrackersConditionsDataset handles large tracker counts and mixed condition types."""
from datetime import datetime, timezone
from datetime import datetime

n_trackers = 536
tracker_ids = [f"Index_{i}" for i in range(n_trackers)]
Expand All @@ -317,12 +318,12 @@ def test_parse_tracker_conditions_large_dataset(self) -> None:
data=[
TrackerCondition(
period_in_minutes=5.0,
start_of_period=datetime(2018, 1, 1, 8, 0, tzinfo=timezone.utc),
start_of_period=datetime(2018, 1, 1, 8, 0, tzinfo=UTC),
tracker_rotation_unique_value=0,
),
TrackerCondition(
period_in_minutes=5.0,
start_of_period=datetime(2018, 1, 1, 8, 5, tzinfo=timezone.utc),
start_of_period=datetime(2018, 1, 1, 8, 5, tzinfo=UTC),
tracker_rotations_array_values=list(range(n_trackers)),
),
],
Expand Down
18 changes: 9 additions & 9 deletions tests/test_models/test_trackers_conditions_protobuf.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import gzip
import tempfile
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta, timezone
from pathlib import Path

import pytest
Expand Down Expand Up @@ -66,7 +66,7 @@ def _make_dataset(

class TestDotnetTicksConversion:
def test_utc_roundtrip(self) -> None:
dt = datetime(2020, 6, 15, 12, 30, 0, tzinfo=timezone.utc)
dt = datetime(2020, 6, 15, 12, 30, 0, tzinfo=UTC)
item1, item2 = _datetime_to_dotnet_ticks(dt)
result = _dotnet_ticks_to_datetime(item1, item2)
assert result == dt
Expand All @@ -90,10 +90,10 @@ def test_naive_datetime_treated_as_utc(self) -> None:
item1, item2 = _datetime_to_dotnet_ticks(naive)
assert item2 == 0 # UTC offset = 0
result = _dotnet_ticks_to_datetime(item1, item2)
assert result == datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
assert result == datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)

def test_zero_offset_item2(self) -> None:
_, item2 = _datetime_to_dotnet_ticks(datetime(2020, 1, 1, tzinfo=timezone.utc))
_, item2 = _datetime_to_dotnet_ticks(datetime(2020, 1, 1, tzinfo=UTC))
assert item2 == 0

def test_one_hour_offset_item2(self) -> None:
Expand Down Expand Up @@ -148,12 +148,12 @@ def test_null_vs_zero_unique_value_preserved(self) -> None:
data=[
TrackerCondition(
period_in_minutes=60.0,
start_of_period=datetime(2020, 1, 1, 0, tzinfo=timezone.utc),
start_of_period=datetime(2020, 1, 1, 0, tzinfo=UTC),
tracker_rotation_unique_value=0, # explicitly zero — must survive
),
TrackerCondition(
period_in_minutes=60.0,
start_of_period=datetime(2020, 1, 1, 1, tzinfo=timezone.utc),
start_of_period=datetime(2020, 1, 1, 1, tzinfo=UTC),
# unique_value absent → null
),
]
Expand Down Expand Up @@ -185,7 +185,7 @@ def test_rotations_are_at_middle_of_period_preserved(self) -> None:
data=[
TrackerCondition(
period_in_minutes=30.0,
start_of_period=datetime(2020, 1, 1, tzinfo=timezone.utc),
start_of_period=datetime(2020, 1, 1, tzinfo=UTC),
tracker_rotation_unique_value=100,
)
],
Expand All @@ -209,7 +209,7 @@ def test_rotations_are_at_middle_of_period_preserved(self) -> None:
class TestFromProtobufFiles:
def test_multi_file_combines_records(self) -> None:
ds_a = _make_dataset(n=2, use_array=False)
tz = timezone.utc
tz = UTC
ds_b = TrackersConditionsDataset(
data=[
TrackerCondition(
Expand Down Expand Up @@ -254,7 +254,7 @@ def test_single_file_discovered(self) -> None:
assert len(loaded.data) == len(ds.data)

def test_multi_part_files_discovered_in_order(self) -> None:
tz = timezone.utc
tz = UTC
ds1 = TrackersConditionsDataset(
data=[
TrackerCondition(
Expand Down
Loading
Loading