|
| 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 |
0 commit comments