hefty 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hefty
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Some (relatively) lightweight short-term energy forecasting tools for solar, wind, and load.
5
5
  Author: Will Hobbs
6
6
  License-Expression: BSD-3-Clause
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hefty"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Some (relatively) lightweight short-term energy forecasting tools for solar, wind, and load."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -100,6 +100,10 @@ def model_pv_power(
100
100
  slope_aware_backtracking=True,
101
101
  programmed_cross_axis_slope=pd.NA,
102
102
  altitude=0,
103
+ transient_cell_temp=False,
104
+ k=None,
105
+ cap_adjustment=False,
106
+ horizon_profile=None,
103
107
  **kwargs,
104
108
  ):
105
109
  """
@@ -126,6 +130,20 @@ def model_pv_power(
126
130
  If True, use measured back of module temperature from ``resource_data``
127
131
  (must have column name 'temp_module') in place of modeled cell
128
132
  temperature.
133
+ transient_cell_temp: bool, default False
134
+ If True, apply a 10-minute rolling average to modeled cell temperature.
135
+ This is to account for thermal mass of modules when intervals are less
136
+ than 10 minutes. Based on [2]. Note that this requires uniform,
137
+ monotonic time intervals.
138
+ k: numeric, optional
139
+ Irradiance correction factor, defined in [3]_. Typically positive.
140
+ See pvlib.pvsystem.pvwatt_dc. [unitless]
141
+ cap_adjustment: Boolean, default False
142
+ If True, only apply the optional adjustment at and below 1000 Wm⁻².
143
+ See pvlib.pvsystem.pvwatt_dc.
144
+ horizon_profile: pandas.Series, optional
145
+ Horizon elevation angles, with index of horizon azimuth angles,
146
+ consistent with 'data' output from pvlib.iotools.get_pvgis_horizon.
129
147
 
130
148
  Returns
131
149
  -------
@@ -140,11 +158,22 @@ def model_pv_power(
140
158
  ----------
141
159
  .. [1] William Hobbs, pv-plant-specification-rev4.csv,
142
160
  https://github.com/williamhobbs/pv-plant-specifications
161
+ .. [2] EPRI, Jonathan Allen, Improved PV Plant Energy Production Prediction
162
+ (Phases 1 and 2), 2022.
163
+ https://www.epri.com/research/products/000000003002018708
164
+ .. [3] B. Marion, "Comparison of Predictive Models for Photovoltaic
165
+ Module Performance," In Proc. 33rd IEEE Photovoltaic Specialists
166
+ Conference (PVSC), San Diego, CA, USA, 2008, pp. 1-6,
167
+ :doi:`10.1109/PVSC.2008.4922586`.
168
+ Pre-print: https://docs.nrel.gov/docs/fy08osti/42511.pdf
143
169
  """
144
170
 
145
171
  # ========================================================================
146
172
  # Inputs and Basic Geometry
147
173
  # ========================================================================
174
+ # make a copy to avoid mutability issues
175
+ resource_data = resource_data.copy()
176
+
148
177
  # Fill in some necessary variables with defaults if there is no value
149
178
  # provided.
150
179
  # backtracking settings: default to AM/PM settings, then generic
@@ -244,6 +273,29 @@ def model_pv_power(
244
273
  max_angle=max_tracker_angle,
245
274
  backtrack=backtrack)
246
275
 
276
+ # add 'is_truetracking' and 'is_backtracking' booleans to output.
277
+ # See https://github.com/pvlib/pvlib-python/issues/2672
278
+ if backtrack:
279
+ tr_true = pvlib.tracking.singleaxis(
280
+ solar_position.apparent_zenith,
281
+ solar_position.azimuth,
282
+ gcr=programmed_gcr,
283
+ axis_tilt=axis_tilt,
284
+ axis_azimuth=axis_azimuth,
285
+ cross_axis_tilt=programmed_cross_axis_slope,
286
+ max_angle=max_tracker_angle,
287
+ backtrack=False)
288
+ is_truetr = ((tr.tracker_theta == tr_true.tracker_theta) &
289
+ (tr.tracker_theta.abs() < max_tracker_angle))
290
+ is_backtr = ((~is_truetr) &
291
+ (tr.tracker_theta.abs() < max_tracker_angle))
292
+ resource_data['is_truetracking'] = is_truetr
293
+ resource_data['is_backtracking'] = is_backtr
294
+ else:
295
+ is_truetr = (tr.tracker_theta.abs() < max_tracker_angle)
296
+ resource_data['is_truetracking'] = is_truetr
297
+ resource_data['is_backtracking'] = False
298
+
247
299
  # calculate shading with slope
