Skip to content

Commit a3dcb36

Browse files
authored
Merge branch 'main' into pvgis-tmy-csv
2 parents 94319df + 4b25801 commit a3dcb36

File tree

11 files changed

+430
-152
lines changed

11 files changed

+430
-152
lines changed

docs/sphinx/source/reference/modelchain.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ on the information in the associated :py:class:`~pvsystem.PVSystem` object.
112112
modelchain.ModelChain.infer_dc_model
113113
modelchain.ModelChain.infer_ac_model
114114
modelchain.ModelChain.infer_aoi_model
115-
modelchain.ModelChain.infer_spectral_model
116115
modelchain.ModelChain.infer_temperature_model
117116
modelchain.ModelChain.infer_losses_model
118117

docs/sphinx/source/whatsnew/v0.11.3.rst

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,36 @@
44
v0.11.3 (Anticipated March, 2025)
55
---------------------------------
66

7+
Breaking Changes
8+
~~~~~~~~~~~~~~~~
9+
* The pvlib.location.Location.pytz attribute is now read only. The
10+
pytz attribute is now set internally to be consistent with the
11+
pvlib.location.Location.tz attribute. (:issue:`2340`, :pull:`2341`)
12+
* Users must now provide ModelChain.spectral_model, or the 'no_loss' spectral
13+
model is assumed. pvlib.modelchain.ModelChain no longer attempts to infer
14+
the spectral model from PVSystem attributes. (:issue:`2017`, :pull:`2253`)
15+
716
Bug fixes
817
~~~~~~~~~
18+
* Add a check to :py:func:`~pvlib.snow.fully_covered_nrel` and
19+
:py:func:`~pvlib.snow.coverage_nrel`. The check uses snow depth on the ground
20+
to improve modeling for systems with shallow tilt angles. The check
21+
adds a new, optional parameter snow_depth. (:issue:`1171`, :pull:`2292`)
22+
* Fix a bug in :py:func:`pvlib.bifacial.get_irradiance_poa` which may have yielded non-zero
23+
ground irradiance when the sun was below the horizon. (:issue:`2245`, :pull:`2359`)
24+
* Fix a bug where :py:func:`pvlib.transformer.simple_efficiency` could only be imported
25+
using the `from pvlib.transformer` syntax (:pull:`2388`)
26+
* :py:class:`~pvlib.modelchain.ModelChain` now requires only a minimal set of
27+
parameters to run the SAPM electrical model. (:issue:`2369`, :pull:`2393`)
28+
* Correct keys for First Solar modules in `~pvlib.spectrum.spectral_factor_pvspec` (:issue:`2398`, :pull:`2400`)
29+
* Ensure proper tz and pytz types in pvlib.location.Location. To ensure that
30+
the time zone in pvlib.location.Location remains internally consistent
31+
if/when the time zone is updated, the tz attribute is now the single source
32+
of time-zone truth, is the single time-zone setter interface, and its getter
33+
returns an IANA string. (:issue:`2340`, :pull:`2341`)
934
* :py:func:`~pvlib.iotools.get_pvgis_tmy` with ``outputformat='csv'`` now
1035
works with the updated data format returned by PVGIS. (:issue:`2344`, :pull:`2395`)
1136

12-
1337
Deprecations
1438
~~~~~~~~~~~~
1539

@@ -18,28 +42,27 @@ Enhancements
1842
~~~~~~~~~~~~
1943
* :py:func:`~pvlib.irradiance.gti_dirint` now raises an informative message
2044
when input data don't include values with AOI<90 (:issue:`1342`, :pull:`2347`)
21-
* Fix a bug in :py:func:`pvlib.bifacial.get_irradiance_poa` which may have yielded non-zero
22-
ground irradiance when the sun was below the horizon. (:issue:`2245`, :pull:`2359`)
23-
* Fix a bug where :py:func:`pvlib.transformer.simple_efficiency` could only be imported
24-
using the `from pvlib.transformer` syntax (:pull:`2388`)
2545
* Reduced space requirements by excluding tests and test files from wheel.
2646
Zipped wheel is now 66% of the previous size, and installed size is 50% of
2747
the previous size.
2848
(:issue:`2271`, :pull:`2277`)
29-
* Correct keys for First Solar modules in `~pvlib.spectrum.spectral_factor_pvspec` (:issue:`2398`, :pull:`2400`)
3049

