Skip to content

Commit c90cb4e

Browse files
authored
Add gallery example for OEDI system 9068 (#1985)
* add example script and static plot images * configure sphinx-gallery to not execute this one * lint * whatsnew * fix backticks issue * updates from review
1 parent 92c0e5b commit c90cb4e

File tree

7 files changed

+288
-2
lines changed

7 files changed

+288
-2
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
System Models
2+
-------------
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
"""
2+
4.7 MW CdTe single-axis tracking (OEDI System 9068)
3+
===================================================
4+
5+
A basic model of a 4.7 MW single-axis tracking CdTe system located in
6+
Colorado, United States.
7+
"""
8+
9+
# %%
10+
# This example model uses satellite-based solar resource data from the
11+
# NSRDB PSM3. This approach is useful for pre-construction energy modeling
12+
# and in retrospective analyses where the system’s own irradiance
13+
# measurements are not present or unreliable.
14+
#
15+
# The system has public monitoring data available at the Open Energy Data
16+
# Initiative (OEDI) under `System ID
17+
# 9068 <https://data.openei.org/s3_viewer?bucket=oedi-data-lake&prefix=pvdaq%2F2023-solar-data-prize%2F9068_OEDI%2F>`__.
18+
# For more information about the system, see its `OEDI
19+
# page <https://openei.org/wiki/PVDAQ/Sites/SR_CO>`__.
20+
21+
import pvlib
22+
import pandas as pd
23+
import matplotlib.pyplot as plt
24+
25+
# %%
26+
# System parameters
27+
# -----------------
28+
#
29+
# The system description on the OEDI provides some high-level system
30+
# information, but unfortunately we have to make some guesses about other
31+
# aspects of the system’s configuration.
32+
#
33+
# The cells below define the system parameter values required in the
34+
# simulation.
35+
36+
# information provided by system description on OEDI
37+
38+
latitude = 40.3864
39+
longitude = -104.5512
40+
41+
# the inverters have identical PV array topologies:
42+
modules_per_string = 15
43+
strings_per_inverter = 1344
44+
45+
# %%
46+
47+
# "unofficial" information
48+
49+
# We know the system uses 117.5 W CdTe modules. Based on the system vintage
50+
# (data begins in 2017), it seems likely that the array uses First Solar
51+
# Series 4 modules (FS-4117).
52+
cec_module_db = pvlib.pvsystem.retrieve_sam('cecmod')
53+
module_parameters = cec_module_db['First_Solar__Inc__FS_4117_3']
54+
# ensure that correct spectral correction is applied
55+
module_parameters['Technology'] = 'CdTe'
56+
57+
# default Faiman model parameters:
58+
temperature_model_parameters = dict(u0=25.0, u1=6.84)
59+
module_unit_mass = 12 / 0.72 # kg/m^2, taken from datasheet values
60+
61+
# The OEDI metadata says the inverters have AC capacities of 1910 kW,
62+
# but the clipping level in the measured inverter output is more like 1840 kW.
63+
# It's not clear what specific model is installed, so let's just assume
64+
# this inverter, which the CEC database lists as having a nominal AC
65+
# capacity of 1833 kW:
66+
cec_inverter_db = pvlib.pvsystem.retrieve_sam('cecinverter')
67+
inverter_parameters = cec_inverter_db['TMEIC__PVL_L1833GRM']
68+
69+
# We'll use the PVWatts v5 losses model. Set shading to zero as it is
70+
# accounted for elsewhere in the model, and disable availability loss since
71+
# we want a "clean" simulation.
72+
# Leaving the other pvwatts loss types (mismatch, wiring, etc) unspecified
73+
# causes them to take their default values.
74+
losses_parameters = dict(shading=0, availability=0)
75+
76+
# Google Street View images show that each row is four modules high, in
77+
# landscape orientation. Assuming the modules are First Solar Series 4,
78+
# each of them is 600 mm wide.
79+
# Assume ~1 centimeter gap between modules (three gaps total).
80+
# And from Google Earth, the array's pitch is estimated to be about 7.0 meters.
81+
# From these we calculate the ground coverage ratio (GCR):
82+
pitch = 7 # meters
83+
gcr = (4 * 0.6 + 3 * 0.01) / pitch
84+
85+
# The tracker rotation measurements reveal that the tracker rotation limits
86+
# are +/- 60 degrees, and backtracking is not enabled:
87+
max_angle = 60 # degrees
88+
backtrack = False
89+
90+
# Google Earth shows that the tracker axes are very close to north-south:
91+
axis_azimuth = 180 # degrees
92+
93+
# Estimated from Google Street View images
94+
axis_height = 1.5 # meters
95+
96+
# %%
97+
# Create system objects
98+
# ---------------------
99+
#
100+
# The system has two inverters which seem to have identical specifications
101+
# and arrays. To save some code and computation repetition, we will just
102+
# model one inverter.
103+
104+
location = pvlib.location.Location(latitude, longitude)
105+
mount = pvlib.pvsystem.SingleAxisTrackerMount(
106+
gcr=gcr,
107+
backtrack=backtrack,
108+
max_angle=max_angle,
109+
axis_azimuth=axis_azimuth
110+
)
111+
array = pvlib.pvsystem.Array(
112+
mount,
113+
module_parameters=module_parameters,
114+
modules_per_string=modules_per_string,
115+
temperature_model_parameters=temperature_model_parameters,
116+
strings=strings_per_inverter
117+
)
118+
system = pvlib.pvsystem.PVSystem(
119+
array,
120+
inverter_parameters=inverter_parameters,
121+
losses_parameters=losses_parameters
122+
)
123+
124+
model = pvlib.modelchain.ModelChain(
125+
system,
126+
location,
127+
spectral_model='first_solar',
128+
aoi_model='physical',
129+
losses_model='pvwatts'
130+
)
131+
132+
# %%
133+
# Fetch weather data
134+
# ------------------
135+
#
136+
# The system does have measured plane-of-array irradiance data, but the
137+
# measurements suffer from row-to-row shading and tracker stalls. In this
138+
# example, we will use weather data taken from the NSRDB PSM3 for the year
139+
# 2019.
140+
141+
api_key = 'DEMO_KEY'
142+
143+
144+
keys = ['ghi', 'dni', 'dhi', 'temp_air', 'wind_speed',
145+
'albedo', 'precipitable_water']
146+
psm3, psm3_metadata = pvlib.iotools.get_psm3(latitude, longitude, api_key,
147+
email, interval=5, names=2019,
148+
map_variables=True, leap_day=True,
149+
attributes=keys)
150+
151+
# %%
152+
# Pre-generate some model inputs
153+
# ------------------------------
154+
#
155+
# This system’s trackers are configured to not backtrack, meaning the
156+
# array shades itself when the sun is low in the sky. pvlib’s
157+
# ``ModelChain`` currently has no shade modeling ability, so we will model
158+
# it separately.
159+
#
160+
# Since this system uses thin-film modules, oriented in such a way that
161+
# row-to-row shadows affect each cell in the module equally, we can assume
162+
# that the effect of shading is linear with the reduction in incident beam
163+
# irradiance. That means we can use pvlib’s infinite sheds model, which
164+
# penalizes incident beam irradiance according to the calculated shaded
165+
# module fraction and returns the average irradiance over the total module
166+
# surface.
167+
168+
solar_position = location.get_solarposition(psm3.index, latitude, longitude)
169+
tracker_angles = mount.get_orientation(
170+
solar_position['apparent_zenith'],
171+
solar_position['azimuth']
172+
)
173+
dni_extra = pvlib.irradiance.get_extra_radiation(psm3.index)
174+
175+
# note: this system is monofacial, so only calculate irradiance for the
176+
# front side:
177+
averaged_irradiance = pvlib.bifacial.infinite_sheds.get_irradiance_poa(
178+
tracker_angles['surface_tilt'], tracker_angles['surface_azimuth'],
179+
solar_position['apparent_zenith'], solar_position['azimuth'],
180+
gcr, axis_height, pitch,
181+
psm3['ghi'], psm3['dhi'], psm3['dni'], psm3['albedo'],
182+
model='haydavies', dni_extra=dni_extra,
183+
)
184+
185+
# %%
186+
# ``ModelChain`` does not consider thermal transience either, so since we
187+
# are using 5-minute weather data, we will precalculate the cell
188+
# temperature as well:
189+
190+
cell_temperature_steady_state = pvlib.temperature.faiman(
191+
poa_global=averaged_irradiance['poa_global'],
192+
temp_air=psm3['temp_air'],
193+
wind_speed=psm3['wind_speed'],
194+
**temperature_model_parameters,
195+
)
196+
197+
cell_temperature = pvlib.temperature.prilliman(
198+
cell_temperature_steady_state,
199+
psm3['wind_speed'],
200+
unit_mass=module_unit_mass
201+
)
202+
203+
# %%
204+
# Run the model
205+
# -------------
206+
#
207+
# Finally, we are ready to run the rest of the system model. Since we want
208+
# to use pre-calculated plane-of-array irradiance, we will use
209+
# :py:meth:`~pvlib.modelchain.ModelChain.run_model_from_poa`:
210+
211+
weather_inputs = pd.DataFrame({
212+
'poa_global': averaged_irradiance['poa_global'],
213+
'poa_direct': averaged_irradiance['poa_direct'],
214+
'poa_diffuse': averaged_irradiance['poa_diffuse'],
215+
'cell_temperature': cell_temperature,
216+
'precipitable_water': psm3['precipitable_water'], # for the spectral model
217+
})
218+
model.run_model_from_poa(weather_inputs)
219+
220+
221+
# %%
222+
# Compare with measured production
223+
# --------------------------------
224+
#
225+
# Now, let’s compare our modeled AC power with the system’s actual
226+
# inverter-level AC power measurements:
227+
228+
fn = r"path/to/9068_ac_power_data.csv"
229+
df_inverter_measured = pd.read_csv(fn, index_col=0, parse_dates=True)
230+
df_inverter_measured = df_inverter_measured.tz_localize('US/Mountain',
231+
ambiguous='NaT',
232+
nonexistent='NaT')
233+
# convert to standard time to match the NSRDB-based simulation
234+
df_inverter_measred = df_inverter_measured.tz_convert('Etc/GMT+7')
235+
236+
# %%
237+
238+
inverter_ac_powers = [
239+
'inverter_1_ac_power_(kw)_inv_150143',
240+
'inverter_2_ac_power_(kw)_inv_150144'
241+
]
242+
df = df_inverter_measured.loc['2019', inverter_ac_powers]
243+
df['model'] = model.results.ac / 1000 # convert W to kW
244+
245+
# %%
246+
247+
for column_name in inverter_ac_powers:
248+
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
249+
df.plot.scatter('model', column_name, ax=axes[0], s=1, alpha=0.1)
250+
axes[0].axline((0, 0), slope=1, c='k')
251+
axes[0].set_ylabel('Measured 5-min power [kW]')
252+
axes[0].set_xlabel('Modeled 5-min power [kW]')
253+
254+
hourly_average = df.resample('h').mean()
255+
hourly_average.plot.scatter('model', column_name, ax=axes[1], s=2)
256+
axes[1].axline((0, 0), slope=1, c='k')
257+
axes[1].set_ylabel('Measured hourly energy [kWh]')
258+
axes[1].set_xlabel('Modeled hourly energy [kWh]')
259+
260+
daily_total = hourly_average.resample('d').sum()
261+
daily_total.plot.scatter('model', column_name, ax=axes[2], s=5)
262+
axes[2].axline((0, 0), slope=1, c='k')
263+
axes[2].set_ylabel('Measured daily energy [kWh]')
264+
axes[2].set_xlabel('Modeled daily energy [kWh]')
265+
266+
fig.suptitle(column_name)
267+
fig.tight_layout()
268+
269+
270+
# %%
271+
# .. image:: ../../_images/OEDI_9068_inverter1_comparison.png
272+
#
273+
# .. image:: ../../_images/OEDI_9068_inverter2_comparison.png
274+
275+
fig, ax = plt.subplots(figsize=(12, 4))
276+
daily_energy = df.clip(lower=0).resample('h').mean().resample('d').sum()
277+
daily_energy.plot(ax=ax)
278+
plt.ylim(bottom=0)
279+
plt.ylabel('Daily Production [kWh]')
280+
plt.tight_layout()
281+
282+
# %%
283+
# .. image:: ../../_images/OEDI_9068_daily_timeseries.png
Loading
Loading
Loading

docs/sphinx/source/conf.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,8 @@ def setup(app):
357357
sphinx_gallery_conf = {
358358
'examples_dirs': ['../../examples'], # location of gallery scripts
359359
'gallery_dirs': ['gallery'], # location of generated output
360-
# sphinx-gallery only shows plots from plot_*.py files by default:
361-
# 'filename_pattern': '*.py',
360+
# execute all scripts except for ones in the "system-models" directory:
361+
'filename_pattern': '^((?!system-models).)*$',
362362

363363
# directory where function/class granular galleries are stored
364364
'backreferences_dir': 'reference/generated/gallery_backreferences',

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Documentation
4949
* Improved references and description for :py:func:`~pvlib.irradiance.get_ground_diffuse`. (:pull:`1953`)
5050
* Fixed broken URLs in various places. (:pull:`1957`, :pull:`1960`)
5151
* Clarified documentation for :py:func:`~pvlib.irradiance.get_ground_diffuse`. (:pull:`1883`)
52+
* Added a gallery example with a model for OEDI system 9068. (:pull:`1985`)
5253

5354

5455
Requirements

0 commit comments

Comments
 (0)