# -*- coding: utf-8 -*-
# Copyright (c) 2019 Stephen Wasilewski
# =======================================================================
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
# =======================================================================
import numpy as np
from matplotlib.path import Path
from raytraverse import translate, io
from raytraverse.sky import skycalc
from raytraverse.mapper.mapper import Mapper
from raytraverse.mapper.angularmixin import AngularMixin
[docs]class SkyMapper(AngularMixin, Mapper):
"""translate between world direction vectors and normalized UV space for a
given view angle. pixel projection yields equiangular projection
Parameters
----------
loc: any, optional
can be a number of formats:
1. either a numeric iterable of length 3 (lat, lon, mer)
where lat is +west and mer is tz*15 (matching gendaylit).
2. an array (or tsv file loadable with np.loadtxt) of shape
(N,3), (N,4), or (N,5):
a. 2 elements: alt, azm (angles in degrees)
b. 3 elements: dx,dy,dz of sun positions
c. 4 elements: alt, azm, dirnorm, diffhoriz (angles in degrees)
d. 5 elements: dx, dy, dz, dirnorm, diffhoriz.
3. path to an epw or wea formatted file
4. None (default) all possible sun positions are considered
self.in_solarbounds always returns True
in the case of a geo location, sun positions are considered valid when
in the solar transit for that location. for candidate options, sun
positions are drawn from this set (with one randomly chosen from all
candidates within bin.
skyro: float, optional
counterclockwise sky-rotation in degrees (equivalent to clockwise
project north rotation)
sunres: float, optional
initial sampling resolution for suns
name: str, optional
"""
_flipu = False
_xsign = 1
def __init__(self, loc=None, skyro=0.0, sunres=9, name='suns',
jitterrate=0.5):
self._viewangle = 180.0
self._chordfactor = 1.0
self._ivm = None
self.skyro = skyro
self.sunres = sunres
super().__init__(name=name, aspect=1, jitterrate=jitterrate)
self.loc = loc
@property
def skyro(self):
return self._skyro
@skyro.setter
def skyro(self, ro):
self._skyro = ro
self._skyromtx = translate.rmtx_elem(ro)
@property
def loc(self):
return self._loc
@loc.setter
def loc(self, location):
if location is None:
self._candidates = None
self._loc = None
self.in_solarbounds_uv = self._test_uv_none
self.solar_grid_uv = self._solar_grid_uv
elif type(location) == str:
self._load_sun_data(location)
self.in_solarbounds_uv = self._test_uv_candidates
self.solar_grid_uv = self._solar_grid_uv_candidates
elif (np.asarray(location).size > 3 or
len(np.asarray(location).shape) == 2):
self._norm_input_data(np.asarray(location))
self.in_solarbounds_uv = self._test_uv_candidates
self.solar_grid_uv = self._solar_grid_uv_candidates
else:
self._load_sun_border(location)
self.in_solarbounds_uv = self._test_uv_boundary
self.solar_grid_uv = self._solar_grid_uv
@property
def solarbounds(self):
return self._solarbounds
@property
def candidates(self):
return self._candidates
[docs] def in_solarbounds(self, xyz, level=0, include='any'):
"""for checking if src direction is in solar transit
Parameters
----------
xyz: np.array
source directions
level: int
for determining patch size, 2**level resolution from sunres
include: {'center', 'all', 'any'}, optional
boundary test condition. 'center' tests uv only, 'all' requires
for corners of box centered at uv to be in, 'any' requires atleast
one corner. 'any' is the least restrictive and 'all' is the most,
but with increasing levels 'any' will exclude more positions while
'all' will exclude less (both approaching 'center' as level -> N)
Returns
-------
result: np.array
Truth of ray.src within solar transit
"""
uv = self.xyz2uv(xyz)
return self.in_solarbounds_uv(uv, level=level, include=include)
[docs] def shape(self, level=0):
s = self.sunres * 2**level
return s, s
[docs] def solar_grid(self, jitter=True, level=0, masked=True):
"""generate a grid of solar positions
Parameters
----------
jitter: bool, optional
if None, use the instance default, if True jitters point samples
within stratified grid
level: int, optional
sets the resolution of the grid as a power of 2 from sunress
masked: bool, optional
apply in_solarbounds to suns before returning
Returns
-------
np.array
shape (N, 3)
"""
return self.uv2xyz(self.solar_grid_uv(jitter, level, masked))
def _solar_grid_uv(self, jitter=True, level=0, masked=True):
"""add a grid of UV coordinates
Parameters
----------
jitter: bool, optional
if None, use the instance default, if True jitters point samples
within stratified grid
level: int, optional
sets the resolution of the grid as a power of 2 from sunres
masked: bool, optional
apply in_solarbounds before returning
Returns
-------
np.array
shape (N, 2)
"""
shape = self.shape(level)
idx = np.arange(np.product(shape))
uv = self.idx2uv(idx, shape, jitter)
if masked:
return uv[self.in_solarbounds_uv(uv, level=level)]
else:
return uv
def _solar_grid_uv_candidates(self, jitter=True, level=0, masked=True):
"""add a grid of UV coordinates
Parameters
----------
jitter: bool, optional
jitters weighting of condidate selection (not centered)
level: int, optional
sets the resolution of the grid as a power of 2 from sunres
masked: bool, optional
apply in_solarbounds before returning
Returns
-------
np.array
shape (N, 2)
"""
uvsize, _ = self.shape(level)
cbins = translate.uv2bin(self._candidates, uvsize)
sbins = np.arange(uvsize**2)
suv = self._solar_grid_uv(jitter=False, level=level, masked=False)
if masked:
mask = self.in_solarbounds_uv(suv, level=level)
sbins = sbins[mask]
suv = suv[mask]
sunsuv = []
badsun = np.array([1.5, .5])
for b, uv in zip(sbins, suv):
# choose sun position, priortizing values closer to center of bin
# or closest to random point with jitter=True
candidates = self._candidates[cbins == b]
if candidates.shape[0] == 1:
a = candidates[0]
elif candidates.size > 1:
binc = self.uv2xyz(uv)
bp = np.linalg.norm(self.uv2xyz(candidates) - binc,
axis=1)
if jitter:
a = np.random.default_rng().choice(candidates, axis=0,
p=bp/np.sum(bp))
else:
a = candidates[np.argmin(bp)]
else:
a = badsun
sunsuv.append(a)
return np.array(sunsuv)
def _load_sun_border(self, loc):
self._candidates = None
self._loc = loc
jun = np.arange('2020-06-21', '2020-06-22', 5,
dtype='datetime64[m]')
dec = np.arange('2020-12-21', '2020-12-22', 5,
dtype='datetime64[m]')
jxyz = skycalc.sunpos_xyz(jun, *loc)
dxyz = skycalc.sunpos_xyz(dec, *loc)
# to close paths well outside UV box
extrema = np.array([(1000, 0, 0), (-1000, 0, 0)])
jxyz = (self._skyromtx@jxyz.T).T
dxyz = (self._skyromtx@dxyz.T).T
extrema = (self._skyromtx@extrema.T).T
juv = self.xyz2uv(jxyz[jxyz[:, 2] > 0])
duv = self.xyz2uv(dxyz[dxyz[:, 2] > 0])[::-1]
# handle (ant)arctic circles
if duv.size == 0:
dxyz = (self._skyromtx@np.array([(0, -1000, 0)]).T).T
duv = self.xyz2uv(dxyz)
if juv.size == 0:
jxyz = (self._skyromtx@np.array([(0, 1000, 0)]).T).T
juv = self.xyz2uv(jxyz)
euv = self.xyz2uv(extrema)
bounds = np.vstack((euv[0:1], juv, euv[1:], duv, euv[0:1]))
bpath = Path(bounds, closed=True)
self._solarbounds = bpath
def _load_sun_data(self, wea):
try:
dat = np.atleast_2d(io.load_txt(wea))
self._norm_input_data(dat)
except ValueError:
loc = skycalc.get_loc_epw(wea)
wdat = skycalc.read_epw(wea)
times = skycalc.row_2_datetime64(wdat[:, 0:3])[wdat[:, 3] > 0]
cxyz = skycalc.sunpos_xyz(times, *loc)
self._loc = loc
self._candidates = self.xyz2uv((self._skyromtx@cxyz.T).T)
self._solarbounds = None
def _norm_input_data(self, dat):
if dat.shape[1] == 2:
cxyz = translate.aa2xyz(dat[dat[:, 0] > 0])
elif dat.shape[1] == 3:
cxyz = translate.norm(dat[dat[:, 2] > 0])
elif dat.shape[1] == 4:
cxyz = translate.aa2xyz(dat[np.logical_and(dat[:, 0] > 0,
dat[:, 2] > 0), 0:2])
elif dat.shape[1] == 5:
cxyz = translate.norm(dat[np.logical_and(dat[:, 2] > 0,
dat[:, 3] > 0), 0:3])
else:
raise ValueError("Data has wrong shape, must be (N, {2,3,4,5})")
self._loc = None
self._candidates = self.xyz2uv((self._skyromtx@cxyz.T).T)
self._solarbounds = None
def _test_uv_candidates(self, uv, level=0, **kwargs):
uvsize = self.sunres * 2**level
cbins = np.unique(translate.uv2bin(self._candidates, uvsize))
uvbins = translate.uv2bin(uv, uvsize)
return np.isin(uvbins, cbins)
def _test_uv_boundary(self, uv, level=0, include='any'):
""" for checking if src direction is in solar transit when
self._solarbounds is set
Parameters
----------
uv: np.array
source directions
level: int
for determining patch size, 2**level resolution from sunres
include: {'center', 'all', 'any'}, optional
boundary test condition. 'center' tests uv only, 'all' requires
for corners of box centered at uv to be in, 'any' requires atleast
one corner. 'any' is the least restrictive and 'all' is the most,
but with increasing levels 'any' will exclude more positions while
'all' will exclude less (both approaching 'center' as level -> N)
Returns
-------
result: np.array
Truth of ray.src within solar transit
"""
uvs = uv.reshape(-1, 2)
if include == 'center':
result = self._solarbounds.contains_points(uvs)
else:
offsets = np.array([(-1, -1), (1, -1), (1, 1), (-1, 1)],
dtype=np.float64)
offsets *= 1 / (self.sunres * 2**(level + 1))
uvs = uv[:, None, :] + offsets[None, ...]
result = self._solarbounds.contains_points(uvs.reshape(-1, 2))
# include should be any or all -> np.all, np.any
result = getattr(np, include)(result.reshape(-1, 4), 1)
return result
@staticmethod
def _test_uv_none(uv, **kwargs):
"""default no uv solar position filtering (see loc.setter)"""
return np.full(uv.shape[0], True)