|
10 | 10 |
|
11 | 11 | import numpy as np
|
12 | 12 | import pandas as pd
|
| 13 | +import functools |
13 | 14 | from pvlib.tools import cosd, sind, tand, asind
|
14 | 15 |
|
15 | 16 | # a dict of required parameter names for each IAM model
|
@@ -527,3 +528,221 @@ def sapm(aoi, module, upper=None):
|
527 | 528 | iam = pd.Series(iam, aoi.index)
|
528 | 529 |
|
529 | 530 | return iam
|
| 531 | + |
| 532 | + |
| 533 | +def marion_diffuse(model, surface_tilt, **kwargs): |
| 534 | + """ |
| 535 | + Determine diffuse irradiance incidence angle modifiers using Marion's |
| 536 | + method of integrating over solid angle. |
| 537 | +
|
| 538 | + Parameters |
| 539 | + ---------- |
| 540 | + model : str |
| 541 | + The IAM function to evaluate across solid angle. Must be one of |
| 542 | + `'ashrae', 'physical', 'martin_ruiz', 'sapm'`. |
| 543 | +
|
| 544 | + surface_tilt : numeric |
| 545 | + Surface tilt angles in decimal degrees. |
| 546 | + The tilt angle is defined as degrees from horizontal |
| 547 | + (e.g. surface facing up = 0, surface facing horizon = 90). |
| 548 | +
|
| 549 | + **kwargs |
| 550 | + Extra parameters passed to the IAM function. |
| 551 | +
|
| 552 | + Returns |
| 553 | + ------- |
| 554 | + iam : dict |
| 555 | + IAM values for each type of diffuse irradiance: |
| 556 | +
|
| 557 | + * 'sky': radiation from the sky dome (zenith <= 90) |
| 558 | + * 'horizon': radiation from the region of the sky near the horizon |
| 559 | + (89.5 <= zenith <= 90) |
| 560 | + * 'ground': radiation reflected from the ground (zenith >= 90) |
| 561 | +
|
| 562 | + See [1]_ for a detailed description of each class. |
| 563 | +
|
| 564 | + See Also |
| 565 | + -------- |
| 566 | + pvlib.iam.marion_integrate |
| 567 | +
|
| 568 | + References |
| 569 | + ---------- |
| 570 | + .. [1] B. Marion "Numerical method for angle-of-incidence correction |
| 571 | + factors for diffuse radiation incident photovoltaic modules", |
| 572 | + Solar Energy, Volume 147, Pages 344-348. 2017. |
| 573 | + DOI: 10.1016/j.solener.2017.03.027 |
| 574 | +
|
| 575 | + Examples |
| 576 | + -------- |
| 577 | + >>> marion_diffuse('physical', surface_tilt=20) |
| 578 | + {'sky': 0.9539178294437575, |
| 579 | + 'horizon': 0.7652650139134007, |
| 580 | + 'ground': 0.6387140117795903} |
| 581 | +
|
| 582 | + >>> marion_diffuse('ashrae', [20, 30], b=0.04) |
| 583 | + {'sky': array([0.96748999, 0.96938408]), |
| 584 | + 'horizon': array([0.86478428, 0.91825792]), |
| 585 | + 'ground': array([0.77004435, 0.8522436 ])} |
| 586 | + """ |
| 587 | + |
| 588 | + models = { |
| 589 | + 'physical': physical, |
| 590 | + 'ashrae': ashrae, |
| 591 | + 'sapm': sapm, |
| 592 | + 'martin_ruiz': martin_ruiz, |
| 593 | + } |
| 594 | + |
| 595 | + try: |
| 596 | + iam_model = models[model] |
| 597 | + except KeyError: |
| 598 | + raise ValueError('model must be one of: ' + str(list(models.keys()))) |
| 599 | + |
| 600 | + iam_function = functools.partial(iam_model, **kwargs) |
| 601 | + iam = {} |
| 602 | + for region in ['sky', 'horizon', 'ground']: |
| 603 | + iam[region] = marion_integrate(iam_function, surface_tilt, region) |
| 604 | + |
| 605 | + return iam |
| 606 | + |
| 607 | + |
| 608 | +def marion_integrate(function, surface_tilt, region, num=None): |
| 609 | + """ |
| 610 | + Integrate an incidence angle modifier (IAM) function over solid angle |
| 611 | + to determine a diffuse irradiance correction factor using Marion's method. |
| 612 | +
|
| 613 | + This lower-level function actually performs the IAM integration for the |
| 614 | + specified solid angle region. |
| 615 | +
|
| 616 | + Parameters |
| 617 | + ---------- |
| 618 | + function : callable(aoi) |
| 619 | + The IAM function to evaluate across solid angle. The function must |
| 620 | + be vectorized and take only one parameter, the angle of incidence in |
| 621 | + degrees. |
| 622 | +
|
| 623 | + surface_tilt : numeric |
| 624 | + Surface tilt angles in decimal degrees. |
| 625 | + The tilt angle is defined as degrees from horizontal |
| 626 | + (e.g. surface facing up = 0, surface facing horizon = 90). |
| 627 | +
|
| 628 | + region : {'sky', 'horizon', 'ground'} |
| 629 | + The region to integrate over. Must be one of: |
| 630 | +
|
| 631 | + * 'sky': radiation from the sky dome (zenith <= 90) |
| 632 | + * 'horizon': radiation from the region of the sky near the horizon |
| 633 | + (89.5 <= zenith <= 90) |
| 634 | + * 'ground': radiation reflected from the ground (zenith >= 90) |
| 635 | +
|
| 636 | + See [1]_ for a detailed description of each class. |
| 637 | +
|
| 638 | + num : int, optional |
| 639 | + The number of increments in the zenith integration. |
| 640 | + If not specified, N will follow the values used in [1]_: |
| 641 | +
|
| 642 | + * 'sky' or 'ground': num = 180 |
| 643 | + * 'horizon': num = 1800 |
| 644 | +
|
| 645 | + Returns |
| 646 | + ------- |
| 647 | + iam : numeric |
| 648 | + AOI diffuse correction factor for the specified region. |
| 649 | +
|
| 650 | + See Also |
| 651 | + -------- |
| 652 | + pvlib.iam.marion_diffuse |
| 653 | +
|
| 654 | + References |
| 655 | + ---------- |
| 656 | + .. [1] B. Marion "Numerical method for angle-of-incidence correction |
| 657 | + factors for diffuse radiation incident photovoltaic modules", |
| 658 | + Solar Energy, Volume 147, Pages 344-348. 2017. |
| 659 | + DOI: 10.1016/j.solener.2017.03.027 |
| 660 | +
|
| 661 | + Examples |
| 662 | + -------- |
| 663 | + >>> marion_integrate(pvlib.iam.ashrae, 20, 'sky') |
| 664 | + 0.9596085829811408 |
| 665 | +
|
| 666 | + >>> from functools import partial |
| 667 | + >>> f = partial(pvlib.iam.physical, n=1.3) |
| 668 | + >>> marion_integrate(f, [20, 30], 'sky') |
| 669 | + array([0.96225034, 0.9653219 ]) |
| 670 | + """ |
| 671 | + |
| 672 | + if num is None: |
| 673 | + if region in ['sky', 'ground']: |
| 674 | + num = 180 |
| 675 | + elif region == 'horizon': |
| 676 | + num = 1800 |
| 677 | + else: |
| 678 | + raise ValueError('Invalid region: {}'.format(region)) |
| 679 | + |
| 680 | + beta = np.radians(surface_tilt) |
| 681 | + if isinstance(beta, pd.Series): |
| 682 | + # convert Series to np array for broadcasting later |
| 683 | + beta = beta.values |
| 684 | + ai = np.pi/num # angular increment |
| 685 | + |
| 686 | + phi_range = np.linspace(0, np.pi, num, endpoint=False) |
| 687 | + psi_range = np.linspace(0, 2*np.pi, 2*num, endpoint=False) |
| 688 | + |
| 689 | + # the pseudocode in [1] do these checks at the end, but it's |
| 690 | + # faster to do this criteria check up front instead of later. |
| 691 | + if region == 'sky': |
| 692 | + mask = phi_range + ai <= np.pi/2 |
| 693 | + elif region == 'horizon': |
| 694 | + lo = 89.5 * np.pi/180 |
| 695 | + hi = np.pi/2 |
| 696 | + mask = (lo <= phi_range) & (phi_range + ai <= hi) |
| 697 | + elif region == 'ground': |
| 698 | + mask = (phi_range >= np.pi/2) |
| 699 | + else: |
| 700 | + raise ValueError('Invalid region: {}'.format(region)) |
| 701 | + phi_range = phi_range[mask] |
| 702 | + |
| 703 | + # fast Cartesian product of phi and psi |
| 704 | + angles = np.array(np.meshgrid(phi_range, psi_range)).T.reshape(-1, 2) |
| 705 | + # index with single-element lists to maintain 2nd dimension so that |
| 706 | + # these angle arrays broadcast across the beta array |
| 707 | + phi_1 = angles[:, [0]] |
| 708 | + psi_1 = angles[:, [1]] |
| 709 | + phi_2 = phi_1 + ai |
| 710 | + # psi_2 = psi_1 + ai # not needed |
| 711 | + phi_avg = phi_1 + 0.5*ai |
| 712 | + psi_avg = psi_1 + 0.5*ai |
| 713 | + term_1 = np.cos(beta) * np.cos(phi_avg) |
| 714 | + # The AOI formula includes a term based on the difference between |
| 715 | + # panel azimuth and the photon azimuth, but because we assume each class |
| 716 | + # of diffuse irradiance is isotropic and we are integrating over all |
| 717 | + # angles, it doesn't matter what panel azimuth we choose (i.e., the |
| 718 | + # system is rotationally invariant). So we choose gamma to be zero so |
| 719 | + # that we can omit it from the cos(psi_avg) term. |
| 720 | + # Marion's paper mentions this in the Section 3 pseudocode: |
| 721 | + # "set gamma to pi (or any value between 0 and 2pi)" |
| 722 | + term_2 = np.sin(beta) * np.sin(phi_avg) * np.cos(psi_avg) |
| 723 | + cosaoi = term_1 + term_2 |
| 724 | + aoi = np.arccos(cosaoi) |
| 725 | + # simplify Eq 8, (psi_2 - psi_1) is always ai |
| 726 | + dAs = ai * (np.cos(phi_1) - np.cos(phi_2)) |
| 727 | + cosaoi_dAs = cosaoi * dAs |
| 728 | + # apply the final AOI check, zeroing out non-passing points |
| 729 | + mask = aoi < np.pi/2 |
| 730 | + cosaoi_dAs = np.where(mask, cosaoi_dAs, 0) |
| 731 | + numerator = np.sum(function(np.degrees(aoi)) * cosaoi_dAs, axis=0) |
| 732 | + denominator = np.sum(cosaoi_dAs, axis=0) |
| 733 | + |
| 734 | + with np.errstate(invalid='ignore'): |
| 735 | + # in some cases, no points pass the criteria |
| 736 | + # (e.g. region='ground', surface_tilt=0), so we override the division |
| 737 | + # by zero to set Fd=0. Also, preserve nans in beta. |
| 738 | + Fd = np.where((denominator != 0) | ~np.isfinite(beta), |
| 739 | + numerator / denominator, |
| 740 | + 0) |
| 741 | + |
| 742 | + # preserve input type |
| 743 | + if np.isscalar(surface_tilt): |
| 744 | + Fd = Fd.item() |
| 745 | + elif isinstance(surface_tilt, pd.Series): |
| 746 | + Fd = pd.Series(Fd, surface_tilt.index) |
| 747 | + |
| 748 | + return Fd |
0 commit comments