-
Notifications
You must be signed in to change notification settings - Fork 947
/
tracking.py
571 lines (469 loc) · 23.5 KB
/
tracking.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
import numpy as np
import pandas as pd
from pvlib.tools import cosd, sind
from pvlib.pvsystem import _combine_localized_attributes
from pvlib.pvsystem import PVSystem
from pvlib.location import Location
from pvlib import irradiance, atmosphere
class SingleAxisTracker(PVSystem):
"""
Inherits the PV modeling methods from :py:class:`~pvlib.pvsystem.PVSystem`.
Parameters
----------
axis_tilt : float, default 0
The tilt of the axis of rotation (i.e, the y-axis defined by
axis_azimuth) with respect to horizontal, in decimal degrees.
axis_azimuth : float, default 0
A value denoting the compass direction along which the axis of
rotation lies. Measured in decimal degrees East of North.
max_angle : float, default 90
A value denoting the maximum rotation angle, in decimal degrees,
of the one-axis tracker from its horizontal position (horizontal
if axis_tilt = 0). A max_angle of 90 degrees allows the tracker
to rotate to a vertical position to point the panel towards a
horizon. max_angle of 180 degrees allows for full rotation.
backtrack : bool, default True
Controls whether the tracker has the capability to "backtrack"
to avoid row-to-row shading. False denotes no backtrack
capability. True denotes backtrack capability.
gcr : float, default 2.0/7.0
A value denoting the ground coverage ratio of a tracker system
which utilizes backtracking; i.e. the ratio between the PV array
surface area to total ground area. A tracker system with modules
2 meters wide, centered on the tracking axis, with 6 meters
between the tracking axes has a gcr of 2/6=0.333. If gcr is not
provided, a gcr of 2/7 is default. gcr must be <=1.
"""
def __init__(self, axis_tilt=0, axis_azimuth=0,
max_angle=90, backtrack=True, gcr=2.0/7.0, **kwargs):
self.axis_tilt = axis_tilt
self.axis_azimuth = axis_azimuth
self.max_angle = max_angle
self.backtrack = backtrack
self.gcr = gcr
kwargs['surface_tilt'] = None
kwargs['surface_azimuth'] = None
super(SingleAxisTracker, self).__init__(**kwargs)
def __repr__(self):
attrs = ['axis_tilt', 'axis_azimuth', 'max_angle', 'backtrack', 'gcr']
sat_repr = ('SingleAxisTracker: \n ' + '\n '.join(
('{}: {}'.format(attr, getattr(self, attr)) for attr in attrs)))
# get the parent PVSystem info
pvsystem_repr = super(SingleAxisTracker, self).__repr__()
# remove the first line (contains 'PVSystem: \n')
pvsystem_repr = '\n'.join(pvsystem_repr.split('\n')[1:])
return sat_repr + '\n' + pvsystem_repr
def singleaxis(self, apparent_zenith, apparent_azimuth):
"""
Get tracking data. See :py:func:`pvlib.tracking.singleaxis` more
detail.
Parameters
----------
apparent_zenith : float, 1d array, or Series
Solar apparent zenith angles in decimal degrees.
apparent_azimuth : float, 1d array, or Series
Solar apparent azimuth angles in decimal degrees.
Returns
-------
tracking data
"""
tracking_data = singleaxis(apparent_zenith, apparent_azimuth,
self.axis_tilt, self.axis_azimuth,
self.max_angle,
self.backtrack, self.gcr)
return tracking_data
def localize(self, location=None, latitude=None, longitude=None,
**kwargs):
"""
Creates a :py:class:`LocalizedSingleAxisTracker` object using
this object and location data. Must supply either location
object or latitude, longitude, and any location kwargs
Parameters
----------
location : None or Location, default None
latitude : None or float, default None
longitude : None or float, default None
**kwargs : see Location
Returns
-------
localized_system : LocalizedSingleAxisTracker
"""
if location is None:
location = Location(latitude, longitude, **kwargs)
return LocalizedSingleAxisTracker(pvsystem=self, location=location)
def get_aoi(self, surface_tilt, surface_azimuth, solar_zenith,
solar_azimuth):
"""Get the angle of incidence on the system.
For a given set of solar zenith and azimuth angles, the
surface tilt and azimuth parameters are typically determined
by :py:method:`~SingleAxisTracker.singleaxis`. The
:py:method:`~SingleAxisTracker.singleaxis` method also returns
the angle of incidence, so this method is only needed
if using a different tracking algorithm.
Parameters
----------
surface_tilt : numeric
Panel tilt from horizontal.
surface_azimuth : numeric
Panel azimuth from north
solar_zenith : float or Series.
Solar zenith angle.
solar_azimuth : float or Series.
Solar azimuth angle.
Returns
-------
aoi : Series
The angle of incidence in degrees from normal.
"""
aoi = irradiance.aoi(surface_tilt, surface_azimuth,
solar_zenith, solar_azimuth)
return aoi
def get_irradiance(self, surface_tilt, surface_azimuth,
solar_zenith, solar_azimuth, dni, ghi, dhi,
dni_extra=None, airmass=None, model='haydavies',
**kwargs):
"""
Uses the :func:`irradiance.get_total_irradiance` function to
calculate the plane of array irradiance components on a tilted
surface defined by the input data and ``self.albedo``.
For a given set of solar zenith and azimuth angles, the
surface tilt and azimuth parameters are typically determined
by :py:meth:`~SingleAxisTracker.singleaxis`.
Parameters
----------
surface_tilt : numeric
Panel tilt from horizontal.
surface_azimuth : numeric
Panel azimuth from north
solar_zenith : numeric
Solar zenith angle.
solar_azimuth : numeric
Solar azimuth angle.
dni : float or Series
Direct Normal Irradiance
ghi : float or Series
Global horizontal irradiance
dhi : float or Series
Diffuse horizontal irradiance
dni_extra : float or Series, default None
Extraterrestrial direct normal irradiance
airmass : float or Series, default None
Airmass
model : String, default 'haydavies'
Irradiance model.
**kwargs
Passed to :func:`irradiance.get_total_irradiance`.
Returns
-------
poa_irradiance : DataFrame
Column names are: ``total, beam, sky, ground``.
"""
# not needed for all models, but this is easier
if dni_extra is None:
dni_extra = irradiance.get_extra_radiation(solar_zenith.index)
if airmass is None:
airmass = atmosphere.get_relative_airmass(solar_zenith)
return irradiance.get_total_irradiance(surface_tilt,
surface_azimuth,
solar_zenith,
solar_azimuth,
dni, ghi, dhi,
dni_extra=dni_extra,
airmass=airmass,
model=model,
albedo=self.albedo,
**kwargs)
class LocalizedSingleAxisTracker(SingleAxisTracker, Location):
"""
The LocalizedSingleAxisTracker class defines a standard set of
installed PV system attributes and modeling functions. This class
combines the attributes and methods of the SingleAxisTracker (a
subclass of PVSystem) and Location classes.
The LocalizedSingleAxisTracker may have bugs due to the difficulty
of robustly implementing multiple inheritance. See
:py:class:`~pvlib.modelchain.ModelChain` for an alternative paradigm
for modeling PV systems at specific locations.
"""
def __init__(self, pvsystem=None, location=None, **kwargs):
new_kwargs = _combine_localized_attributes(
pvsystem=pvsystem,
location=location,
**kwargs,
)
SingleAxisTracker.__init__(self, **new_kwargs)
Location.__init__(self, **new_kwargs)
def __repr__(self):
attrs = ['latitude', 'longitude', 'altitude', 'tz']
return ('Localized' +
super(LocalizedSingleAxisTracker, self).__repr__() + '\n ' +
'\n '.join(('{}: {}'.format(attr, getattr(self, attr))
for attr in attrs)))
def singleaxis(apparent_zenith, apparent_azimuth,
axis_tilt=0, axis_azimuth=0, max_angle=90,
backtrack=True, gcr=2.0/7.0):
"""
Determine the rotation angle of a single axis tracker using the
equations in [1]_ when given a particular sun zenith and azimuth
angle. backtracking may be specified, and if so, a ground coverage
ratio is required.
Rotation angle is determined in a panel-oriented coordinate system.
The tracker azimuth axis_azimuth defines the positive y-axis; the
positive x-axis is 90 degress clockwise from the y-axis and parallel
to the earth surface, and the positive z-axis is normal and oriented
towards the sun. Rotation angle tracker_theta indicates tracker
position relative to horizontal: tracker_theta = 0 is horizontal,
and positive tracker_theta is a clockwise rotation around the y axis
in the x, y, z coordinate system. For example, if tracker azimuth
axis_azimuth is 180 (oriented south), tracker_theta = 30 is a
rotation of 30 degrees towards the west, and tracker_theta = -90 is
a rotation to the vertical plane facing east.
Parameters
----------
apparent_zenith : float, 1d array, or Series
Solar apparent zenith angles in decimal degrees.
apparent_azimuth : float, 1d array, or Series
Solar apparent azimuth angles in decimal degrees.
axis_tilt : float, default 0
The tilt of the axis of rotation (i.e, the y-axis defined by
axis_azimuth) with respect to horizontal, in decimal degrees.
axis_azimuth : float, default 0
A value denoting the compass direction along which the axis of
rotation lies. Measured in decimal degrees East of North.
max_angle : float, default 90
A value denoting the maximum rotation angle, in decimal degrees,
of the one-axis tracker from its horizontal position (horizontal
if axis_tilt = 0). A max_angle of 90 degrees allows the tracker
to rotate to a vertical position to point the panel towards a
horizon. max_angle of 180 degrees allows for full rotation.
backtrack : bool, default True
Controls whether the tracker has the capability to "backtrack"
to avoid row-to-row shading. False denotes no backtrack
capability. True denotes backtrack capability.
gcr : float, default 2.0/7.0
A value denoting the ground coverage ratio of a tracker system
which utilizes backtracking; i.e. the ratio between the PV array
surface area to total ground area. A tracker system with modules
2 meters wide, centered on the tracking axis, with 6 meters
between the tracking axes has a gcr of 2/6=0.333. If gcr is not
provided, a gcr of 2/7 is default. gcr must be <=1.
Returns
-------
dict or DataFrame with the following columns:
* `tracker_theta`: The rotation angle of the tracker.
tracker_theta = 0 is horizontal, and positive rotation angles are
clockwise.
* `aoi`: The angle-of-incidence of direct irradiance onto the
rotated panel surface.
* `surface_tilt`: The angle between the panel surface and the earth
surface, accounting for panel rotation.
* `surface_azimuth`: The azimuth of the rotated panel, determined by
projecting the vector normal to the panel's surface to the earth's
surface.
References
----------
.. [1] Lorenzo, E et al., 2011, "Tracking and back-tracking", Prog. in
Photovoltaics: Research and Applications, v. 19, pp. 747-753.
"""
# MATLAB to Python conversion by
# Will Holmgren (@wholmgren), U. Arizona. March, 2015.
if isinstance(apparent_zenith, pd.Series):
index = apparent_zenith.index
else:
index = None
# convert scalars to arrays
apparent_azimuth = np.atleast_1d(apparent_azimuth)
apparent_zenith = np.atleast_1d(apparent_zenith)
if apparent_azimuth.ndim > 1 or apparent_zenith.ndim > 1:
raise ValueError('Input dimensions must not exceed 1')
# Calculate sun position x, y, z using coordinate system as in [1], Eq 2.
# Positive y axis is oriented parallel to earth surface along tracking axis
# (for the purpose of illustration, assume y is oriented to the south);
# positive x axis is orthogonal, 90 deg clockwise from y-axis, and parallel
# to the earth's surface (if y axis is south, x axis is west);
# positive z axis is normal to x, y axes, pointed upward.
# Equations in [1] assume solar azimuth is relative to reference vector
# pointed south, with clockwise positive.
# Here, the input solar azimuth is degrees East of North,
# i.e., relative to a reference vector pointed
# north with clockwise positive.
# Rotate sun azimuth to coordinate system as in [1]
# to calculate sun position.
az = apparent_azimuth - 180
apparent_elevation = 90 - apparent_zenith
x = cosd(apparent_elevation) * sind(az)
y = cosd(apparent_elevation) * cosd(az)
z = sind(apparent_elevation)
# translate array azimuth from compass bearing to [1] coord system
# wholmgren: strange to see axis_azimuth calculated differently from az,
# (not that it matters, or at least it shouldn't...).
axis_azimuth_south = axis_azimuth - 180
# translate input array tilt angle axis_tilt to [1] coordinate system.
# In [1] coordinates, axis_tilt is a rotation about the x-axis.
# For a system with array azimuth (y-axis) oriented south,
# the x-axis is oriented west, and a positive axis_tilt is a
# counterclockwise rotation, i.e, lifting the north edge of the panel.
# Thus, in [1] coordinate system, in the northern hemisphere a positive
# axis_tilt indicates a rotation toward the equator,
# whereas in the southern hemisphere rotation toward the equator is
# indicated by axis_tilt<0. Here, the input axis_tilt is
# always positive and is a rotation toward the equator.
# Calculate sun position (xp, yp, zp) in panel-oriented coordinate system:
# positive y-axis is oriented along tracking axis at panel tilt;
# positive x-axis is orthogonal, clockwise, parallel to earth surface;
# positive z-axis is normal to x-y axes, pointed upward.
# Calculate sun position (xp,yp,zp) in panel coordinates using [1] Eq 11
# note that equation for yp (y' in Eq. 11 of Lorenzo et al 2011) is
# corrected, after conversation with paper's authors.
xp = x*cosd(axis_azimuth_south) - y*sind(axis_azimuth_south)
yp = (x*cosd(axis_tilt)*sind(axis_azimuth_south) +
y*cosd(axis_tilt)*cosd(axis_azimuth_south) -
z*sind(axis_tilt))
zp = (x*sind(axis_tilt)*sind(axis_azimuth_south) +
y*sind(axis_tilt)*cosd(axis_azimuth_south) +
z*cosd(axis_tilt))
# The ideal tracking angle wid is the rotation to place the sun position
# vector (xp, yp, zp) in the (y, z) plane; i.e., normal to the panel and
# containing the axis of rotation. wid = 0 indicates that the panel is
# horizontal. Here, our convention is that a clockwise rotation is
# positive, to view rotation angles in the same frame of reference as
# azimuth. For example, for a system with tracking axis oriented south,
# a rotation toward the east is negative, and a rotation to the west is
# positive.
# Use arctan2 and avoid the tmp corrections.
# angle from x-y plane to projection of sun vector onto x-z plane
# tmp = np.degrees(np.arctan(zp/xp))
# Obtain wid by translating tmp to convention for rotation angles.
# Have to account for which quadrant of the x-z plane in which the sun
# vector lies. Complete solution here but probably not necessary to
# consider QIII and QIV.
# wid = pd.Series(index=times)
# wid[(xp>=0) & (zp>=0)] = 90 - tmp[(xp>=0) & (zp>=0)] # QI
# wid[(xp<0) & (zp>=0)] = -90 - tmp[(xp<0) & (zp>=0)] # QII
# wid[(xp<0) & (zp<0)] = -90 - tmp[(xp<0) & (zp<0)] # QIII
# wid[(xp>=0) & (zp<0)] = 90 - tmp[(xp>=0) & (zp<0)] # QIV
# Calculate angle from x-y plane to projection of sun vector onto x-z plane
# and then obtain wid by translating tmp to convention for rotation angles.
wid = 90 - np.degrees(np.arctan2(zp, xp))
# filter for sun above panel horizon
zen_gt_90 = apparent_zenith > 90
wid[zen_gt_90] = np.nan
# Account for backtracking; modified from [1] to account for rotation
# angle convention being used here.
if backtrack:
axes_distance = 1/gcr
# clip needed for low angles. GH 656
temp = np.clip(axes_distance*cosd(wid), -1, 1)
# backtrack angle
# (always positive b/c acosd returns values between 0 and 180)
wc = np.degrees(np.arccos(temp))
# Eq 4 applied when wid in QIV (wid < 0 evalulates True), QI
with np.errstate(invalid='ignore'):
# errstate for GH 622
tracker_theta = np.where(wid < 0, wid + wc, wid - wc)
else:
tracker_theta = wid
tracker_theta = np.minimum(tracker_theta, max_angle)
tracker_theta = np.maximum(tracker_theta, -max_angle)
# calculate panel normal vector in panel-oriented x, y, z coordinates.
# y-axis is axis of tracker rotation. tracker_theta is a compass angle
# (clockwise is positive) rather than a trigonometric angle.
# the *0 is a trick to preserve NaN values.
panel_norm = np.array([sind(tracker_theta),
tracker_theta*0,
cosd(tracker_theta)])
# sun position in vector format in panel-oriented x, y, z coordinates
sun_vec = np.array([xp, yp, zp])
# calculate angle-of-incidence on panel
aoi = np.degrees(np.arccos(np.abs(np.sum(sun_vec*panel_norm, axis=0))))
# calculate panel tilt and azimuth
# in a coordinate system where the panel tilt is the
# angle from horizontal, and the panel azimuth is
# the compass angle (clockwise from north) to the projection
# of the panel's normal to the earth's surface.
# These outputs are provided for convenience and comparison
# with other PV software which use these angle conventions.
# project normal vector to earth surface.
# First rotate about x-axis by angle -axis_tilt so that y-axis is
# also parallel to earth surface, then project.
# Calculate standard rotation matrix
rot_x = np.array([[1, 0, 0],
[0, cosd(-axis_tilt), -sind(-axis_tilt)],
[0, sind(-axis_tilt), cosd(-axis_tilt)]])
# panel_norm_earth contains the normal vector
# expressed in earth-surface coordinates
# (z normal to surface, y aligned with tracker axis parallel to earth)
panel_norm_earth = np.dot(rot_x, panel_norm).T
# projection to plane tangent to earth surface,
# in earth surface coordinates
projected_normal = np.array([panel_norm_earth[:, 0],
panel_norm_earth[:, 1],
panel_norm_earth[:, 2]*0]).T
# calculate vector magnitudes
projected_normal_mag = np.sqrt(np.nansum(projected_normal**2, axis=1))
# renormalize the projected vector
# avoid creating nan values.
non_zeros = projected_normal_mag != 0
projected_normal[non_zeros] = (projected_normal[non_zeros].T /
projected_normal_mag[non_zeros]).T
# calculation of surface_azimuth
# 1. Find the angle.
# surface_azimuth = pd.Series(
# np.degrees(np.arctan(projected_normal[:,1]/projected_normal[:,0])),
# index=times)
surface_azimuth = \
np.degrees(np.arctan2(projected_normal[:, 1], projected_normal[:, 0]))
# 2. Clean up atan when x-coord or y-coord is zero
# surface_azimuth[(projected_normal[:,0]==0) & (projected_normal[:,1]>0)] = 90
# surface_azimuth[(projected_normal[:,0]==0) & (projected_normal[:,1]<0)] = -90
# surface_azimuth[(projected_normal[:,1]==0) & (projected_normal[:,0]>0)] = 0
# surface_azimuth[(projected_normal[:,1]==0) & (projected_normal[:,0]<0)] = 180
# 3. Correct atan for QII and QIII
# surface_azimuth[(projected_normal[:,0]<0) & (projected_normal[:,1]>0)] += 180 # QII
# surface_azimuth[(projected_normal[:,0]<0) & (projected_normal[:,1]<0)] += 180 # QIII
# 4. Skip to below
# at this point surface_azimuth contains angles between -90 and +270,
# where 0 is along the positive x-axis,
# the y-axis is in the direction of the tracker azimuth,
# and positive angles are rotations from the positive x axis towards
# the positive y-axis.
# Adjust to compass angles
# (clockwise rotation from 0 along the positive y-axis)
# surface_azimuth[surface_azimuth<=90] = 90 - surface_azimuth[surface_azimuth<=90]
# surface_azimuth[surface_azimuth>90] = 450 - surface_azimuth[surface_azimuth>90]
# finally rotate to align y-axis with true north
# PVLIB_MATLAB has this latitude correction,
# but I don't think it's latitude dependent if you always
# specify axis_azimuth with respect to North.
# if latitude > 0 or True:
# surface_azimuth = surface_azimuth - axis_azimuth
# else:
# surface_azimuth = surface_azimuth - axis_azimuth - 180
# surface_azimuth[surface_azimuth<0] = 360 + surface_azimuth[surface_azimuth<0]
# the commented code above is mostly part of PVLIB_MATLAB.
# My (wholmgren) take is that it can be done more simply.
# Say that we're pointing along the postive x axis (likely west).
# We just need to rotate 90 degrees to get from the x axis
# to the y axis (likely south),
# and then add the axis_azimuth to get back to North.
# Anything left over is the azimuth that we want,
# and we can map it into the [0,360) domain.
# 4. Rotate 0 reference from panel's x axis to it's y axis and
# then back to North.
surface_azimuth = 90 - surface_azimuth + axis_azimuth
# 5. Map azimuth into [0,360) domain.
# surface_azimuth[surface_azimuth < 0] += 360
# surface_azimuth[surface_azimuth >= 360] -= 360
surface_azimuth = surface_azimuth % 360
# Calculate surface_tilt
dotproduct = (panel_norm_earth * projected_normal).sum(axis=1)
surface_tilt = 90 - np.degrees(np.arccos(dotproduct))
# Bundle DataFrame for return values and filter for sun below horizon.
out = {'tracker_theta': tracker_theta, 'aoi': aoi,
'surface_azimuth': surface_azimuth, 'surface_tilt': surface_tilt}
if index is not None:
out = pd.DataFrame(out, index=index)
out = out[['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt']]
out[zen_gt_90] = np.nan
else:
out = {k: np.where(zen_gt_90, np.nan, v) for k, v in out.items()}
return out