3150
Documentation
3251
~~~~~~~~~~~~~
3352
* Fix Procedural and Object Oriented simulation examples having slightly different results, in :ref:`introtutorial`. (:issue:`2366`, :pull:`2367`)
3453
* Restructure the user guide with subsections (:issue:`2302`, :pull:`2310`)
3554
* Add references for :py:func:`pvlib.snow.loss_townsend`. (:issue:`2383`, :pull:`2384`)
36-
* Add :term:`ghi_clear` to the :ref:`nomenclature` page (:issue:`2272`, :pull`2397`)
55+
* Add :term:`ghi_clear` to the :ref:`nomenclature` page (:issue:`2272`, :pull:`2397`)
56+
* Add output variable naming clarifaction to :py:func:`pvlib.pvsystem.calcparams_desoto` and :py:func:`pvlib.pvsystem.calcparams_pvsyst` (:issue:`716`, :pull:`2405`)
3757

3858
Testing
3959
~~~~~~~
4060
* Moved tests folder to `/tests` and data exclusively used for testing to `/tests/data`.
4161
(:issue:`2271`, :pull:`2277`)
4262
* Added Python 3.13 to test suite. (:pull:`2258`)
63+
* Add tests for all input types for the pvlib.location.Location.tz attribute.
64+
(:issue:`2340`, :pull:`2341`)
65+
* Add tests for time-conversion functions in pvlib.tools. (:issue:`2340`, :pull:`2341`)
4366

4467

4568
Requirements
@@ -57,10 +80,12 @@ Maintenance
5780
Contributors
5881
~~~~~~~~~~~~
5982
* Rajiv Daxini (:ghuser:`RDaxini`)
60-
* Mark Campanelli (:ghuser:`markcampanelli`)
6183
* Cliff Hansen (:ghuser:`cwhanse`)
6284
* Jason Lun Leung (:ghuser:`jason-rpkt`)
6385
* Manoj K S (:ghuser:`manojks1999`)
6486
* Kurt Rhee (:ghuser:`kurt-rhee`)
6587
* Ayush jariyal (:ghuser:`ayushjariyal`)
88+
* Kevin Anderson (:ghuser:`kandersolar`)
6689
* Echedey Luis (:ghuser:`echedey-ls`)
90+
* Mark Campanelli (:ghuser:`markcampanelli`)
91+
* Max Jackson (:ghuser:`MaxJackson`)

pvlib/location.py

Lines changed: 71 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import pathlib
88
import datetime
9+
import zoneinfo
910

