From d03c3482987009a8ac1d9ce86256ef351036add9 Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Tue, 27 Oct 2026 10:30:10 +0100 Subject: [PATCH 1/2] Drop support for Python 3.10 --- .github/workflows/test.yml | 2 +- AGENTS.md | 9 ++++++--- README.md | 4 +++- pyproject.toml | 12 ++++-------- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 57f585e..180e91f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 9130cee..07556b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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( @@ -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( @@ -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")} @@ -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 ``` diff --git a/README.md b/README.md index c9ac52e..46babeb 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/pyproject.toml b/pyproject.toml index cbff2ce..431cc06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] @@ -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", @@ -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 = [ @@ -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 From 911b94098f0093f4254f6b92e8f7a9d37f21fc4b Mon Sep 17 00:00:00 2001 From: Javier Lopez Lorente Date: Tue, 27 Oct 2026 11:11:01 +0100 Subject: [PATCH 2/2] Fix lint errors. UP042 (enums now use enum.StrEnum) and UP017 (timezone.utc become datetime.UTC) --- solarfarmer/models/enums.py | 22 +++++++++---------- solarfarmer/models/pvsystem/plant_defaults.py | 4 ++-- solarfarmer/rcl/__init__.py | 4 ++-- tests/test_custom_rotations.py | 4 ++-- tests/test_models/test_serialization.py | 21 +++++++++--------- .../test_trackers_conditions_protobuf.py | 18 +++++++-------- tests/test_models/test_validation.py | 22 ++++++++++--------- tests/test_rcl.py | 6 ++--- 8 files changed, 52 insertions(+), 49 deletions(-) diff --git a/solarfarmer/models/enums.py b/solarfarmer/models/enums.py index e195299..0bef72d 100644 --- a/solarfarmer/models/enums.py +++ b/solarfarmer/models/enums.py @@ -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" @@ -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. @@ -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 @@ -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" @@ -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. @@ -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 diff --git a/solarfarmer/models/pvsystem/plant_defaults.py b/solarfarmer/models/pvsystem/plant_defaults.py index db016c2..16e56fa 100644 --- a/solarfarmer/models/pvsystem/plant_defaults.py +++ b/solarfarmer/models/pvsystem/plant_defaults.py @@ -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 diff --git a/solarfarmer/rcl/__init__.py b/solarfarmer/rcl/__init__.py index 7beb084..d2b965c 100644 --- a/solarfarmer/rcl/__init__.py +++ b/solarfarmer/rcl/__init__.py @@ -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 @@ -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: diff --git a/tests/test_custom_rotations.py b/tests/test_custom_rotations.py index 057c32b..1b0bbe1 100644 --- a/tests/test_custom_rotations.py +++ b/tests/test_custom_rotations.py @@ -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 @@ -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: diff --git a/tests/test_models/test_serialization.py b/tests/test_models/test_serialization.py index 421598f..5ed428b 100644 --- a/tests/test_models/test_serialization.py +++ b/tests/test_models/test_serialization.py @@ -1,4 +1,5 @@ import json +from datetime import UTC import pytest @@ -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) @@ -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( @@ -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], ), ], @@ -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)] @@ -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)), ), ], diff --git a/tests/test_models/test_trackers_conditions_protobuf.py b/tests/test_models/test_trackers_conditions_protobuf.py index 88ffa0b..3ca46e8 100644 --- a/tests/test_models/test_trackers_conditions_protobuf.py +++ b/tests/test_models/test_trackers_conditions_protobuf.py @@ -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 @@ -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 @@ -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: @@ -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 ), ] @@ -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, ) ], @@ -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( @@ -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( diff --git a/tests/test_models/test_validation.py b/tests/test_models/test_validation.py index 5fd276e..325331b 100644 --- a/tests/test_models/test_validation.py +++ b/tests/test_models/test_validation.py @@ -1,3 +1,5 @@ +from datetime import UTC + import pytest from pydantic import ValidationError @@ -428,52 +430,52 @@ def test_is_mutable(self) -> None: class TestTrackerConditionValidation: def test_both_rotation_fields_raises(self) -> None: - from datetime import datetime, timezone + from datetime import datetime with pytest.raises(ValidationError, match="tracker_rotations_array_values"): TrackerCondition( period_in_minutes=60.0, - start_of_period=datetime(2020, 1, 1, tzinfo=timezone.utc), + start_of_period=datetime(2020, 1, 1, tzinfo=UTC), tracker_rotations_array_values=[1234, -500], tracker_rotation_unique_value=0, ) def test_unique_value_out_of_range_raises(self) -> None: - from datetime import datetime, timezone + from datetime import datetime with pytest.raises(ValidationError, match="tracker_rotation_unique_value"): TrackerCondition( period_in_minutes=5.0, - start_of_period=datetime(2018, 1, 1, tzinfo=timezone.utc), + start_of_period=datetime(2018, 1, 1, tzinfo=UTC), tracker_rotation_unique_value=9000, # > 8990 ) def test_unique_value_negative_out_of_range_raises(self) -> None: - from datetime import datetime, timezone + from datetime import datetime with pytest.raises(ValidationError, match="tracker_rotation_unique_value"): TrackerCondition( period_in_minutes=5.0, - start_of_period=datetime(2018, 1, 1, tzinfo=timezone.utc), + start_of_period=datetime(2018, 1, 1, tzinfo=UTC), tracker_rotation_unique_value=-9000, # < -8990 ) def test_array_value_out_of_range_raises(self) -> None: - from datetime import datetime, timezone + from datetime import datetime with pytest.raises(ValidationError, match="tracker_rotations_array_values"): TrackerCondition( period_in_minutes=5.0, - start_of_period=datetime(2018, 1, 1, tzinfo=timezone.utc), + start_of_period=datetime(2018, 1, 1, tzinfo=UTC), tracker_rotations_array_values=[-1570, 9000], # 9000 > 8990 ) def test_boundary_values_are_valid(self) -> None: - from datetime import datetime, timezone + from datetime import datetime cond = TrackerCondition( period_in_minutes=5.0, - start_of_period=datetime(2018, 1, 1, tzinfo=timezone.utc), + start_of_period=datetime(2018, 1, 1, tzinfo=UTC), tracker_rotations_array_values=[-8990, 0, 8990], ) assert cond.tracker_rotations_array_values == [-8990, 0, 8990] diff --git a/tests/test_rcl.py b/tests/test_rcl.py index eec54c2..851ecda 100644 --- a/tests/test_rcl.py +++ b/tests/test_rcl.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import MagicMock, patch import pytest @@ -116,12 +116,12 @@ def test_reset_datetime_is_utc(self): info = self._make() dt = info.reset_datetime assert isinstance(dt, datetime) - assert dt.tzinfo == timezone.utc + assert dt.tzinfo == 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) + assert info.reset_datetime == datetime(1970, 1, 1, tzinfo=UTC) def test_usage_percent_normal(self): info = self._make(remaining=75, limit=100)