Source code for pvfactors.geometry.base

"""Base classes for pvfactors geometry subpackage."""

import numpy as np
from pvfactors import PVFactorsError
from pvfactors.config import (
    DEFAULT_NORMAL_VEC, COLOR_DIC, DISTANCE_TOLERANCE, PLOT_FONTSIZE,
    ALPHA_TEXT, MAX_X_GROUND)
from pvfactors.geometry.plot import plot_coords, plot_bounds, plot_line
from pvfactors.geometry.utils import \
    is_collinear, check_collinear, are_2d_vecs_collinear, difference, contains
from shapely.geometry import GeometryCollection, LineString
from shapely.geometry.collection import geos_geometrycollection_from_py
from shapely.ops import linemerge
from pvlib.tools import cosd, sind


def _check_uniform_shading(list_elements):
    """Check that all :py:class:`~pvfactors.geometry.base.PVSurface` objects in
    list have uniform shading

    Parameters
    ----------
    list_elements : list of :py:class:`~pvfactors.geometry.base.PVSurface`

    Raises
    ------
    PVFactorsError
        if all elements don't have the same shading flag
    """
    shaded = None
    for element in list_elements:
        if shaded is None:
            shaded = element.shaded
        else:
            is_uniform = shaded == element.shaded
            if not is_uniform:
                msg = "All elements should have same shading"
                raise PVFactorsError(msg)


def _coords_from_center_tilt_length(xy_center, tilt, length,
                                    surface_azimuth, axis_azimuth):
    """Calculate ``shapely`` :py:class:`LineString` coordinates from
    center coords, surface angles and length of line.
    The axis azimuth indicates the axis of rotation of the pvrows (if single-
    axis trackers). In the 2D plane, the axis of rotation will be the vector
    normal to that 2D plane and going into the 2D plane (when plotting it).
    The surface azimuth should always be 90 degrees away from the axis azimuth,
    either in the positive or negative direction.
    For instance, a single axis trk with axis azimuth = 0 deg (North), will
    have surface azimuth values equal to 90 deg (East) or 270 deg (West).
    Tilt angles need to always be positive. Given the axis azimuth and surface
    azimuth, a rotation angle will be derived. Positive rotation angles will
    indicate pvrows pointing to the left, and negative rotation angles will
    indicate pvrows pointing to the right (no matter what the the axis azimuth
    is).
    All of these conventions are necessary to make sure that no matter what
    the tilt and surface angles are, we can still identify correctly
    the same pv rows: the leftmost PV row will have index 0, and the rightmost
    will have index -1.

    Parameters
    ----------
    xy_center : tuple
        x, y coordinates of center point of desired linestring
    tilt : float or np.ndarray
        Surface tilt angles desired [deg]. Values should all be positive.
    length : float
        desired length of linestring [m]
    surface_azimuth : float or np.ndarray
        Surface azimuth angles of PV surface [deg]
    axis_azimuth : float
        Axis azimuth of the PV surface, i.e. direction of axis of rotation
        [deg]

    Returns
    -------
    list
        List of linestring coordinates obtained from inputs (could be vectors)
        in the form of [[x1, y1], [x2, y2]], where xi and yi could be arrays
        or scalar values.
    """
    # PV row params
    x_center, y_center = xy_center
    radius = length / 2.
    # Get rotation
    rotation = _get_rotation_from_tilt_azimuth(surface_azimuth, axis_azimuth,
                                               tilt)
    # Calculate coords
    x1 = radius * cosd(rotation + 180.) + x_center
    y1 = radius * sind(rotation + 180.) + y_center
    x2 = radius * cosd(rotation) + x_center
    y2 = radius * sind(rotation) + y_center

    return [[x1, y1], [x2, y2]]


def _get_rotation_from_tilt_azimuth(surface_azimuth, axis_azimuth, tilt):
    """Calculate the rotation angle using surface azimuth, axis azimuth,
    and surface tilt angles. While surface tilt angles need to always be
    positive, rotation angles can be negative.
    In pvfactors, positive rotation angles will indicate pvrows pointing to the
    left, and negative rotation angles will indicate pvrows pointing to the
    right (no matter what the the axis azimuth is).

    Parameters
    ----------
    tilt : float or np.ndarray
        Surface tilt angles desired [deg]. Values should all be positive.
    surface_azimuth : float or np.ndarray
        Surface azimuth angles of PV surface [deg]
    axis_azimuth : float
        Axis azimuth of the PV surface, i.e. direction of axis of rotation
        [deg]

    Returns
    -------
    float or np.ndarray
        Calculated rotation angle(s) in [deg]
    """

    # Calculate rotation of PV row (signed tilt angle)
    is_pointing_right = ((surface_azimuth - axis_azimuth) % 360.) > 180.
    rotation = np.where(is_pointing_right, tilt, -tilt)
    rotation[tilt == 0] = -0.0  # GH 125
    return rotation