1011
import pandas as pd
1112
import pytz
@@ -18,13 +19,16 @@
1819
class Location:
1920
"""
2021
Location objects are convenient containers for latitude, longitude,
21-
timezone, and altitude data associated with a particular
22-
geographic location. You can also assign a name to a location object.
22+
time zone, and altitude data associated with a particular geographic
23+
location. You can also assign a name to a location object.
2324
24-
Location objects have two timezone attributes:
25+
Location objects have two time-zone attributes:
2526
26-
* ``tz`` is a IANA timezone string.
27-
* ``pytz`` is a pytz timezone object.
27+
* ``tz`` is an IANA time-zone string.
28+
* ``pytz`` is a pytz-based time-zone object (read only).
29+
30+
The read-only ``pytz`` attribute will stay in sync with any changes made
31+
using ``tz``.
2832
2933
Location objects support the print method.
3034
@@ -38,12 +42,16 @@ class Location:
3842
Positive is east of the prime meridian.
3943
Use decimal degrees notation.
4044
41-
tz : str, int, float, or pytz.timezone, default 'UTC'.
42-
See
43-
http://en.wikipedia.org/wiki/List_of_tz_database_time_zones
44-
for a list of valid time zones.
45-
pytz.timezone objects will be converted to strings.
46-
ints and floats must be in hours from UTC.
45+
tz : time zone as str, int, float, or datetime.tzinfo, default 'UTC'.
46+
See http://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a
47+
list of valid name strings. An `int` or `float` must be a whole-number
48+
hour offsets from UTC that can be converted to the IANA-supported
49+
'Etc/GMT-N' format. (Note the limited range of the offset N and its
50+
sign-change convention.) Time zones from the pytz and zoneinfo packages
51+
may also be passed here, as they are subclasses of datetime.tzinfo.
52+
53+
The `tz` attribute is represented as a valid IANA time zone name
54+
string.
4755
4856
altitude : float, optional
4957
Altitude from sea level in meters.
@@ -54,43 +62,75 @@ class Location:
5462
name : string, optional
5563
Sets the name attribute of the Location object.
5664
65+
Raises
66+
------
67+
ValueError
68+
when the time zone ``tz`` cannot be converted.
69+
70+
zoneinfo.ZoneInfoNotFoundError
71+
when the time zone ``tz`` is not recognizable as an IANA time zone by
72+
the ``zoneinfo.ZoneInfo`` initializer used for internal time-zone
73+
representation.
74+
5775
See also
5876
--------
5977
pvlib.pvsystem.PVSystem
6078
"""
6179

62-
def __init__(self, latitude, longitude, tz='UTC', altitude=None,
63-
name=None):
64-
80+
def __init__(
81+
self, latitude, longitude, tz='UTC', altitude=None, name=None
82+
):
6583
self.latitude = latitude
6684
self.longitude = longitude
67-
68-
if isinstance(tz, str):
69-
self.tz = tz
70-
self.pytz = pytz.timezone(tz)
71-
elif isinstance(tz, datetime.timezone):
72-
self.tz = 'UTC'
73-
self.pytz = pytz.UTC
74-
elif isinstance(tz, datetime.tzinfo):
75-
self.tz = tz.zone
76-
self.pytz = tz
77-
elif isinstance(tz, (int, float)):
78-
self.tz = tz
79-
self.pytz = pytz.FixedOffset(tz*60)
80-
else:
81-
raise TypeError('Invalid tz specification')
85+
self.tz = tz
8286

8387
if altitude is None:
8488
altitude = lookup_altitude(latitude, longitude)
8589

8690
self.altitude = altitude
87-
8891
self.name = name
8992

9093
def __repr__(self):
9194
attrs = ['name', 'latitude', 'longitude', 'altitude', 'tz']
95+
# Use None as getattr default in case __repr__ is called during
96+
# initialization before all attributes have been assigned.
9297
return ('Location: \n ' + '\n '.join(
93-
f'{attr}: {getattr(self, attr)}' for attr in attrs))
98+
f'{attr}: {getattr(self, attr, None)}' for attr in attrs))
99+
100+
@property
101+
def tz(self):
102+
"""The location's IANA time-zone string."""
103+
return str(self._zoneinfo)
104+
105+
@tz.setter
106+
def tz(self, tz_):
107+
# self._zoneinfo holds single source of time-zone truth as IANA name.
108+
if isinstance(tz_, str):
109+
self._zoneinfo = zoneinfo.ZoneInfo(tz_)
110+
elif isinstance(tz_, int):
111+
self._zoneinfo = zoneinfo.ZoneInfo(f"Etc/GMT{-tz_:+d}")
112+
elif isinstance(tz_, float):
113+
if tz_ % 1 != 0:
114+
raise TypeError(
115+
"Floating-point tz has non-zero fractional part: "
116+
f"{tz_}. Only whole-number offsets are supported."
117+
)
118+
119+
self._zoneinfo = zoneinfo.ZoneInfo(f"Etc/GMT{-int(tz_):+d}")
120+
elif isinstance(tz_, datetime.tzinfo):
121+
# Includes time zones generated by pytz and zoneinfo packages.
122+
self._zoneinfo = zoneinfo.ZoneInfo(str(tz_))
123+
else:
124+
raise TypeError(
125+
f"invalid tz specification: {tz_}, must be an IANA time zone "
126+
"string, a whole-number int/float UTC offset, or a "
127+
"datetime.tzinfo object (including subclasses)"
128+
)
129+
130+
@property
131+
def pytz(self):
132+
"""The location's pytz time zone (read only)."""
133+
return pytz.timezone(str(self._zoneinfo))
94134

