Skip to content

Commit

Permalink
Direct solar angle input
Browse files Browse the repository at this point in the history
Adds second cli function that allows for direct solar angle input
avoiding having to calculate the shadows across the world.
  • Loading branch information
tomellm committed May 23, 2024
1 parent 6ed3cad commit e998a57
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 38 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ env.bak/
venv.bak/

# Output files
*.png
*.png

**/.DS_Store
48 changes: 45 additions & 3 deletions shadowfinder/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import datetime

from shadowfinder.shadowfinder import ShadowFinder

Expand All @@ -11,7 +11,6 @@ def _validate_args(
"""
Validate the text search CLI arguments, raises an error if the arguments are invalid.
"""

if not object_height:
raise ValueError("Object height cannot be empty")
if not shadow_length:
Expand All @@ -20,6 +19,19 @@ def _validate_args(
raise ValueError("Date time cannot be empty")


def _validate_args_sun(
sun_altitude_angle: float,
date_time: datetime,
) -> None:
"""
Validate the text search CLI arguments, raises an error if the arguments are invalid.
"""
if not sun_altitude_angle:
raise ValueError("Sun altitude angle cannot be empty")
if not date_time:
raise ValueError("Date time cannot be empty")


class ShadowFinderCli:

@staticmethod
Expand All @@ -28,6 +40,7 @@ def find(
shadow_length: float,
date: str,
time: str,
time_format: str = "utc",
) -> None:
"""
Find the shadow length of an object given its height and the date and time.
Expand All @@ -43,5 +56,34 @@ def find(
raise ValueError(f"Invalid argument type or format: {e}")
_validate_args(object_height, shadow_length, date_time)

shadow_finder = ShadowFinder(object_height, shadow_length, date_time)
shadow_finder = ShadowFinder(
object_height, shadow_length, date_time, time_format
)
shadow_finder.quick_find()

@staticmethod
def find_sun(
sun_altitude_angle: float,
date: str,
time: str,
time_format: str = "utc",
) -> None:
"""
Locate a shadow based on the solar altitude angle and the date and time.
:param sun_altitude_angle: Sun altitude angle in radians
:param date: Date in the format YYYY-MM-DD
:param time: UTC Time in the format HH:MM:SS
"""

try:
date_time = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M:%S")
except Exception as e:
raise ValueError(f"Invalid argument type or format: {e}")
_validate_args_sun(sun_altitude_angle, date_time)

shadow_finder = ShadowFinder(
date_time=date_time,
time_format=time_format,
sun_altitude_angle=sun_altitude_angle,
)
shadow_finder.quick_find()
121 changes: 87 additions & 34 deletions shadowfinder/shadowfinder.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,40 @@

class ShadowFinder:
def __init__(
self, object_height=None, shadow_length=None, date_time=None, time_format="utc"
self,
object_height=None,
shadow_length=None,
date_time=None,
time_format="utc",
sun_altitude_angle=None,
):

self.set_details(object_height, shadow_length, date_time, time_format)
self.set_details(
date_time, object_height, shadow_length, time_format, sun_altitude_angle
)

self.lats = None
self.lons = None
self.shadow_lengths = None
self.location_likelihoods = None

self.timezones = None
self.tf = TimezoneFinder(in_memory=True)

self.fig = None

self.angular_resolution=0.5
self.min_lat=-60
self.max_lat=85
self.min_lon=-180
self.max_lon=180
self.angular_resolution = 0.5
self.min_lat = -60
self.max_lat = 85
self.min_lon = -180
self.max_lon = 180

def set_details(self, object_height, shadow_length, date_time, time_format=None):
def set_details(
self,
date_time,
object_height=None,
shadow_length=None,
time_format=None,
sun_altitude_angle=None,
):
self.object_height = object_height
self.shadow_length = shadow_length
if date_time is not None and date_time.tzinfo is not None:
Expand All @@ -49,13 +62,19 @@ def set_details(self, object_height, shadow_length, date_time, time_format=None)
], "time_format must be 'utc' or 'local'"
self.time_format = time_format

self.sun_altitude_angle = sun_altitude_angle

def quick_find(self):
self.generate_timezone_grid()
self.find_shadows()
fig = self.plot_shadows()
fig.savefig(
f"shadow_finder_{self.date_time.strftime('%Y%m%d-%H%M%S')}-{self.time_format.title()}_{self.object_height}_{self.shadow_length}.png"
)

if self.sun_altitude_angle is not None:
file_name = f"shadow_finder_{self.date_time.strftime('%Y%m%d-%H%M%S')}-{self.time_format.title()}_{self.sun_altitude_angle}.png"
else:
file_name = f"shadow_finder_{self.date_time.strftime('%Y%m%d-%H%M%S')}-{self.time_format.title()}_{self.object_height}_{self.shadow_length}.png"

fig.savefig(file_name)

def generate_timezone_grid(self):
lats = np.arange(self.min_lat, self.max_lat, self.angular_resolution)
Expand Down Expand Up @@ -85,7 +104,7 @@ def save_timezone_grid(self, filename="timezone_grid.json"):

def load_timezone_grid(self, filename="timezone_grid.json"):
data = json.load(open(filename, "r"))

self.min_lat = data["min_lat"]
self.max_lat = data["max_lat"]
self.min_lon = data["min_lon"]
Expand Down Expand Up @@ -138,28 +157,51 @@ def find_shadows(self):

valid_sun_altitudes = pos_obj["altitude"] # in radians

# Calculate the shadow length
shadow_lengths = self.object_height / np.apply_along_axis(
np.tan, 0, valid_sun_altitudes
)
# If object height and shadow length are set the sun altitudes are used
# to calculate the shadow lengths across the world and then compared to
# the expected shadow length.
if self.object_height is not None and self.shadow_length is not None:
# Calculate the shadow length
shadow_lengths = self.object_height / np.apply_along_axis(
np.tan, 0, valid_sun_altitudes
)

# Replace points where the sun is below the horizon with nan
shadow_lengths[valid_sun_altitudes <= 0] = np.nan

# Replace points where the sun is below the horizon with nan
shadow_lengths[valid_sun_altitudes <= 0] = np.nan
# Show the relative difference between the calculated shadow length and the observed shadow length
location_likelihoods = (
shadow_lengths - self.shadow_length
) / self.shadow_length

# Show the relative difference between the calculated shadow length and the observed shadow length
shadow_relative_length_difference = (
shadow_lengths - self.shadow_length
) / self.shadow_length
# If the sun altitude angle is set then this value is directly compared
# to the sun altitudes across the world.
elif self.sun_altitude_angle is not None:
# Show relative difference between sun altitudes
location_likelihoods = (
np.array(valid_sun_altitudes) - self.sun_altitude_angle
) / self.sun_altitude_angle

shadow_lengths = shadow_relative_length_difference
# Replace points where the sun is below the horizon
location_likelihoods[valid_sun_altitudes <= 0] = np.nan

else:
raise ValueError(
"Either object height and shadow length or sun altitude angle needs to be set."
)

if self.time_format == "utc":
self.shadow_lengths = shadow_lengths
self.location_likelihoods = location_likelihoods
elif self.time_format == "local":
self.shadow_lengths = np.full(np.shape(mask), np.nan)
np.place(self.shadow_lengths, mask, shadow_lengths)
self.shadow_lengths = np.reshape(
self.shadow_lengths, np.shape(self.lons), order="A"
self.location_likelihoods = np.full(np.shape(mask), np.nan)
np.place(
self.location_likelihoods,
mask,
location_likelihoods,
)

self.location_likelihoods = np.reshape(
self.location_likelihoods, np.shape(self.lons), order="A"
)

def plot_shadows(
Expand All @@ -183,11 +225,22 @@ def plot_shadows(
norm = colors.BoundaryNorm(np.arange(0, 0.2, 0.02), cmap.N)

# Plot the data
m.pcolormesh(x, y, np.abs(self.shadow_lengths), cmap=cmap, norm=norm, alpha=0.7)
m.pcolormesh(
x,
y,
np.abs(self.location_likelihoods),
cmap=cmap,
norm=norm,
alpha=0.7,
)

# plt.colorbar(label='Relative Shadow Length Difference')
plt.title(
f"Possible Locations at {self.date_time.strftime('%Y-%m-%d %H:%M:%S')} {self.time_format.title()}\n(object height: {self.object_height}, shadow length: {self.shadow_length})"
)

if self.sun_altitude_angle is not None:
plt_title = f"Possible Locations at {self.date_time.strftime('%Y-%m-%d %H:%M:%S')} {self.time_format.title()}\n(sun altitue angle: {self.sun_altitude_angle})"
else:
plt_title = f"Possible Locations at {self.date_time.strftime('%Y-%m-%d %H:%M:%S')} {self.time_format.title()}\n(object height: {self.object_height}, shadow length: {self.shadow_length})"

plt.title(plt_title)
self.fig = fig
return fig

0 comments on commit e998a57

Please sign in to comment.