def _get_solar_2d_vectors(solar_zenith, solar_azimuth, axis_azimuth):
    """Projection of 3d solar vector onto the cross section of the systems:
    which is the 2D plane we are considering.
    This is needed to calculate shadows.
    Remember that the 2D plane is such that the direction of the torque
    tube vector (or rotation axis) goes into (and normal to) the 2D plane,
    such that positive rotation angles will have the PV surfaces tilted to the
    LEFT and vice versa.

    Parameters
    ----------
    solar_zenith : float or numpy array
        Solar zenith angle [deg]
    solar_azimuth : float or numpy array
        Solar azimuth angle [deg]
    axis_azimuth : float
        Axis azimuth of the PV surface, i.e. direction of axis of rotation
        [deg]

    Returns
    -------
    solar_2d_vector : numpy array
        Two vector components of the solar vector in the 2D plane, with the
        form [x, y], where x and y can be arrays
    """
    solar_2d_vector = np.array([
        # a drawing really helps understand the following
        sind(solar_zenith) * cosd(solar_azimuth - axis_azimuth - 90.),
        cosd(solar_zenith)])

    return solar_2d_vector


[docs]class BaseSurface(LineString): """Base surfaces will be extensions of :py:class:`LineString` classes, but adding an orientation to it (normal vector). So two surfaces could use the same linestring, but have opposite orientations."""
[docs] def __init__(self, coords, normal_vector=None, index=None, param_names=None, params=None): """Create a surface using linestring coordinates. Normal vector can have two directions for a given LineString, so the user can provide it in order to be specific, otherwise it will be automatically calculated, but then the surface won't know if it was supposed to be pointing "up" or "down". If the surface is empty, the normal vector will take the default value. Parameters ---------- coords : list List of linestring coordinates for the surface normal_vector : list, optional Normal vector for the surface (Default = None, so will be calculated) index : int, optional Surface index (Default = None) param_names : list of str, optional Names of the surface parameters, eg reflectivity, total incident irradiance, temperature, etc. (Default = None) params : dict, optional Surface float parameters (Default = None) """ param_names = [] if param_names is None else param_names super(BaseSurface, self).__init__(coords) if normal_vector is None: self.n_vector = self._calculate_n_vector() else: self.n_vector = np.array(normal_vector) self.index = index self.param_names = param_names self.params = params if params is not None \ else dict.fromkeys(self.param_names)
def _calculate_n_vector(self): """Calculate normal vector of the surface, if surface is not empty""" if not self.is_empty: b1, b2 = self.boundary dx = b2.x - b1.x dy = b2.y - b1.y return np.array([-dy, dx]) else: return DEFAULT_NORMAL_VEC def plot(self, ax, color=None, with_index=False): """Plot the surface on the given axes. Parameters ---------- ax : :py:class:`matplotlib.pyplot.axes` object Axes for plotting color : str, optional Color to use for plotting the surface (Default = None) with_index : bool Flag to annotate surfaces with their indices (Default = False) """ plot_coords(ax, self) plot_bounds(ax, self) plot_line(ax, self, color) if with_index: # Prepare text location v = self.n_vector v_norm = v / np.linalg.norm(v) centroid = self.centroid alpha = ALPHA_TEXT x = centroid.x + alpha * v_norm[0] y = centroid.y + alpha * v_norm[1] # Add text # FIXME: hack to get a nice plot in jupyter notebook if np.abs(x) < MAX_X_GROUND / 2.: ax.text(x, y, '{}'.format(self.index), verticalalignment='center', horizontalalignment='center') def difference(self, linestring): """Calculate remaining surface after removing part belonging from provided linestring, Parameters ---------- linestring : :py:class:`shapely.geometry.LineString` Line string to remove from surface Returns ------- :py:class:`shapely.geometry.LineString` Resulting difference of current surface minus given linestring """ return difference(self, linestring) def get_param(self, param): """Get parameter value from surface. Parameters ---------- param : str Surface parameter to return Returns ------- Parameter value to return Raises ------ KeyError if parameter name not in the surface parameters """ return self.params[param] def update_params(self, new_dict): """Update surface parameters. Parameters ---------- new_dict : dict Parameters to add or update for the surface """ self.params.update(new_dict)
[docs]class PVSurface(BaseSurface): """PV surfaces inherit from :py:class:`~pvfactors.geometry.base.BaseSurface`. The only difference is that PV surfaces have a ``shaded`` attribute. """
[docs] def __init__(self, coords=None, normal_vector=None, shaded=False, index=None, param_names=None, params=None): """Initialize PV surface. Parameters ---------- coords : list, optional List of linestring coordinates for the surface normal_vector : list, optional Normal vector for the surface (Default = None, so will be calculated) shaded : bool, optional Flag telling if surface is shaded or not (Default = False) index : int, optional Surface index (Default = None) param_names : list of str, optional Names of the surface parameters, eg reflectivity, total incident irradiance, temperature, etc. (Default = None) params : dict, optional Surface float parameters (Default = None) """ param_names = [] if param_names is None else param_names super(PVSurface, self).__init__(coords, normal_vector, index=index, param_names=param_names, params=params) self.shaded = shaded
[docs]class ShadeCollection(GeometryCollection): """A group of :py:class:`~pvfactors.geometry.base.PVSurface` objects that all have the same shading status. The PV surfaces are not necessarily contiguous or collinear."""
[docs] def __init__(self, list_surfaces=None, shaded=None, param_names=None): """Initialize shade collection. Parameters ---------- list_surfaces : list, optional List of :py:class:`~pvfactors.geometry.base.PVSurface` object (Default = None) shaded : bool, optional Shading status of the collection. If not specified, will be derived from list of surfaces (Default = None) param_names : list of str, optional Names of the surface parameters, eg reflectivity, total incident irradiance, temperature, etc. (Default = None) """ list_surfaces = [] if list_surfaces is None else list_surfaces param_names = [] if param_names is None else param_names _check_uniform_shading(list_surfaces) self.list_surfaces = list_surfaces self.shaded = self._get_shading(shaded) self.is_collinear = is_collinear(list_surfaces) self.param_names = param_names super(ShadeCollection, self).__init__(list_surfaces)
def _get_shading(self, shaded): """Get the surface shading from the provided list of pv surfaces. Parameters ---------- shaded : bool Shading flag passed during initialization Returns ------- bool Shading status of the collection """ if len(self.list_surfaces): return self.list_surfaces[0].shaded else: return shaded def plot(self, ax, color=None, with_index=False): """Plot the surfaces in the shade collection. Parameters ---------- ax : :py:class:`matplotlib.pyplot.axes` object Axes for plotting color : str, optional Color to use for plotting the surface (Default = None) with_index : bool Flag to annotate surfaces with their indices (Default = False) """ for surface in self.list_surfaces: surface.plot(ax, color=color, with_index=with_index) def add_linestring(self, linestring, normal_vector=None): """Add PV surface to the collection using a linestring Parameters ---------- linestring : :py:class:`shapely.geometry.LineString` Linestring to use to add a PV surface to the collection normal_vector : list, optional Normal vector to use for the PV surface to create (Default = None, will try to get it from collection) """ if normal_vector is None: normal_vector = self.n_vector surf = PVSurface(coords=linestring.coords, normal_vector=normal_vector, shaded=self.shaded, param_names=self.param_names) self.add_pvsurface(surf) def add_pvsurface(self, pvsurface): """Add PV surface to the collection. Parameters ---------- pvsurface : :py:class:`~pvfactors.geometry.base.PVSurface` PV Surface to add to collection """ self.list_surfaces.append(pvsurface) self.is_collinear = is_collinear(self.list_surfaces) super(ShadeCollection, self).__init__(self.list_surfaces) def remove_linestring(self, linestring): """Remove linestring from shade collection. The method will rearrange the PV surfaces to make it work. Parameters ---------- linestring : :py:class:`shapely.geometry.LineString` Line string to remove from the collection (by differencing) """ new_list_surfaces = [] for surface in self.list_surfaces: # Need to use buffer for intersects bc of floating point precision # errors in shapely if surface.buffer(DISTANCE_TOLERANCE).intersects(linestring): difference = surface.difference(linestring) # We want to make sure we can iterate on it, as # ``difference`` can be a multi-part geometry or not if not hasattr(difference, '__iter__'): difference = [difference] for new_geom in difference: if not new_geom.is_empty: new_surface = PVSurface( new_geom.coords, normal_vector=surface.n_vector, shaded=surface.shaded, param_names=surface.param_names) new_list_surfaces.append(new_surface) else: new_list_surfaces.append(surface) self.list_surfaces = new_list_surfaces # Force update, even if list is empty self.update_geom_collection(self.list_surfaces) def update_geom_collection(self, list_surfaces): """Force update of geometry collection, even if list is empty https://github.com/Toblerity/Shapely/blob/master/shapely/geometry/collection.py#L42 Parameters ---------- list_surfaces : list of :py:class:`~pvfactors.geometry.base.PVSurface` New list of PV surfaces to update the shade collection in place """ self._geom, self._ndim = geos_geometrycollection_from_py(list_surfaces) def merge_surfaces(self): """Merge all surfaces in the shade collection into one contiguous surface, even if they're not contiguous, by using bounds.""" if len(self.list_surfaces) > 1: merged_lines = linemerge(self.list_surfaces) minx, miny, maxx, maxy = merged_lines.bounds surf_1 = self.list_surfaces[0] new_pvsurf = PVSurface( coords=[(minx, miny), (maxx, maxy)], shaded=self.shaded, normal_vector=surf_1.n_vector, param_names=surf_1.param_names) self.list_surfaces = [new_pvsurf] self.update_geom_collection(self.list_surfaces) def cut_at_point(self, point): """Cut collection at point if the collection contains it. Parameters ---------- point : :py:class:`shapely.geometry.Point` Point where to cut collection geometry, if the latter contains the former """ for idx, surface in enumerate(self.list_surfaces): if contains(surface, point): # Make sure that not hitting a boundary b1, b2 = surface.boundary not_hitting_b1 = b1.distance(point) > DISTANCE_TOLERANCE not_hitting_b2 = b2.distance(point) > DISTANCE_TOLERANCE if not_hitting_b1 and not_hitting_b2: coords_1 = [b1, point] coords_2 = [point, b2] # TODO: not sure what to do about index yet new_surf_1 = PVSurface( coords_1, normal_vector=surface.n_vector, shaded=surface.shaded, param_names=surface.param_names) new_surf_2 = PVSurface( coords_2, normal_vector=surface.n_vector, shaded=surface.shaded, param_names=surface.param_names) # Now update collection self.list_surfaces[idx] = new_surf_1 self.list_surfaces.append(new_surf_2) self.update_geom_collection(self.list_surfaces) # No need to continue the loop break def get_param_weighted(self, param): """Get the parameter from the collection's surfaces, after weighting by surface length. Parameters ---------- param: str Surface parameter to return Returns ------- float Weighted parameter value """ value = self.get_param_ww(param) / self.length return value def get_param_ww(self, param): """Get the parameter from the collection's surfaces with weight, i.e. after multiplying by the surface lengths. Parameters ---------- param: str Surface parameter to return Returns ------- float Parameter value multiplied by weights Raises ------ KeyError if parameter name not in a surface parameters """ value = 0 for surf in self.list_surfaces: value += surf.get_param(param) * surf.length return value def update_params(self, new_dict): """Update surface parameters in the collection. Parameters ---------- new_dict : dict Parameters to add or update for the surface """ for surf in self.list_surfaces: surf.update_params(new_dict) @property def n_vector(self): """Unique normal vector of the shade collection, if it exists.""" if not self.is_collinear: msg = "Cannot request n_vector if all elements not collinear" raise PVFactorsError(msg) if len(self.list_surfaces): return self.list_surfaces[0].n_vector else: return DEFAULT_NORMAL_VEC @property def n_surfaces(self): """Number of surfaces in collection.""" return len(self.list_surfaces) @property def surface_indices(self): """Indices of the surfaces in the collection.""" return [surf.index for surf in self.list_surfaces] @classmethod def from_linestring_coords(cls, coords, shaded, normal_vector=None, param_names=None): """Create a shade collection with a single PV surface. Parameters ---------- coords : list List of linestring coordinates for the surface shaded : bool Shading status desired for the collection normal_vector : list, optional Normal vector for the surface (Default = None) param_names : list of str, optional Names of the surface parameters, eg reflectivity, total incident irradiance, temperature, etc. (Default = None) """ surf = PVSurface(coords=coords, normal_vector=normal_vector, shaded=shaded, param_names=param_names) return cls([surf], shaded=shaded, param_names=param_names)
[docs]class PVSegment(GeometryCollection): """A PV segment will be a collection of 2 collinear and contiguous shade collections, a shaded one and an illuminated one. It inherits from :py:class:`shapely.geometry.GeometryCollection` so that users can still call basic geometrical methods and properties on it, eg call length, etc. """
[docs] def __init__(self, illum_collection=ShadeCollection(shaded=False), shaded_collection=ShadeCollection(shaded=True), index=None): """Initialize PV segment. Parameters ---------- illum_collection : \ :py:class:`~pvfactors.geometry.base.ShadeCollection`, optional Illuminated collection of the PV segment (Default = empty shade collection with no shading) shaded_collection : \ :py:class:`~pvfactors.geometry.base.ShadeCollection`, optional Shaded collection of the PV segment (Default = empty shade collection with shading) index : int, optional Index of the PV segment (Default = None) """ assert shaded_collection.shaded, "surface should be shaded" assert not illum_collection.shaded, "surface should not be shaded" self._check_collinear(illum_collection, shaded_collection) self._shaded_collection = shaded_collection self._illum_collection = illum_collection self.index = index self._all_surfaces = None super(PVSegment, self).__init__([self._shaded_collection, self._illum_collection])
def _check_collinear(self, illum_collection, shaded_collection): """Check that all the surfaces in the PV segment are collinear. Parameters ---------- illum_collection : :py:class:`~pvfactors.geometry.base.ShadeCollection`, optional Illuminated collection shaded_collection : :py:class:`~pvfactors.geometry.base.ShadeCollection`, optional Shaded collection Raises ------ PVFactorsError If all the surfaces are not collinear """ assert illum_collection.is_collinear assert shaded_collection.is_collinear # Check that if none or all of the collection is empty, n_vectors are # equal if (not illum_collection.is_empty) \ and (not shaded_collection.is_empty): n_vec_ill = illum_collection.n_vector n_vec_shaded = shaded_collection.n_vector assert are_2d_vecs_collinear(n_vec_ill, n_vec_shaded) def plot(self, ax, color_shaded=COLOR_DIC['pvrow_shaded'], color_illum=COLOR_DIC['pvrow_illum'], with_index=False): """Plot the surfaces in the PV Segment. Parameters ---------- ax : :py:class:`matplotlib.pyplot.axes` object Axes for plotting color_shaded : str, optional Color to use for plotting the shaded surfaces (Default = COLOR_DIC['pvrow_shaded']) color_shaded : str, optional Color to use for plotting the illuminated surfaces (Default = COLOR_DIC['pvrow_illum']) with_index : bool Flag to annotate surfaces with their indices (Default = False) """ self._shaded_collection.plot(ax, color=color_shaded, with_index=with_index) self._illum_collection.plot(ax, color=color_illum, with_index=with_index) def cast_shadow(self, linestring): """Cast shadow on PV segment using linestring: will rearrange the PV surfaces between the shaded and illuminated collections of the segment Parameters ---------- linestring : :py:class:`shapely.geometry.LineString` Linestring casting a shadow on the PV segment """ # Using a buffer may slow things down, but it's quite crucial # in order for shapely to get the intersection accurately see: # https://stackoverflow.com/questions/28028910/how-to-deal-with-rounding-errors-in-shapely intersection = (self._illum_collection.buffer(DISTANCE_TOLERANCE) .intersection(linestring)) if not intersection.is_empty: # Split up only if interesects the illuminated collection # print(intersection) self._shaded_collection.add_linestring(intersection, normal_vector=self.n_vector) # print(self._shaded_collection.length) self._illum_collection.remove_linestring(intersection) # print(self._illum_collection.length) super(PVSegment, self).__init__([self._shaded_collection, self._illum_collection]) def cut_at_point(self, point): """Cut PV segment at point if the segment contains it. Parameters ---------- point : :py:class:`shapely.geometry.Point` Point where to cut collection geometry, if the latter contains the former """ if contains(self, point): if contains(self._illum_collection, point): self._illum_collection.cut_at_point(point) else: self._shaded_collection.cut_at_point(point) def get_param_weighted(self, param): """Get the parameter from the segment's surfaces, after weighting by surface length. Parameters ---------- param: str Surface parameter to return Returns ------- float Weighted parameter value """ value = self.get_param_ww(param) / self.length return value def get_param_ww(self, param): """Get the parameter from the segment's surfaces with weight, i.e. after multiplying by the surface lengths. Parameters ---------- param: str Surface parameter to return Returns ------- float Parameter value multiplied by weights Raises ------ KeyError if parameter name not in a surface parameters """ value = 0 value += self._shaded_collection.get_param_ww(param) value += self._illum_collection.get_param_ww(param) return value def update_params(self, new_dict): """Update surface parameters in the collection. Parameters ---------- new_dict : dict Parameters to add or update for the surfaces """ self._shaded_collection.update_params(new_dict) self._illum_collection.update_params(new_dict) @property def n_vector(self): """Since shaded and illum surfaces are supposed to be collinear, this should return either surfaces' normal vector. If both empty, return Default value for normal vector.""" if not self.illum_collection.is_empty: return self.illum_collection.n_vector elif not self.shaded_collection.is_empty: return self.shaded_collection.n_vector else: return DEFAULT_NORMAL_VEC @property def n_surfaces(self): """Number of surfaces in collection.""" n_surfaces = self._illum_collection.n_surfaces \ + self._shaded_collection.n_surfaces return n_surfaces @property def surface_indices(self): """Indices of the surfaces in the PV segment.""" list_indices = [] list_indices += self._illum_collection.surface_indices list_indices += self._shaded_collection.surface_indices return list_indices @classmethod def from_linestring_coords(cls, coords, shaded=False, normal_vector=None, index=None, param_names=None): """Create a PV segment with a single PV surface. Parameters ---------- coords : list List of linestring coordinates for the surface shaded : bool, optional Shading status desired for the resulting PV surface (Default = False) normal_vector : list, optional Normal vector for the surface (Default = None) index : int, optional Index of the segment (Default = None) param_names : list of str, optional Names of the surface parameters, eg reflectivity, total incident irradiance, temperature, etc. (Default = None) """ col = ShadeCollection.from_linestring_coords( coords, shaded=shaded, normal_vector=normal_vector, param_names=param_names) # Realized that needed to instantiate other_col, otherwise could # end up with shared collection among different PV segments other_col = ShadeCollection(list_surfaces=[], shaded=not shaded, param_names=param_names) if shaded: return cls(illum_collection=other_col, shaded_collection=col, index=index) else: return cls(illum_collection=col, shaded_collection=other_col, index=index) @property def shaded_collection(self): """Shaded collection of the PV segment""" return self._shaded_collection @shaded_collection.setter def shaded_collection(self, new_collection): """Set shaded collection of the PV segment with new one. Parameters ---------- new_collection : :py:class:`pvfactors.geometry.base.ShadeCollection` New collection to use for update """ assert new_collection.shaded, "surface should be shaded" self._shaded_collection = new_collection super(PVSegment, self).__init__([self._shaded_collection, self._illum_collection]) @shaded_collection.deleter def shaded_collection(self): """Delete shaded collection of PV segment and replace with empty one. """ self._shaded_collection = ShadeCollection(shaded=True) super(PVSegment, self).__init__([self._shaded_collection, self._illum_collection]) @property def illum_collection(self): """Illuminated collection of the PV segment.""" return self._illum_collection @illum_collection.setter def illum_collection(self, new_collection): """Set illuminated collection of the PV segment with new one. Parameters ---------- new_collection : :py:class:`pvfactors.geometry.base.ShadeCollection` New collection to use for update """ assert not new_collection.shaded, "surface should not be shaded" self._illum_collection = new_collection super(PVSegment, self).__init__([self._shaded_collection, self._illum_collection]) @illum_collection.deleter def illum_collection(self): """Delete illuminated collection of PV segment and replace with empty one.""" self._illum_collection = ShadeCollection(shaded=False) super(PVSegment, self).__init__([self._shaded_collection, self._illum_collection]) @property def shaded_length(self): """Length of the shaded collection of the PV segment. Returns ------- float Length of the shaded collection """ return self._shaded_collection.length @property def all_surfaces(self): """List of all the :py:class:`pvfactors.geometry.base.PVSurface` Returns ------- list of :py:class:`~pvfactors.geometry.base.PVSurface` PV surfaces in the PV segment """ if self._all_surfaces is None: self._all_surfaces = [] self._all_surfaces += self._illum_collection.list_surfaces self._all_surfaces += self._shaded_collection.list_surfaces return self._all_surfaces
[docs]class BaseSide(GeometryCollection): """A side represents a fixed collection of PV segments objects that should all be collinear, with the same normal vector"""
[docs] def __init__(self, list_segments=None): """Create a side geometry. Parameters ---------- list_segments : list of :py:class:`~pvfactors.geometry.base.PVSegment`, optional List of PV segments for side (Default = None) """ list_segments = [] if list_segments is None else list_segments check_collinear(list_segments) self.list_segments = tuple(list_segments) self._all_surfaces = None super(BaseSide, self).__init__(list_segments)
@classmethod def from_linestring_coords(cls, coords, shaded=False, normal_vector=None, index=None, n_segments=1, param_names=None): """Create a Side with a single PV surface, or multiple discretized identical ones. Parameters ---------- coords : list List of linestring coordinates for the surface shaded : bool, optional Shading status desired for the resulting PV surface (Default = False) normal_vector : list, optional Normal vector for the surface (Default = None) index : int, optional Index of the segments (Default = None) n_segments : int, optional Number of same-length segments to use (Default = 1) param_names : list of str, optional Names of the surface parameters, eg reflectivity, total incident irradiance, temperature, etc. (Default = None) """ if n_segments == 1: list_pvsegments = [PVSegment.from_linestring_coords( coords, shaded=shaded, normal_vector=normal_vector, index=index, param_names=param_names)] else: # Discretize coords and create segments accordingly linestring = LineString(coords) fractions = np.linspace(0., 1., num=n_segments + 1) list_points = [linestring.interpolate(fraction, normalized=True) for fraction in fractions] list_pvsegments = [] for idx in range(n_segments): new_coords = list_points[idx:idx + 2] # TODO: not clear what to do with the index here pvsegment = PVSegment.from_linestring_coords( new_coords, shaded=shaded, normal_vector=normal_vector, index=index, param_names=param_names) list_pvsegments.append(pvsegment) return cls(list_segments=list_pvsegments) @property def n_vector(self): """Normal vector of the Side.""" if len(self.list_segments): return self.list_segments[0].n_vector else: return DEFAULT_NORMAL_VEC @property def shaded_length(self): """Shaded length of the Side.""" shaded_length = 0. for segment in self.list_segments: shaded_length += segment.shaded_length return shaded_length @property def n_surfaces(self): """Number of surfaces in the Side object.""" n_surfaces = 0 for segment in self.list_segments: n_surfaces += segment.n_surfaces return n_surfaces @property def all_surfaces(self): """List of all surfaces in the Side object.""" if self._all_surfaces is None: self._all_surfaces = [] for segment in self.list_segments: self._all_surfaces += segment.all_surfaces return self._all_surfaces @property def surface_indices(self): """List of all surface indices in the Side object.""" list_indices = [] for seg in self.list_segments: list_indices += seg.surface_indices return list_indices def plot(self, ax, color_shaded=COLOR_DIC['pvrow_shaded'], color_illum=COLOR_DIC['pvrow_illum'], with_index=False): """Plot the surfaces in the Side object. Parameters ---------- ax : :py:class:`matplotlib.pyplot.axes` object Axes for plotting color_shaded : str, optional Color to use for plotting the shaded surfaces (Default = COLOR_DIC['pvrow_shaded']) color_shaded : str, optional Color to use for plotting the illuminated surfaces (Default = COLOR_DIC['pvrow_illum']) with_index : bool Flag to annotate surfaces with their indices (Default = False) """ for segment in self.list_segments: segment.plot(ax, color_shaded=color_shaded, color_illum=color_illum, with_index=with_index) def cast_shadow(self, linestring): """Cast shadow on Side using linestring: will rearrange the PV surfaces between the shaded and illuminated collections of the segments. Parameters ---------- linestring : :py:class:`shapely.geometry.LineString` Linestring casting a shadow on the Side object """ for segment in self.list_segments: segment.cast_shadow(linestring) def merge_shaded_areas(self): """Merge shaded areas of all PV segments""" for seg in self.list_segments: seg._shaded_collection.merge_surfaces() def cut_at_point(self, point): """Cut Side at point if the side contains it. Parameters ---------- point : :py:class:`shapely.geometry.Point` Point where to cut side geometry, if the latter contains the former """ if contains(self, point): for segment in self.list_segments: # Nothing will happen to the segments that do not contain # the point segment.cut_at_point(point) def get_param_weighted(self, param): """Get the parameter from the side's surfaces, after weighting by surface length. Parameters ---------- param: str Surface parameter to return Returns ------- float Weighted parameter value """ value = self.get_param_ww(param) / self.length return value def get_param_ww(self, param): """Get the parameter from the side's surfaces with weight, i.e. after multiplying by the surface lengths. Parameters ---------- param: str Surface parameter to return Returns ------- float Parameter value multiplied by weights Raises ------ KeyError if parameter name not in a surface parameters """ value = 0 for seg in self.list_segments: value += seg.get_param_ww(param) return value def update_params(self, new_dict): """Update surface parameters in the Side. Parameters ---------- new_dict : dict Parameters to add or update for the surfaces """ for seg in self.list_segments: seg.update_params(new_dict)
[docs]class BasePVArray(object): """Base class for PV arrays in pvfactors. Will provide basic capabilities.""" registry_cols = ['geom', 'line_type', 'pvrow_index', 'side', 'pvsegment_index', 'shaded', 'surface_index']
[docs] def __init__(self, axis_azimuth=None): """Initialize Base of PV array. Parameters ---------- axis_azimuth : float, optional Azimuth angle of rotation axis [deg] (Default = None) """ # All PV arrays should have a fixed axis azimuth in pvfactors self.axis_azimuth = axis_azimuth # The are required attributes of any PV array self.ts_pvrows = None self.ts_ground = None
@property def n_ts_surfaces(self): """Number of timeseries surfaces in the PV array.""" n_ts_surfaces = 0 n_ts_surfaces += self.ts_ground.n_ts_surfaces for ts_pvrow in self.ts_pvrows: n_ts_surfaces += ts_pvrow.n_ts_surfaces return n_ts_surfaces @property def all_ts_surfaces(self): """List of all timeseries surfaces in PV array""" all_ts_surfaces = [] all_ts_surfaces += self.ts_ground.all_ts_surfaces for ts_pvrow in self.ts_pvrows: all_ts_surfaces += ts_pvrow.all_ts_surfaces return all_ts_surfaces @property def ts_surface_indices(self): """List of indices of all the timeseries surfaces""" return [ts_surf.index for ts_surf in self.all_ts_surfaces] def plot_at_idx(self, idx, ax, merge_if_flag_overlap=True, with_cut_points=True, x_min_max=None, with_surface_index=False): """Plot all the PV rows and the ground in the PV array at a desired step index. This can be called before transforming the array, and after fitting it. Parameters ---------- idx : int Selected timestep index for plotting the PV array ax : :py:class:`matplotlib.pyplot.axes` object Axes for plotting the PV array geometries merge_if_flag_overlap : bool, optional Decide whether to merge all shadows if they overlap (Default = True) with_cut_points : bool, optional Decide whether to include the saved cut points in the created PV ground geometry (Default = True) x_min_max : tuple, optional List of minimum and maximum x coordinates for the flat ground surface [m] (Default = None) with_surface_index : bool, optional Plot the surfaces with their index values (Default = False) """ # Plot pv array structures self.ts_ground.plot_at_idx( idx, ax, color_shaded=COLOR_DIC['ground_shaded'], color_illum=COLOR_DIC['ground_illum'], merge_if_flag_overlap=merge_if_flag_overlap, with_cut_points=with_cut_points, x_min_max=x_min_max, with_surface_index=with_surface_index) for ts_pvrow in self.ts_pvrows: ts_pvrow.plot_at_idx( idx, ax, color_shaded=COLOR_DIC['pvrow_shaded'], color_illum=COLOR_DIC['pvrow_illum'], with_surface_index=with_surface_index) # Plot formatting ax.axis('equal') if self.distance is not None: n_pvrows = self.n_pvrows ax.set_xlim(- 0.5 * self.distance, (n_pvrows - 0.5) * self.distance) if self.height is not None: ax.set_ylim(- self.height, 2 * self.height) ax.set_xlabel("x [m]", fontsize=PLOT_FONTSIZE) ax.set_ylabel("y [m]", fontsize=PLOT_FONTSIZE) def fit(self, *args, **kwargs): """Not implemented.""" raise NotImplementedError def update_params(self, new_dict): """Update timeseries surface parameters in the collection. Parameters ---------- new_dict : dict Parameters to add or update for the surfaces """ self.ts_ground.update_params(new_dict) for ts_pvrow in self.ts_pvrows: ts_pvrow.update_params(new_dict) def _index_all_ts_surfaces(self): """Add unique indices to all surfaces in the PV array.""" for idx, ts_surface in enumerate(self.all_ts_surfaces): ts_surface.index = idx