95135
@classmethod
96136
def from_tmy(cls, tmy_metadata, tmy_data=None, **kwargs):

pvlib/modelchain.py

Lines changed: 18 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929

3030
# Optional keys to communicate temperature data. If provided,
3131
# 'cell_temperature' overrides ModelChain.temperature_model and sets
32-
# ModelChain.cell_temperature to the data. If 'module_temperature' is provdied,
32+
# ModelChain.cell_temperature to the data. If 'module_temperature' is provided,
3333
# overrides ModelChain.temperature_model with
3434
# pvlib.temperature.sapm_celL_from_module
3535
TEMPERATURE_KEYS = ('module_temperature', 'cell_temperature')
@@ -253,7 +253,7 @@ def __repr__(self):
253253
def _head(obj):
254254
try:
255255
return obj[:3]
256-
except:
256+
except Exception:
257257
return obj
258258

259259
if type(self.dc) is tuple:
@@ -269,7 +269,7 @@ def _head(obj):
269269
'\n')
270270
lines = []
271271
for attr in mc_attrs:
272-
if not (attr.startswith('_') or attr=='times'):
272+
if not (attr.startswith('_') or attr == 'times'):
273273
lines.append(f' {attr}: ' + _mcr_repr(getattr(self, attr)))
274274
desc4 = '\n'.join(lines)
275275
return (desc1 + desc2 + desc3 + desc4)
@@ -330,12 +330,15 @@ class ModelChain:
330330
'interp' and 'no_loss'. The ModelChain instance will be passed as the
331331
first argument to a user-defined function.
332332
333-
spectral_model : str, or function, optional
334-
If not specified, the model will be inferred from the parameters that
335-
are common to all of system.arrays[i].module_parameters.
336-
Valid strings are 'sapm', 'first_solar', 'no_loss'.
333+
spectral_model : str or function, optional
334+
Valid strings are:
335+
336+
- ``'sapm'``
337+
- ``'first_solar'``
338+
- ``'no_loss'``
339+
337340
The ModelChain instance will be passed as the first argument to
338-
a user-defined function.
341+
a user-defined function. If not specified, ``'no_loss'`` is assumed.
339342
340343
temperature_model : str or function, optional
341344
Valid strings are: 'sapm', 'pvsyst', 'faiman', 'fuentes', 'noct_sam'.
@@ -386,7 +389,6 @@ def __init__(self, system, location,
386389

387390
self.results = ModelChainResult()
388391

389-
390392
@classmethod
391393
def with_pvwatts(cls, system, location,
392394
clearsky_model='ineichen',
@@ -855,9 +857,7 @@ def spectral_model(self):
855857

856858
@spectral_model.setter
857859
def spectral_model(self, model):
858-
if model is None:
859-
self._spectral_model = self.infer_spectral_model()
860-
elif isinstance(model, str):
860+
if isinstance(model, str):
861861
model = model.lower()
862862
if model == 'first_solar':
863863
self._spectral_model = self.first_solar_spectral_loss
@@ -867,30 +867,12 @@ def spectral_model(self, model):
867867
self._spectral_model = self.no_spectral_loss
868868
else:
869869
raise ValueError(model + ' is not a valid spectral loss model')
870-
else:
870+
elif model is None:
871+
# not setting a model is equivalent to setting no_loss
872+
self._spectral_model = self.no_spectral_loss
873+
else: # assume model is callable with 1st argument = the MC instance
871874
self._spectral_model = partial(model, self)
872875

873-
def infer_spectral_model(self):
874-
"""Infer spectral model from system attributes."""
875-
module_parameters = tuple(
876-
array.module_parameters for array in self.system.arrays)
877-
params = _common_keys(module_parameters)
878-
if {'A4', 'A3', 'A2', 'A1', 'A0'} <= params:
879-
return self.sapm_spectral_loss
880-
elif ((('Technology' in params or
881-
'Material' in params) and
882-
(self.system._infer_cell_type() is not None)) or
883-
'first_solar_spectral_coefficients' in params):
884-
return self.first_solar_spectral_loss
885-
else:
886-
raise ValueError('could not infer spectral model from '
887-
'system.arrays[i].module_parameters. Check that '
888-
'the module_parameters for all Arrays in '
889-
'system.arrays contain valid '
890-
'first_solar_spectral_coefficients, a valid '
891-
'Material or Technology value, or set '
892-
'spectral_model="no_loss".')
893-
894876
def first_solar_spectral_loss(self):
895877
self.results.spectral_modifier = self.system.first_solar_spectral_loss(
896878
_tuple_from_dfs(self.results.weather, 'precipitable_water'),
@@ -1570,7 +1552,7 @@ def _prepare_temperature(self, data):
15701552
----------
15711553
data : DataFrame
15721554
May contain columns ``'cell_temperature'`` or
1573-
``'module_temperaure'``.
1555+
``'module_temperature'``.
15741556
15751557
Returns
15761558
-------
@@ -1679,6 +1661,7 @@ def run_model(self, weather):
16791661
self.prepare_inputs(weather)
16801662
self.aoi_model()
16811663
self.spectral_model()
1664+
16821665
self.effective_irradiance_model()
16831666

16841667
self._run_from_effective_irrad(weather)

pvlib/pvsystem.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,10 @@
2929
# a dict of required parameter names for each DC power model
3030
_DC_MODEL_PARAMS = {
3131
'sapm': {
32-
'A0', 'A1', 'A2', 'A3', 'A4', 'B0', 'B1', 'B2', 'B3',
33-
'B4', 'B5', 'C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6',
34-
'C7', 'Isco', 'Impo', 'Voco', 'Vmpo', 'Aisc', 'Aimp', 'Bvoco',
32+
'C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7',
33+
'Isco', 'Impo', 'Voco', 'Vmpo', 'Aisc', 'Aimp', 'Bvoco',
3534
'Mbvoc', 'Bvmpo', 'Mbvmp', 'N', 'Cells_in_Series',
36-
'IXO', 'IXXO', 'FD'},
35+
'IXO', 'IXXO'},
3736
'desoto': {
3837
'alpha_sc', 'a_ref', 'I_L_ref', 'I_o_ref',
3938
'R_sh_ref', 'R_s'},
@@ -1710,6 +1709,8 @@ def calcparams_desoto(effective_irradiance, temp_cell,
17101709
Rs = R_s
17111710

17121711
numeric_args = (effective_irradiance, temp_cell)
1712+
# IL: photocurrent, I0: saturation_current, Rs: resistance_series,
1713+
# Rsh: resistance_shunt
17131714
out = (IL, I0, Rs, Rsh, nNsVth)
17141715

17151716
if all(map(np.isscalar, numeric_args)):
@@ -1976,6 +1977,8 @@ def calcparams_pvsyst(effective_irradiance, temp_cell,
19761977
Rs = R_s
19771978

19781979
numeric_args = (effective_irradiance, temp_cell)
1980+
# IL: photocurrent, I0: saturation_current, Rs: resistance_series,
1981+
# Rsh: resistance_shunt
19791982
out = (IL, I0, Rs, Rsh, nNsVth)
19801983

19811984
if all(map(np.isscalar, numeric_args)):

0 commit comments

Comments
 (0)