Skip to content

Commit

Permalink
geospatial ground irradiance
Browse files Browse the repository at this point in the history
  • Loading branch information
tobin-ford committed Nov 20, 2024
1 parent 49232fc commit ee05305
Show file tree
Hide file tree
Showing 6 changed files with 888 additions and 36 deletions.
Binary file added pvdeg/data/inspireInstance.pkl
Binary file not shown.
71 changes: 58 additions & 13 deletions pvdeg/geospatial.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
humidity,
letid,
utilities,
pysam,
)


import xarray as xr
import dask.array as da
import pandas as pd
Expand All @@ -27,6 +29,8 @@
from typing import Tuple
from shapely import LineString, MultiLineString

# {var: (dim, da.empty([dims_size[d] for d in dim]), attrs.get(var)) for var, dim in shapes.items()}

def start_dask(hpc=None):
"""
Starts a dask cluster for parallel processing.
Expand Down Expand Up @@ -88,8 +92,31 @@ def start_dask(hpc=None):

return client

def _ds_from_arbitrary(res, func):
"""
Convert an arbitrary return type to xarray.Dataset.
"""

if isinstance(res, pysam.inspirePysamReturn):
return pysam._handle_pysam_return(res)
# add more conditionals if we have special cases
# or add general case for mixed return dimensions: HARD

# handles collections with elements of same shapes
df = _df_from_arbitrary(res=res, func=func)
ds = xr.Dataset.from_dataframe(df)

if not df.index.name:
ds = ds.isel(index=0, drop=True)

return ds


def _df_from_arbitrary(res, func):
"""
Convert an arbitrary return type to dataframe.
Results must be of similar shape currently. Either all numerics or all timeseries.
"""
numerics = (int, float, np.number)
arrays = (np.ndarray, pd.Series)

Expand All @@ -102,12 +129,13 @@ def _df_from_arbitrary(res, func):
elif isinstance(res, tuple) and all(isinstance(item, numerics) for item in res):
return pd.DataFrame([res])
elif isinstance(res, tuple) and all(isinstance(item, arrays) for item in res):
# add check for same size, raise value error otherwise
return pd.concat(
res, axis=1
) # they must all be the same length here or this will error out
else:
raise NotImplementedError(
f"function return type: {type(res)} not available for geospatial analysis yet."
f"function return type: {type(res)} not available for geospatial analysis yet. This could be result of mismatched coordinates of outputs. EX. tuple(dataframe, int)."
)


Expand Down Expand Up @@ -137,20 +165,15 @@ def calc_gid(ds_gid, meta_gid, func, **kwargs):
if type(meta_gid["latitude"]) == dict:
meta_gid = utilities.fix_metadata(meta_gid)

df_weather = (
ds_gid.to_dataframe()
) # set time index here? is there any reason the weather shouldn't always have only pd.datetime index? @ martin?
# set time index here? is there any reason the weather shouldn't always have only pd.datetime index? @ martin?
df_weather = ds_gid.to_dataframe()
if isinstance(
df_weather.index, pd.MultiIndex
): # check for multiindex and convert to just time index, don't know what was causing this
df_weather = df_weather.reset_index().set_index("time")

res = func(weather_df=df_weather, meta=meta_gid, **kwargs)
df_res = _df_from_arbitrary(res, func) # convert all return types to dataframe
ds_res = xr.Dataset.from_dataframe(df_res)

if not df_res.index.name:
ds_res = ds_res.isel(index=0, drop=True)
ds_res = _ds_from_arbitrary(res, func)

return ds_res

Expand Down Expand Up @@ -268,17 +291,39 @@ def output_template(
dims = set([d for dim in shapes.values() for d in dim])
dims_size = dict(ds_gids.sizes) | add_dims

# we need to properly add the dimensions
# can we redo with dictionary comprehension
# best approach @martin?
coords = {}
for dim in dims:
if dim in ds_gids:
coords[dim] = ds_gids[dim]
elif dim in add_dims:
coords[dim] = np.arange(dims_size[dim]) # i dont like this
else:
raise ValueError(f"dim: {dim} not in ds_gids or add_dims")


output_template = xr.Dataset(
data_vars={
var: (dim, da.empty([dims_size[d] for d in dim]), attrs.get(var)) # this will produce a dask array with 1 chunk of the same size as the input
for var, dim in shapes.items()
},
coords={dim: ds_gids[dim] for dim in dims},
# moved as part of the above changes
# coords={dim: ds_gids[dim] for dim in dims},
coords=coords,
attrs=global_attrs,
)

if ds_gids.chunks: # chunk to match input
output_template = output_template.chunk({dim: ds_gids.chunks[dim] for dim in dims})

# chunk to match input
if ds_gids.chunks:
output_template = output_template.chunk(
{
dim: ds_gids.chunks[dim]
for dim in dims
if dim in ds_gids
} # only chunk dimensions existing in the input
)

return output_template

Expand Down
80 changes: 73 additions & 7 deletions pvdeg/pysam.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
"""
Pysam Integration for pvdeg, supports single site and geospatial calculations.
Produced to support Inspire Agrivoltaics: https://openei.org/wiki/InSPIRE
"""

import dask.dataframe as dd
import pandas as pd
import pickle
import json
import sys
import os
Expand All @@ -14,6 +20,7 @@
from pvdeg import (
weather,
utilities,
DATA_DIR
)

def vertical_POA(
Expand Down Expand Up @@ -316,7 +323,7 @@ def pysam(

# Duplicate Columns in the dataframe seem to cause this issue
# Error (-4) converting nested tuple 0 into row in matrix.
pysam_model.SolarResource.solar_resource_data = solar_resource
pysam_model.SolarResource.solar_resource_data = sr
pysam_model.execute()
outputs = pysam_model.Outputs.export()

Expand Down Expand Up @@ -359,11 +366,51 @@ def pysam_hourly_trivial(weather_df, meta):

return outputs

# required to safely unpack results during geospatial.analysis
class inspirePysamReturn():
def __init__(self, annual_poa, ground_irradiance, timeseries_index):
self.annual_poa = annual_poa
self.ground_irradiance = ground_irradiance
self.timeseries_index = timeseries_index


# rename?
import xarray as xr
def _handle_pysam_return(pysam_res : inspirePysamReturn) -> xr.Dataset:
"""Handle a pysam return object and transform it to an xarray"""

import dask.array as da
import numpy as np

# redo this using numba?
ground_irradiance_values = da.from_array([row[1:] for row in pysam_res.ground_irradiance[1:]])

single_location_ds = xr.Dataset(
data_vars={
"annual_poa" : pysam_res.annual_poa, # scalar variable
"ground_irradiance" : (("time", "distance"), ground_irradiance_values)
},
coords={
# "time" : np.arange(17520), # this will probably break because of the values assigned here, should be a pd.datetimeindex instead
"time" : pysam_res.timeseries_index,
# "distance" : np.array(pysam_res.ground_irradiance[0][1:])
"distance" : np.arange(10), # this matches the dimension axis of the output_temlate dataset
}
)

return single_location_ds


# annual_poa_nom, annual_poa_front, annual_poa_rear, poa_nom, poa_front, or poa_rear
# TODO: add config file
# TODO: add config file, multiple config files.
def inspire_ground_irradiance(weather_df, meta):
"""
Get ground irradiance array and annual poa irradiance for a given point using pvsamv1
Returns
--------
result : inspirePysamReturn
returns an custom class object so we can unpack it later.
"""

sr = solar_resource_dict(weather_df=weather_df, meta=meta)
Expand All @@ -373,13 +420,25 @@ def inspire_ground_irradiance(weather_df, meta):
model.execute()
outputs = model.Outputs.export()

# we only want these two
outputs = pysam(
weather_df = weather_df,
meta = meta,
pv_model = "pysamv1",
pv_model_default = "FlatPlatePVCommercial", # should use config file instead
results = ["subarray1_ground_rear_spatial", "annual_poa_front"],
)

# these will be of very different shapes
# annual_poa_front is a single numeric
# subarray1_ground_rear_spatial is a 2d result

inspire_res = {}
inspire_res["subarray1_ground_rear_spatial"] = outputs["subarray1_ground_rear_spatial"]
inspire_res["annual_poa_front"] = outputs["annual_poa_front"]
result = inspirePysamReturn(
ground_irradiance = outputs["subarray1_ground_rear_spatial"],
annual_poa = outputs["annual_poa_front"],
timeseries_index=weather_df.index,
)

return inspire_res
return result

def solar_resource_dict(weather_df, meta):
"""
Expand Down Expand Up @@ -409,3 +468,10 @@ def solar_resource_dict(weather_df, meta):
}