248
300
  fs_array = pvlib.shading.shaded_fraction1d(
249
301
  solar_position.apparent_zenith,
@@ -295,7 +347,7 @@ def model_pv_power(
295
347
  solar_position.azimuth)
296
348
 
297
349
  # ========================================================================
298
- # Modeled POA
350
+ # DHI
299
351
  # ========================================================================
300
352
  if 'dhi' not in resource_data:
301
353
  print('calculating dhi')
@@ -304,6 +356,33 @@ def model_pv_power(
304
356
  resource_data['dhi'] = (resource_data.ghi - resource_data.dni *
305
357
  pvlib.tools.cosd(solar_position.zenith))
306
358
 
359
+ # ========================================================================
360
+ # Horizon shade
361
+ # ========================================================================
362
+ # based on https://pvlib-python.readthedocs.io/en/v0.15.0/gallery/shading/plot_simple_irradiance_adjustment_for_horizon_shading.html
363
+ if horizon_profile is not None:
364
+ # interpolate horizon profile at each azimuth
365
+ horizon_elevation_data = np.interp(solar_position.azimuth,
366
+ horizon_profile.index,
367
+ horizon_profile
368
+ )
369
+ # convert back to a series
370
+ horizon_elevation_data = pd.Series(horizon_elevation_data, times)
371
+ # set dni to zero when the sun is below the horizon profile
372
+ dni_adjusted = np.where((solar_position.elevation >
373
+ horizon_elevation_data),
374
+ resource_data['dni'], 0)
375
+ # set ghi to be equal to dhi when the sun is below the horizon profile
376
+ ghi_adjusted = np.where(dni_adjusted == 0,
377
+ resource_data['dhi'],
378
+ resource_data['ghi'])
379
+ # write new dhi and ghi values to the resource_data dataframe
380
+ resource_data['dni'] = dni_adjusted
381
+ resource_data['ghi'] = ghi_adjusted
382
+
383
+ # ========================================================================
384
+ # Modeled POA
385
+ # ========================================================================
307
386
  # dni
308
387
  dni_extra = pvlib.irradiance.get_extra_radiation(resource_data.index)
309
388
 
@@ -332,8 +411,8 @@ def model_pv_power(
332
411
  poa_total_without_direct_shade, aoi,
333
412
  solar_position.apparent_zenith, solar_position.azimuth,
334
413
  times, surface_tilt, surface_azimuth)
335
- poa_direct_unshaded = irrad_dirint['dni'] # output of gti_dirint
336
- poa_diffuse_unshaded = irrad_dirint['dhi'] # output of gti_dirint
414
+ poa_direct_unshaded = irrad_dirint['dni'] # output of gti_dirint
415
+ poa_diffuse_unshaded = irrad_dirint['dhi'] # output of gti_dirint
337
416
  else:
338
417
  # work backwards to unshaded direct irradiance for the array:
339
418
  # poa_direct_unshaded = total_irrad.poa_direct / (1-fs_array)
@@ -412,8 +491,7 @@ def model_pv_power(
412
491
  poa_total_with_direct_shade = (((1-fs) * poa_direct_unshaded.values) +
413
492
  total_irrad['poa_diffuse'].values)
414
493
  # diffuse fraction
415
- fd = (total_irrad['poa_diffuse'].values /
416
- poa_total_without_direct_shade.values)
494
+ fd = (total_irrad['poa_diffuse'] / total_irrad['poa_global']).values
417
495
 
418
496
  # calculate shade loss for each course/string
419
497
  if shade_loss_model == 'linear':
@@ -426,6 +504,28 @@ def model_pv_power(
426
504
  poa_front_effective = ((1 - shade_loss) *
427
505
  poa_total_without_direct_shade.values)
428
506
 
507
+ # ========================================================================
508
+ # Non-linear irradiance response
509
+ # ========================================================================
510
+ # apply Marion's correction if k is provided
511
+ # similar to pvlib.pvsystem.pvwatts_dc, but applied here to front side
512
+ # irradiance instead of dc power
513
+ if k is not None:
514
+ # rename variables
515
+ effective_irradiance = poa_front_effective
516
+
517
+ # calculate error adjustments
518
+ err_1 = k * (1 - (1 - effective_irradiance / 200)**4)
519
+ err_2 = k * (1000 - effective_irradiance) / (1000 - 200)
520
+ err = np.where(effective_irradiance <= 200, err_1, err_2)
521
+
522
+ # cap adjustment, if needed
523
+ if cap_adjustment:
524
+ err = np.where(effective_irradiance >= 1000, 0, err)
525
+
526
+ # make error adjustment
527
+ poa_front_effective = poa_front_effective - 1000 * err
528
+
429
529
  # ========================================================================
430
530
  # Temperature
431
531
  # ========================================================================
@@ -439,23 +539,18 @@ def model_pv_power(
439
539
  resource_data['wind_speed']).values
440
540
  for n in range(eff_row_side_num_mods)])
441
541
 
442
- # apply rolling 10-min avg if interval is less than 10 min
443
- # based on https://www.epri.com/research/products/000000003002018708
444
- # sample_interval, samples_per_window = pvlib.tools._get_sample_intervals(
445
- # times=resource_data.index, win_length=10)
446
- # based on pvlib.tools._get_sample_intervals, but not exactly the same,
447
- # because it doesn't work when there are gaps
448
- sample_interval = resource_data.index[1] - resource_data.index[0]
449
- sample_interval = sample_interval.seconds / 60 # in minutes
450
- win_length = 10
451
- samples_per_window = int(win_length / sample_interval)
452
- if sample_interval < 10:
453
- N = samples_per_window
454
- # based on https://stackoverflow.com/a/47490020/27574852
455
- t_cell_modeled = np.array([
456
- np.convolve(
457
- t_cell_modeled[n], np.ones((N,))/N, 'same')
458
- for n in range(eff_row_side_num_mods)])
542
+ if transient_cell_temp is True:
543
+ # apply rolling 10-min avg if interval is less than 10 min
544
+ # based on https://www.epri.com/research/products/000000003002018708
545
+ sample_int, samples_per_window = pvlib.tools._get_sample_intervals(
546
+ times=resource_data.index, win_length=10)
547
+ if sample_int < 10:
548
+ N = samples_per_window
549
+ # based on https://stackoverflow.com/a/47490020/27574852
550
+ t_cell_modeled = np.array([
551
+ np.convolve(
552
+ t_cell_modeled[n], np.ones((N,))/N, 'same')
553
+ for n in range(eff_row_side_num_mods)])
459
554
 
460
555
  if use_measured_temp_module is True:
461
556
  # use measured module temperature - repeat the single timeseries
@@ -568,6 +663,9 @@ def model_pv_power(
568
663
  pdc_inv_total.fillna(0, inplace=True)
569
664
  resource_data.fillna(0, inplace=True)
570
665
 
666
+ # add dc power to output
667
+ resource_data['pdc'] = pdc_inv_total
668
+
571
669
  # ac power with PVWatts inverter model
572
670
  power_ac = pvlib.inverter.pvwatts(pdc_inv_total, pdc0, eta_inv_nom)
573
671
 
@@ -1487,6 +1487,31 @@ def get_solar_forecast_ensemble(latitude, longitude, init_date, run_length,
1487
1487
  priority=priority)
1488
1488
  FH.download(search_str)
1489
1489
  ds = FH.xarray(search_str, remove_grib=False)
1490
+ # check for missing members. if any, raise error
1491
+ # fixes GH #28
1492
+ # see https://github.com/williamhobbs/hefty/issues/28 for
1493
+ # details
1494
+ for data_var in ds.data_vars:
1495
+ # count of valid values in each step/number
1496
+ # combination (slicing along lat/lon plane)
1497
+ c_v = (
1498
+ ds.count(dim=['latitude', 'longitude'])[data_var].
1499
+ values)
1500
+ num_missing_members = (np.count_nonzero(c_v == 0))
1501
+ if num_missing_members > 0:
1502
+ # indices of steps w/ missing members
1503
+ steps_idx = (
1504
+ [i for i, sublist in enumerate(c_v) if 0 in
1505
+ sublist])
1506
+ # fxx values
1507
+ fxx_vals = ((ds['step'].values[steps_idx] /
1508
+ np.timedelta64(1, 'h')).
1509
+ astype(int).tolist())
1510
+ msg = (f'{num_missing_members} members appear to '
1511
+ f'be missing for init_date {init_date}, fxx'
1512
+ f' values {fxx_vals}')
1513
+ print(msg)
1514
+ raise ValueError(msg)
1490
1515
  else:
1491
1516
  # after first attempt, set overwrite=True to overwrite
1492
1517
  # partial files
@@ -1498,6 +1523,28 @@ def get_solar_forecast_ensemble(latitude, longitude, init_date, run_length,
1498
1523
  priority=priority)
1499
1524
  FH.download(search_str, overwrite=True)
1500
1525
  ds = FH.xarray(search_str, remove_grib=False)
1526
+ # check for missing members again
1527
+ for data_var in ds.data_vars:
1528
+ # count of valid values in each step/number
1529
+ # combination (slicing along lat/lon plane)
1530
+ c_v = (
1531
+ ds.count(dim=['latitude', 'longitude'])[data_var].
1532
+ values)
1533
+ num_missing_members = (np.count_nonzero(c_v == 0))
1534
+ if num_missing_members > 0:
1535
+ # indices of steps w/ missing members
1536
+ steps_idx = (
1537
+ [i for i, sublist in enumerate(c_v) if 0 in
1538
+ sublist])
1539
+ # fxx values
1540
+ fxx_vals = ((ds['step'].values[steps_idx] /
1541
+ np.timedelta64(1, 'h')).
1542
+ astype(int).tolist())
1543
+ msg = (f'{num_missing_members} members appear to '
1544
+ f'be missing for init_date {init_date}, fxx'
1545
+ f' values {fxx_vals}')
1546
+ print(msg)
1547
+ raise ValueError(msg)
1501
1548
  except Exception as e:
1502
1549
  print(e)
1503
1550
  if attempts_remaining:
@@ -82,7 +82,7 @@ def get_fcast_definition(model='gfs'):
82
82
  '2024-11-12 12:00',
83
83
  '2024-11-12 06:00'],
84
84
  'start_hour': [0, 150, 0],
85
- 'end_hour': [144, 360, 90],
85
+ 'end_hour': [144, 360, 144],
86
86
  'interval': [3, 6, 3],
87
87
  'first_cycle': [0, 0, 6],
88
88
  'update_period': [12, 12, 12],
@@ -122,13 +122,16 @@ def get_fcast_definition(model='gfs'):
122
122
  # IFS ens does not have ssrd until sometime March 2024. '2024-03-12 12:00'
123
123
  # was the first init_date used in https://github.com/williamhobbs/PVSC-2025-daily-energy-forecaster,
124
124
  # so start there for now.
125
- # delays based on https://dynamical.org/status/ as of 2026-04-24
125
+ # delays based on https://dynamical.org/status/ as of 2026-04-24.
126
+ # From https://www.ecmwf.int/en/forecasts/datasets/open-data,
127
+ # For times 00z &12z: 0 to 144 by 3, 150 to 360 by 6.
128
+ # For times 06z & 18z: 0 to 144 by 3.
126
129
  fcast_sched_dict_ifs_ens_1 = {
127
130
  'start_date': ['2024-03-10 12:00',
128
131
  '2024-03-10 12:00',
129
132
  '2024-03-10 18:00'], # https://herbie.readthedocs.io/en/stable/gallery/ecmwf_models/ecmwf.html#Data-Availability
130
133
  'start_hour': [0, 150, 0],
131
- 'end_hour': [144, 240, 90],
134
+ 'end_hour': [144, 240, 144],
132
135
  'interval': [3, 6, 3],
133
136
  'first_cycle': [0, 0, 6],
134
137
  'update_period': [12, 12, 12],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hefty
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Some (relatively) lightweight short-term energy forecasting tools for solar, wind, and load.
5
5
  Author: Will Hobbs
6
6
  License-Expression: BSD-3-Clause
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes