diff --git a/README.rst b/README.rst index 51d75da..f12d0cc 100644 --- a/README.rst +++ b/README.rst @@ -9,16 +9,13 @@ This fork exists so that the pvfactors model can continue to be used with `pvlib python `_ even though the original repository is no longer maintained. The objective is to provide a working dependency for the existing pvfactors functionality currently in pvlib python. -New features may be added, but don't count on it. -Documentation for this fork can be found at `Read The Docs `_. +Documentation for this fork can be found at `Read the Docs `_. The project can be installed from PyPI using ``pip install solarfactors``. Note that the package is still used from python under the ``pvfactors`` name, i.e. with ``from pvfactors.geometry import OrderedPVArray``. -The original ``pvfactors`` is preserved below: - pvfactors: irradiance modeling made simple ========================================== @@ -34,6 +31,8 @@ equations to account for reflections between all of the surfaces. pvfactors was originally ported from the SunPower developed 'vf_model' package, which was introduced at the IEEE PV Specialist Conference 44 2017 (see [#pvfactors_paper]_ and link_ to paper). +This fork, `pvlib/solarfactors `_ is maintained by the pvlib project with contributions from the pvlib community. + ------------------------------------------ .. contents:: Table of contents @@ -44,8 +43,8 @@ pvfactors was originally ported from the SunPower developed 'vf_model' package, Documentation ------------- -The documentation can be found `here `_. -It includes a lot of tutorials_ that describe the different ways of using pvfactors. +The documentation of this fork can be found `here `_. +It includes a lot of tutorials_ that describe the different ways of using solarfactors. Quick Start @@ -187,11 +186,11 @@ The users can also create a "report" while running the simulations that will rel Installation ------------ -pvfactors is currently compatible and tested with 3.6+, and is available in `PyPI `_. The easiest way to install pvfactors is to use pip_ as follows: +solarfactors is currently compatible and tested with Python 3.11 and Shapely 2.0.6, and is available in `PyPI `_. The easiest way to install solarfactors is to use pip_ as follows: .. code:: sh - $ pip install pvfactors + $ pip install solarfactors The package wheel files are also available in the `release section`_ of the Github repository. @@ -203,13 +202,13 @@ Requirements are included in the ``requirements.txt`` file of the package. Here * `numpy `_ * `pvlib-python `_ -* `shapely `_ +* `shapely `_ (version >= 2.0) Citing pvfactors ---------------- -We appreciate your use of pvfactors. If you use pvfactors in a published work, we kindly ask that you cite: +If you use solarfactors in a published work, cite the following paper: .. parsed-literal:: @@ -220,8 +219,9 @@ We appreciate your use of pvfactors. If you use pvfactors in a published work, w Contributing ------------ -Contributions are needed in order to improve pvfactors. -If you wish to contribute, you can start by forking and cloning the repository, and then installing pvfactors using pip_ in the root folder of the package: +Contributions are needed in order to improve solarfactors. + +If you wish to contribute, you can start by forking and cloning the repository, and then installing solarfactors using pip_ in the root folder of the package: .. code:: sh @@ -234,6 +234,25 @@ To install the package in editable mode, you can use: $ pip install -e . + +Testing ++++++++ + +Install test dependencies using the ``test`` extra: + +.. code:: sh + + $ pip install .[test] + +Then run the tests using: + +.. code:: sh + + $ python -m pytest + +You will need to close manually the plots that are generated during the tests, unless you define the ``CI`` environment variable, which will disable the tests that generate plots. + + Releasing +++++++++ @@ -254,27 +273,27 @@ References .. _link: https://pdfs.semanticscholar.org/ebb2/35e3c3796b158e1a3c45b40954e60d876ea9.pdf -.. _tutorials: https://sunpower.github.io/pvfactors/tutorials/index.html +.. _tutorials: https://solarfactors.readthedocs.io/en/latest/tutorials/index.html -.. _`full mode`: https://sunpower.github.io/pvfactors/theory/problem_formulation.html#full-simulations +.. _`full mode`: https://solarfactors.readthedocs.io/en/latest/theory/problem_formulation.html#full-simulations -.. _`fast mode`: https://sunpower.github.io/pvfactors/theory/problem_formulation.html#fast-simulations +.. _`fast mode`: https://solarfactors.readthedocs.io/en/latest/theory/problem_formulation.html#fast-simulations .. _pip: https://pip.pypa.io/en/stable/ -.. _`release section`: https://github.com/SunPower/pvfactors/releases +.. _`release section`: https://github.com/pvlib/solarfactors/releases -.. |Logo| image:: https://raw.githubusercontent.com/SunPower/pvfactors/master/docs/sphinx/_static/logo.png - :target: http://sunpower.github.io/pvfactors/ +.. |Logo| image:: https://github.com/pvlib/solarfactors/blob/main/docs/sphinx/_static/logo_small.png?raw=true + :target: https://solarfactors.readthedocs.io/en/latest/index.html .. |CircleCI| image:: https://circleci.com/gh/SunPower/pvfactors.svg?style=shield :target: https://circleci.com/gh/SunPower/pvfactors .. |License| image:: https://img.shields.io/badge/License-BSD%203--Clause-blue.svg - :target: https://github.com/SunPower/pvfactors/blob/master/LICENSE + :target: https://github.com/pvlib/solarfactors/blob/main/LICENSE -.. |PyPI-Status| image:: https://img.shields.io/pypi/v/pvfactors.svg - :target: https://pypi.org/project/pvfactors +.. |PyPI-Status| image:: https://img.shields.io/pypi/v/solarfactors.svg + :target: https://pypi.org/project/solarfactors/ -.. |PyPI-Versions| image:: https://img.shields.io/pypi/pyversions/pvfactors.svg?logo=python&logoColor=white - :target: https://pypi.org/project/pvfactors +.. |PyPI-Versions| image:: https://img.shields.io/pypi/pyversions/solarfactors.svg?logo=python&logoColor=white + :target: https://pypi.org/project/solarfactors/ diff --git a/docs/sphinx/conf.py b/docs/sphinx/conf.py index 9cd5999..b2b6830 100644 --- a/docs/sphinx/conf.py +++ b/docs/sphinx/conf.py @@ -341,8 +341,8 @@ def setup(app): extlinks = { - 'issue': ('https://github.com/pvlib/solarfactors/issues/%s', 'GH'), - 'pull': ('https://github.com/pvlib/solarfactors/pull/%s', 'GH'), - 'doi': ('http://dx.doi.org/%s', 'DOI: '), - 'ghuser': ('https://github.com/%s', '@') + 'issue': ('https://github.com/pvlib/solarfactors/issues/%s', 'GH #%s'), + 'pull': ('https://github.com/pvlib/solarfactors/pull/%s', 'GH #%s'), + 'doi': ('http://dx.doi.org/%s', 'DOI:%s'), + 'ghuser': ('https://github.com/%s', '@%s') } diff --git a/pvfactors/__init__.py b/pvfactors/__init__.py index 1e30107..0d21107 100644 --- a/pvfactors/__init__.py +++ b/pvfactors/__init__.py @@ -5,20 +5,14 @@ logging.basicConfig() try: - from shapely.geos import lgeos # noqa: F401 -except OSError as err: - # https://github.com/SunPower/pvfactors/issues/109 + from shapely import geos_version, geos_capi_version # noqa: F401 +except ImportError as err: msg = ( - "pvfactors encountered an error when importing the shapely package. " - "This often happens when a binary dependency is missing because " - "shapely was installed from PyPI using pip. Try reinstalling shapely " - "from another source like conda-forge with " - "`conda install -c conda-forge shapely`, or alternatively from " - "Christoph Gohlke's website if you're on Windows: " - "https://www.lfd.uci.edu/~gohlke/pythonlibs/#shapely" + "pvfactors detected that the Shapely package is not correctly installed. " + "Make sure that you installed the prerequisites, including Shapely version " + "2.0+, in a supported environment." ) - err.strerror += "; " + msg - raise err + raise ImportError(msg) from err class PVFactorsError(Exception): diff --git a/pvfactors/geometry/base.py b/pvfactors/geometry/base.py index 4cc7d1d..58a5df2 100644 --- a/pvfactors/geometry/base.py +++ b/pvfactors/geometry/base.py @@ -9,7 +9,6 @@ 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 @@ -159,8 +158,8 @@ def _get_solar_2d_vectors(solar_zenith, solar_azimuth, axis_azimuth): return solar_2d_vector -class BaseSurface(LineString): - """Base surfaces will be extensions of :py:class:`LineString` classes, +class BaseSurface: + """BaseSurface will wrap the :py:class:`LineString` class, but adding an orientation to it (normal vector). So two surfaces could use the same linestring, but have opposite orientations.""" @@ -190,9 +189,8 @@ def __init__(self, coords, normal_vector=None, index=None, params : dict, optional Surface float parameters (Default = None) """ - + self.geometry = LineString(coords) # Composition 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: @@ -202,10 +200,49 @@ def __init__(self, coords, normal_vector=None, index=None, self.params = params if params is not None \ else dict.fromkeys(self.param_names) + @property + def is_empty(self): + """Check if the surface is empty.""" + return self.geometry.is_empty + + @property + def length(self): + """Return the length of the surface.""" + return self.geometry.length + + @property + def boundary(self): + """Return the boundary of the surface.""" + return self.geometry.boundary + + @property + def coords(self): + return self.geometry.coords + + @property + def centroid(self): + return self.geometry.centroid + + def interpolate(self, *args, **kwargs): + """Interpolate along the linestring by the given distance.""" + return self.geometry.interpolate(*args, **kwargs) + + def distance(self, *args, **kwargs): + """Distance between the surface and another geometry.""" + return self.geometry.distance(*args, **kwargs) + + def dwithin(self, *args, **kwargs): + """Check if the surface is within a certain distance of another geometry.""" + return self.geometry.dwithin(*args, **kwargs) + + def buffer(self, *args, **kwargs): + """Buffer the surface.""" + return self.geometry.buffer(*args, **kwargs) + 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 + b1, b2 = self.geometry.boundary.geoms dx = b2.x - b1.x dy = b2.y - b1.y return np.array([-dy, dx]) @@ -231,7 +268,7 @@ def plot(self, ax, color=None, with_index=False): # Prepare text location v = self.n_vector v_norm = v / np.linalg.norm(v) - centroid = self.centroid + centroid = self.geometry.centroid alpha = ALPHA_TEXT x = centroid.x + alpha * v_norm[0] y = centroid.y + alpha * v_norm[1] @@ -256,7 +293,7 @@ def difference(self, linestring): :py:class:`shapely.geometry.LineString` Resulting difference of current surface minus given linestring """ - return difference(self, linestring) + return difference(self.geometry, linestring) def get_param(self, param): """Get parameter value from surface. @@ -289,7 +326,7 @@ def update_params(self, new_dict): class PVSurface(BaseSurface): - """PV surfaces inherit from + """PVSurface inherits from :py:class:`~pvfactors.geometry.base.BaseSurface`. The only difference is that PV surfaces have a ``shaded`` attribute. """ @@ -315,14 +352,13 @@ def __init__(self, coords=None, normal_vector=None, shaded=False, 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 -class ShadeCollection(GeometryCollection): +class ShadeCollection: """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.""" @@ -350,7 +386,34 @@ def __init__(self, list_surfaces=None, shaded=None, param_names=None): self.shaded = self._get_shading(shaded) self.is_collinear = is_collinear(list_surfaces) self.param_names = param_names - super(ShadeCollection, self).__init__(list_surfaces) + self._geometry_valid = False + self._geometry = None + + @property + def geometry(self): + """Return a Shapely GeometryCollection built from the current surfaces.""" + if not self._geometry_valid: + self._geometry = GeometryCollection([_.geometry for _ in self.list_surfaces]) + self._geometry_valid = True + return self._geometry + + @property + def is_empty(self): + """Check if the collection is empty.""" + return self.geometry.is_empty + + @property + def length(self): + """Convenience property to get the total length of all lines in the collection.""" + return self.geometry.length + + def distance(self, *args, **kwargs): + """Distance between the collection and another geometry.""" + return self.geometry.distance(*args, **kwargs) + + def dwithin(self, *args, **kwargs): + """Check if the collection is within a certain distance of another geometry.""" + return self.geometry.dwithin(*args, **kwargs) def _get_shading(self, shaded): """Get the surface shading from the provided list of pv surfaces. @@ -413,7 +476,7 @@ def add_pvsurface(self, pvsurface): """ self.list_surfaces.append(pvsurface) self.is_collinear = is_collinear(self.list_surfaces) - super(ShadeCollection, self).__init__(self.list_surfaces) + self._geometry_valid = False def remove_linestring(self, linestring): """Remove linestring from shade collection. @@ -432,9 +495,11 @@ def remove_linestring(self, 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 isinstance(difference, LineString): + geoms = [difference] + else: + geoms = difference.geoms + for new_geom in geoms: if not new_geom.is_empty: new_surface = PVSurface( new_geom.coords, normal_vector=surface.n_vector, @@ -443,27 +508,14 @@ def remove_linestring(self, linestring): 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) + self._geometry_valid = False 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) + merged_lines = linemerge(self.geometry) minx, miny, maxx, maxy = merged_lines.bounds surf_1 = self.list_surfaces[0] new_pvsurf = PVSurface( @@ -471,7 +523,7 @@ def merge_surfaces(self): 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) + self._geometry_valid = False def cut_at_point(self, point): """Cut collection at point if the collection contains it. @@ -485,7 +537,7 @@ def cut_at_point(self, point): for idx, surface in enumerate(self.list_surfaces): if contains(surface, point): # Make sure that not hitting a boundary - b1, b2 = surface.boundary + b1, b2 = surface.boundary.geoms not_hitting_b1 = b1.distance(point) > DISTANCE_TOLERANCE not_hitting_b2 = b2.distance(point) > DISTANCE_TOLERANCE if not_hitting_b1 and not_hitting_b2: @@ -503,8 +555,8 @@ def cut_at_point(self, point): # 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 + self._geometry_valid = False break def get_param_weighted(self, param): @@ -602,11 +654,9 @@ def from_linestring_coords(cls, coords, shaded, normal_vector=None, return cls([surf], shaded=shaded, param_names=param_names) -class PVSegment(GeometryCollection): +class PVSegment: """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. + shade collections, a shaded one and an illuminated one. """ def __init__(self, illum_collection=ShadeCollection(shaded=False), @@ -632,9 +682,30 @@ def __init__(self, illum_collection=ShadeCollection(shaded=False), 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]) + self._geometry_valid = False + self._geometry = None + + @property + def geometry(self): + if not self._geometry_valid: + self._geometry = GeometryCollection([self._shaded_collection.geometry, + self._illum_collection.geometry]) + self._geometry_valid = True + return self._geometry + + @property + def is_empty(self): + return self.geometry.is_empty + + @property + def length(self): + return self.geometry.length + + def distance(self, *args, **kwargs): + return self.geometry.distance(*args, **kwargs) + + def dwithin(self, *args, **kwargs): + return self.geometry.dwithin(*args, **kwargs) def _check_collinear(self, illum_collection, shaded_collection): """Check that all the surfaces in the PV segment are collinear. @@ -698,18 +769,14 @@ def cast_shadow(self, linestring): # 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 = (self._illum_collection.geometry.buffer(DISTANCE_TOLERANCE) .intersection(linestring)) if not intersection.is_empty: - # Split up only if interesects the illuminated collection - # print(intersection) + # Split up only if intersects the illuminated collection 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]) + self._geometry_valid = False def cut_at_point(self, point): """Cut PV segment at point if the segment contains it. @@ -725,6 +792,7 @@ def cut_at_point(self, point): self._illum_collection.cut_at_point(point) else: self._shaded_collection.cut_at_point(point) + self._geometry_valid = False def get_param_weighted(self, param): """Get the parameter from the segment's surfaces, after weighting @@ -855,16 +923,14 @@ def shaded_collection(self, new_collection): """ assert new_collection.shaded, "surface should be shaded" self._shaded_collection = new_collection - super(PVSegment, self).__init__([self._shaded_collection, - self._illum_collection]) + self._geometry_valid = False @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]) + self._geometry_valid = False @property def illum_collection(self): @@ -882,16 +948,14 @@ def illum_collection(self, new_collection): """ 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]) + self._geometry_valid = False @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]) + self._geometry_valid = False @property def shaded_length(self): @@ -913,15 +977,12 @@ def all_surfaces(self): 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 + return self._illum_collection.list_surfaces + \ + self._shaded_collection.list_surfaces -class BaseSide(GeometryCollection): - """A side represents a fixed collection of PV segments objects that should +class BaseSide: + """A side represents a fixed collection of PV segment objects that should all be collinear, with the same normal vector""" def __init__(self, list_segments=None): @@ -936,7 +997,27 @@ def __init__(self, list_segments=None): check_collinear(list_segments) self.list_segments = tuple(list_segments) self._all_surfaces = None - super(BaseSide, self).__init__(list_segments) + + @property + def geometry(self): + return GeometryCollection([_.geometry for _ in self.list_segments]) + + @property + def is_empty(self): + return self.geometry.is_empty + + @property + def length(self): + return self.geometry.length + + def distance(self, *args, **kwargs): + return self.geometry.distance(*args, **kwargs) + + def dwithin(self, *args, **kwargs): + return self.geometry.dwithin(*args, **kwargs) + + def intersects(self, *args, **kwargs): + return self.geometry.intersects(*args, **kwargs) @classmethod def from_linestring_coords(cls, coords, shaded=False, normal_vector=None, @@ -1070,7 +1151,7 @@ def cut_at_point(self, point): Point where to cut side geometry, if the latter contains the former """ - if contains(self, point): + if contains(self.geometry, point): for segment in self.list_segments: # Nothing will happen to the segments that do not contain # the point diff --git a/pvfactors/geometry/plot.py b/pvfactors/geometry/plot.py index 8a2dc6b..235e794 100644 --- a/pvfactors/geometry/plot.py +++ b/pvfactors/geometry/plot.py @@ -13,10 +13,10 @@ def plot_coords(ax, ob): """ try: - x, y = ob.xy + x, y = ob.geometry.xy ax.plot(x, y, 'o', color='#999999', zorder=1) except NotImplementedError: - for line in ob: + for line in ob.geometry.geoms: x, y = line.xy ax.plot(x, y, 'o', color='#999999', zorder=1) @@ -33,10 +33,10 @@ def plot_bounds(ax, ob): """ # Check if shadow reduces to one point (for very specific sun alignment) - if len(ob.boundary) == 0: + if len(ob.boundary.geoms) == 0: x, y = ob.coords[0] else: - x, y = zip(*list((p.x, p.y) for p in ob.boundary)) + x, y = zip(*list((p.x, p.y) for p in ob.boundary.geoms)) ax.plot(x, y, 'o', color='#000000', zorder=1) @@ -54,11 +54,11 @@ def plot_line(ax, ob, line_color): """ try: - x, y = ob.xy + x, y = ob.geometry.xy ax.plot(x, y, color=line_color, alpha=0.7, linewidth=3, solid_capstyle='round', zorder=2) except NotImplementedError: - for line in ob: + for line in ob.geometry.geoms: x, y = line.xy ax.plot(x, y, color=line_color, alpha=0.7, linewidth=3, solid_capstyle='round', zorder=2) diff --git a/pvfactors/geometry/pvground.py b/pvfactors/geometry/pvground.py index f336c2a..93d2f4c 100644 --- a/pvfactors/geometry/pvground.py +++ b/pvfactors/geometry/pvground.py @@ -719,8 +719,8 @@ def _merge_shadow_surfaces(self, idx, non_pt_shadow_elements): if i_surf == 0: # Need to merge with preceding if exists if surface_to_merge is not None: - coords = [surface_to_merge.boundary[0], - surface.boundary[1]] + coords = [surface_to_merge.boundary.geoms[0], + surface.boundary.geoms[1]] surface = PVSurface( coords, shaded=True, param_names=self.param_names, @@ -735,8 +735,8 @@ def _merge_shadow_surfaces(self, idx, non_pt_shadow_elements): elif i_surf == 0: # first surface but definitely not last either if surface_to_merge is not None: - coords = [surface_to_merge.boundary[0], - surface.boundary[1]] + coords = [surface_to_merge.boundary.geoms[0], + surface.boundary.geoms[1]] list_shadow_surfaces.append( PVSurface(coords, shaded=True, param_names=self.param_names, diff --git a/pvfactors/geometry/pvrow.py b/pvfactors/geometry/pvrow.py index 66fe966..abd52ba 100644 --- a/pvfactors/geometry/pvrow.py +++ b/pvfactors/geometry/pvrow.py @@ -1,6 +1,7 @@ """Module will classes related to PV row geometries""" import numpy as np +from shapely.ops import unary_union, linemerge from pvfactors.config import COLOR_DIC from pvfactors.geometry.base import \ BaseSide, _coords_from_center_tilt_length, PVSegment @@ -654,9 +655,10 @@ def n_ts_surfaces(self): class PVRowSide(BaseSide): """A PV row side represents the whole surface of one side of a PV row. + At its core it will contain a fixed number of :py:class:`~pvfactors.geometry.base.PVSegment` objects that will together - constitue one side of a PV row: a PV row side can also be + constitute one side of a PV row: a PV row side can also be "discretized" into multiple segments""" def __init__(self, list_segments=[]): @@ -671,7 +673,7 @@ def __init__(self, list_segments=[]): super(PVRowSide, self).__init__(list_segments) -class PVRow(GeometryCollection): +class PVRow: """A PV row is made of two PV row sides, a front and a back one.""" def __init__(self, front_side=PVRowSide(), back_side=PVRowSide(), @@ -689,14 +691,42 @@ def __init__(self, front_side=PVRowSide(), back_side=PVRowSide(), original_linestring : :py:class:`shapely.geometry.LineString`, optional Full continuous linestring that the PV row will be made of (Default = None) - """ self.front = front_side self.back = back_side self.index = index - self.original_linestring = original_linestring - self._all_surfaces = None - super(PVRow, self).__init__([self.front, self.back]) + if original_linestring is None: + # Compute the union of the front and back sides, assumedly a + # linestring with only two points (TODO: check this assumption / + # issue a warning here) + self._linestring = LineString(linemerge(unary_union( + [front_side.geometry, back_side.geometry])).boundary.geoms) + else: + self._linestring = original_linestring + + @property + def length(self): + """Length of the PV row.""" + return self.front.length + self.back.length + + @property + def boundary(self): + return self._linestring.boundary + + def intersects(self, line): + """Check if the PV row intersects with a line. + + Parameters + ---------- + line : :py:class:`shapely.geometry.LineString` + Line to check for intersection + + Returns + ------- + bool + True if the PV row intersects with the line, False otherwise + """ + return self._linestring.intersects(line) @classmethod def from_linestring_coords(cls, coords, shaded=False, normal_vector=None, @@ -719,7 +749,7 @@ def from_linestring_coords(cls, coords, shaded=False, normal_vector=None, Eg {'front': 3, 'back': 2} will lead to 3 segments on front side and 2 segments on back side. (Default = {}) param_names : list of str, optional - Names of the surface parameters, eg reflectivity, total incident + Names of the surface parameters, e.g. reflectivity, total incident irradiance, temperature, etc. (Default = []) Returns @@ -812,13 +842,13 @@ def plot(self, ax, color_shaded=COLOR_DIC['pvrow_shaded'], @property def boundary(self): - """Boundaries of the PV Row's orginal linestring.""" - return self.original_linestring.boundary + """Boundaries of the PV Row's original linestring.""" + return self._linestring.boundary @property def highest_point(self): """Highest point of the PV Row.""" - b1, b2 = self.boundary + b1, b2 = self.boundary.geoms highest_point = b1 if b1.y > b2.y else b2 return highest_point @@ -832,11 +862,7 @@ def lowest_point(self): @property def all_surfaces(self): """List of all the surfaces in the PV row.""" - if self._all_surfaces is None: - self._all_surfaces = [] - self._all_surfaces += self.front.all_surfaces - self._all_surfaces += self.back.all_surfaces - return self._all_surfaces + return self.front.all_surfaces + self.back.all_surfaces @property def surface_indices(self): diff --git a/pvfactors/geometry/utils.py b/pvfactors/geometry/utils.py index 8025768..1906e47 100644 --- a/pvfactors/geometry/utils.py +++ b/pvfactors/geometry/utils.py @@ -23,8 +23,8 @@ def difference(u, v): :py:class:`shapely.geometry.LineString` Resulting difference of current surface minus given linestring """ - ub1, ub2 = u.boundary - vb1, vb2 = v.boundary + ub1, ub2 = u.boundary.geoms + vb1, vb2 = v.boundary.geoms u_contains_vb1 = contains(u, vb1) u_contains_vb2 = contains(u, vb2) v_contains_ub1 = contains(v, ub1) @@ -73,7 +73,7 @@ def difference(u, v): def contains(linestring, point, tol_distance=DISTANCE_TOLERANCE): """Fixing floating point errors obtained in shapely for contains""" - return linestring.distance(point) < tol_distance + return linestring.dwithin(point, tol_distance) def is_collinear(list_elements): @@ -130,7 +130,7 @@ def projection(point, vector, linestring, must_contain=True): a, b = -vector[1], vector[0] c = - (a * point.x + b * point.y) # Define equation d*x + e*y +f = 0 - b1, b2 = linestring.boundary + b1, b2 = linestring.boundary.geoms d, e = - (b2.y - b1.y), b2.x - b1.x f = - (d * b1.x + e * b1.y) # TODO: check that two lines are not parallel diff --git a/pvfactors/tests/test_geometry/test_base.py b/pvfactors/tests/test_geometry/test_base.py index 1813aae..8a45e64 100644 --- a/pvfactors/tests/test_geometry/test_base.py +++ b/pvfactors/tests/test_geometry/test_base.py @@ -10,9 +10,7 @@ def test_baseside(pvsegments): """Test that the basic BaseSide functionalities work""" - side = BaseSide(pvsegments) - np.testing.assert_array_equal(side.n_vector, [0, 1]) assert side.shaded_length == 1. @@ -147,7 +145,6 @@ def test_cast_shadow_side(): def test_pvsurface_difference_precision_error(): """This would lead to wrong result using shapely ``difference`` method""" - surf_1 = PVSurface([(0, 0), (3, 2)]) surf_2 = PVSurface([surf_1.interpolate(1), Point(6, 4)]) diff = surf_1.difference(surf_2) diff --git a/pvfactors/tests/test_geometry/test_utils.py b/pvfactors/tests/test_geometry/test_utils.py index 6f81e15..bb47dc0 100644 --- a/pvfactors/tests/test_geometry/test_utils.py +++ b/pvfactors/tests/test_geometry/test_utils.py @@ -22,17 +22,17 @@ def test_projection(): # Should be 2nd boundary line_3 = LineString([(0, 0), (1, 0)]) inter = projection(pt_1, vector, line_3) - assert inter == line_3.boundary[1] + assert inter == line_3.boundary.geoms[1] # Should be 1st boundary pt_2 = Point(0, 1) inter = projection(pt_2, vector, line_3) - assert inter == line_3.boundary[0] + assert inter == line_3.boundary.geoms[0] # Should be 1st boundary: very close pt_3 = Point(0 + 1e-9, 1) inter = projection(pt_3, vector, line_3) - assert inter == line_3.boundary[0] + assert inter == line_3.boundary.geoms[0] # Should be empty: very close pt_4 = Point(0 - 1e-9, 1) diff --git a/pvfactors/viewfactors/vfmethods.py b/pvfactors/viewfactors/vfmethods.py index 272abb6..aeb1645 100644 --- a/pvfactors/viewfactors/vfmethods.py +++ b/pvfactors/viewfactors/vfmethods.py @@ -176,9 +176,9 @@ def vf_pvrow_surf_to_gnd_surf_obstruction_hottel( # Use reciprocity to calculate ts vf from gnd surf to pv row surface gnd_surf_length = gnd_surf.length - vf_gnd_to_pvrow_surf = np.where( - gnd_surf_length > DISTANCE_TOLERANCE, - vf_pvrow_to_gnd_surf * pvrow_surf_length / gnd_surf_length, 0.) + vf_gnd_to_pvrow_surf = np.divide( + vf_pvrow_to_gnd_surf * pvrow_surf_length, gnd_surf_length, + where=gnd_surf_length > DISTANCE_TOLERANCE, out=np.zeros_like(gnd_surf_length)) return vf_pvrow_to_gnd_surf, vf_gnd_to_pvrow_surf @@ -239,9 +239,10 @@ def vf_pvrow_to_pvrow(self, ts_pvrows, tilted_to_left, vf_matrix): vf_i_to_j = self._vf_surface_to_surface( surf_i.coords, surf_j.coords, length_i) vf_i_to_j = np.where(tilted_to_left, vf_i_to_j, 0.) - vf_j_to_i = np.where( - surf_j.length > DISTANCE_TOLERANCE, - vf_i_to_j * length_i / length_j, 0.) + vf_j_to_i = np.divide( + vf_i_to_j * length_i , length_j, + where=length_j > DISTANCE_TOLERANCE, + out=np.zeros_like(length_j)) vf_matrix[i, j, :] = vf_i_to_j vf_matrix[j, i, :] = vf_j_to_i @@ -529,9 +530,10 @@ def _vf_hottel_gnd_surf(self, high_pt_pv, low_pt_pv, left_pt_gnd, shadow_is_left) d2 = self._hottel_string_length(low_pt_pv, right_pt_gnd, obstr_pt, shadow_is_left) - vf_1_to_2 = (d1 + d2 - l1 - l2) / (2. * width) # The formula doesn't work if surface is a point - vf_1_to_2 = np.where(width > DISTANCE_TOLERANCE, vf_1_to_2, 0.) + vf_1_to_2 = np.divide(d1 + d2 - l1 - l2, 2. * width, + where=width > DISTANCE_TOLERANCE, + out=np.zeros_like(width)) return vf_1_to_2 @@ -605,9 +607,10 @@ def _vf_surface_to_surface(self, line_1, line_2, width_1): length_4 = self._distance(line_1.b2, line_2.b1) sum_1 = length_1 + length_2 sum_2 = length_3 + length_4 - vf_1_to_2 = np.abs(sum_2 - sum_1) / (2. * width_1) # The formula doesn't work if the line is a point - vf_1_to_2 = np.where(width_1 > DISTANCE_TOLERANCE, vf_1_to_2, 0.) + vf_1_to_2 = np.divide(np.abs(sum_2 - sum_1), 2. * width_1, + where=width_1 > DISTANCE_TOLERANCE, + out=np.zeros_like(width_1)) return vf_1_to_2 diff --git a/pyproject.toml b/pyproject.toml index 326c290..ada2d40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] dependencies = [ "pvlib>=0.9.0", - "shapely>=1.6.4.post2,<2", + "shapely>=2.0", "matplotlib", "future", "six", @@ -39,7 +39,7 @@ test = [ "mock", ] doc = [ - "Sphinx~=4.0", + "sphinx", "sphinx_rtd_theme", "nbsphinx", "sphinxcontrib_github_alt", diff --git a/readthedocs.yml b/readthedocs.yml index 60c6534..7e4ec71 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -1,12 +1,9 @@ version: 2 -sphinx: - configuration: docs/sphinx/conf.py - build: os: "ubuntu-22.04" tools: - python: "3.8" + python: "3.11" python: install: @@ -14,3 +11,6 @@ python: path: . extra_requirements: - doc + +sphinx: + configuration: docs/sphinx/conf.py