return sr

def sample_pysam_result(weather_df, meta): # throw weather, meta away
"""returns a sample inspirePysamReturn"""
with open(os.path.join(DATA_DIR,"inspireInstance.pkl"), "rb") as file:
inspireInstance = pickle.load(file)

return inspireInstance
40 changes: 30 additions & 10 deletions pvdeg/temperature.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,23 @@ def map_model(temp_model: str, cell_or_mod: str) -> callable:

# double check that models are in correct maps
module = { # only module
"sapm": pvlib.temperature.sapm_module,
"sapm_mod": pvlib.temperature.sapm_module,
"sapm" : pvlib.temperature.sapm_module,
"sapm_mod" : pvlib.temperature.sapm_module,
}

cell = { # only cell
"sapm": pvlib.temperature.sapm_cell,
"sapm_cell": pvlib.temperature.sapm_cell,
"pvsyst": pvlib.temperature.pvsyst_cell,
"ross": pvlib.temperature.ross,
"noct_sam": pvlib.temperature.noct_sam,
"sapm" : pvlib.temperature.sapm_cell,
"sapm_cell" : pvlib.temperature.sapm_cell,
"pvsyst" : pvlib.temperature.pvsyst_cell,
"ross" : pvlib.temperature.ross,
"noct_sam" : pvlib.temperature.noct_sam,
"generic_linear": pvlib.temperature.generic_linear,
}

agnostic = { # module or cell
"faiman": pvlib.temperature.faiman,
"faiman_rad": pvlib.temperature.faiman_rad,
"fuentes": pvlib.temperature.fuentes,
"faiman" : pvlib.temperature.faiman,
"faiman_rad" : pvlib.temperature.faiman_rad,
"fuentes" : pvlib.temperature.fuentes,
}

super_map = {"module": module, "cell": cell}
Expand Down Expand Up @@ -409,3 +409,23 @@ def temperature(
temperature = func(**model_args)

return temperature

def _mixed_res(weather_df, meta):
"""
geospatial test function. returns have mixed dimensions. the first is a timeseries, the second is a float.
This function is meant to discover problems with geospatial.analysis and its subroutines.
.. code_block : Python
Shapes = {
"temperatures" : ("gid", "time"),
"avg_temp" : ("gid", ),
}
"""

timeseries_df = pd.DataFrame(pvdeg.temperature.module(weather_df, meta))
avg_temp = timeseries_df[0].mean()

# now we have this problem. how can we run this
# return {'temperatures' : timeseries_df, 'avg_temp' : avg_temp}
return timeseries_df, avg_temp
709 changes: 709 additions & 0 deletions scripts/pysam_america.ipynb

Large diffs are not rendered by default.

24 changes: 18 additions & 6 deletions tutorials_and_tools/tutorials_and_tools/PYSam.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,21 @@
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 1,
"metadata": {},
"outputs": [],
"outputs": [
{
"ename": "NameError",
"evalue": "name 'pvdeg' is not defined",
"output_type": "error",
"traceback": [
"\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
"\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)",
"Cell \u001b[0;32mIn[1], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m location_grabber \u001b[38;5;241m=\u001b[39m pvdeg\u001b[38;5;241m.\u001b[39mscenario\u001b[38;5;241m.\u001b[39mGeospatialScenario()\n\u001b[1;32m 3\u001b[0m location_grabber\u001b[38;5;241m.\u001b[39maddLocation(country\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mUnited States\u001b[39m\u001b[38;5;124m\"\u001b[39m, downsample_factor\u001b[38;5;241m=\u001b[39m\u001b[38;5;241m80\u001b[39m)\n",
"\u001b[0;31mNameError\u001b[0m: name 'pvdeg' is not defined"
]
}
],
"source": [
"location_grabber = pvdeg.scenario.GeospatialScenario()\n",
"\n",
Expand Down Expand Up @@ -228,9 +240,9 @@
],
"metadata": {
"kernelspec": {
"display_name": "deg",
"display_name": "rpp",
"language": "python",
"name": "python3"
"name": "rpp"
},
"language_info": {
"codemirror_mode": {
Expand All @@ -242,9 +254,9 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.9.19"
"version": "3.10.14"
}
},
"nbformat": 4,
"nbformat_minor": 2
"nbformat_minor": 4
}

0 comments on commit ee05305

Please sign in to comment.