Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add solar intensity based on declination angle (single parameter "latitude") #24

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions openskistats/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
add_spatial_metric_columns,
get_bearing_histograms,
get_bearing_summary_stats,
get_solar_intensity_batch,
)

from openskistats.models import BearingStatsModel, SkiAreaModel
from openskistats.openskimap_utils import (
load_downhill_ski_areas_from_download_pl,
Expand Down Expand Up @@ -93,6 +95,11 @@ def analyze_all_ski_areas_polars(skip_runs: bool = False) -> None:
combined_vertical=pl.col("distance_vertical_drop").sum(),
combined_distance=pl.col("distance_3d").sum(),
latitude=pl.col("latitude").mean(),
solar_intensity = pl.col("latitude")
.map_batches(
lambda batch: get_solar_intensity_batch(batch),
return_dtype=pl.Float64
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the type here is wrong?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return_dtype=pl.Float64

That looks correct to me, but you may need returns_scalar=True. Not entirely sure. The polars.Expr.map_batches docs are kinda confusing.

).mean(),
longitude=pl.col("longitude").mean(),
min_elevation=pl.col("elevation").min(),
max_elevation=pl.col("elevation").max(),
Expand Down Expand Up @@ -221,6 +228,7 @@ def aggregate_ski_areas_pl(
max_elevation=pl.max("max_elevation"),
vertical_drop=pl.max("max_elevation") - pl.min("min_elevation"),
latitude=pl.mean("latitude"),
solar_intensity=pl.mean("solar_intensity"),
longitude=pl.mean("longitude"),
_bearing_stats=pl.struct(
pl.col("bearing_mean").alias("bearing"),
Expand Down
97 changes: 97 additions & 0 deletions openskistats/bearing.py
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additions should go in a new sunlight.py module

Original file line number Diff line number Diff line change
Expand Up @@ -315,3 +315,100 @@ def get_bearing_summary_stats(
bearing_magnitude_net=round(net_magnitude, 7),
bearing_magnitude_cum=round(cum_magnitude, 7),
)


# def get_solar_intensity(
# latitudes: list[float] | npt.NDArray[np.float64]
# ) -> pl.DataFrame:


HOURS_IN_DAY = 24
HOURS_START = 9 # Start time (9 AM)
HOURS_END = 16 # End time (4 PM)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you evaluate existing libraries like:

For whether they could do these calculations and were suitable dependencies? For calculations like these that are highly technical, I think it's best if we start with existing tooling and only switch to a homegrown implementation if third party libs don't do what we want.

Code looks nice and methods appear correct to me, but also I know very little here and there are no references provided for which we can verify the methods.


def solar_declination(day_of_year: int) -> float:
"""
Calculate the solar declination for a given day of the year.
This uses the approximation formula for solar declination.
"""
# Calculate the solar declination using the approximation formula
# δ = 23.44 * sin(360° * (N + 10) / 365)
# Where N is the day of the year (1 to 365)
return 23.44 * np.sin(np.radians(360 * (day_of_year + 10) / 365))

def solar_intensity_at_time(latitude: float, declination: float, hour: int) -> float:
"""
Calculate the solar intensity at a given time of day (hour) for a given latitude and solar declination.
"""
# Convert latitude and declination to radians for trigonometric calculations
latitude_rad = np.radians(latitude)
declination_rad = np.radians(declination)

# Hour angle (ω) for a given time of day (simple model)
# Hour angle increases by 15 degrees per hour, starting from 12:00 PM.
hour_angle = np.radians(15 * (hour - 12))

# Calculate the solar zenith angle using the formula
# cos(θ) = sin(δ) * sin(φ) + cos(δ) * cos(φ) * cos(ω)
# Where:
# δ = solar declination, φ = latitude, ω = hour angle
solar_zenith = np.arccos(
np.sin(declination_rad) * np.sin(latitude_rad) +
np.cos(declination_rad) * np.cos(latitude_rad) * np.cos(hour_angle)
)

intensity = np.cos(solar_zenith)
return max(intensity, 0)

def integrate_solar_intensity(latitude: float, day_of_year: int) -> float:
"""
Integrate solar intensity from 9 AM to 4 PM.
The function returns the average solar intensity over this period.
"""
declination = solar_declination(day_of_year)
intensities = [solar_intensity_at_time(latitude, declination, hour) for hour in range(HOURS_START, HOURS_END)]

return np.mean(intensities)

def get_hemisphere(latitude: float) -> Literal["north", "south"] | None:
if latitude is None:
return None
elif latitude >= 0:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

latitude 0 should return None as per

def pl_hemisphere(latitude_col: str = "latitude") -> pl.Expr:
return (
pl.when(pl.col(latitude_col).gt(0))
.then(pl.lit("north"))
.when(pl.col(latitude_col).lt(0))
.then(pl.lit("south"))
)

and my past research into which hemisphere the equator is in. This will never arise in the data.

return "north"
elif latitude < 0:
return "south"
Comment on lines +373 to +379
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to utils



def get_solar_intensity(
latitude: float
) -> float:
"""
Calculate the average solar intensity from 9 AM to 4 PM for each latitude.

Args:
latitudes (list or ndarray): A list or numpy array of latitudes.
hemisphere: lets us know if we are in the northern or southern hemisphere

Returns:
polars.DataFrame: A DataFrame with latitudes and their corresponding average solar intensity.
"""
# For each latitude, calculate the solar intensity (assuming a fixed day of the year, e.g., 172nd day for mid-winter in southern hemisphere)
# and the 355th day for mid-winter in northern hemisphere
import logging
logging.info(f"latitude is {latitude}")
hemisphere: Literal["north", "south"] | None = None
hemisphere = get_hemisphere(latitude)
if hemisphere == "north":
day_of_year = 355 # Mid-winter day (e.g., December 21)
elif hemisphere == "south":
day_of_year = 172 # Mid-winter day (e.g., June 21)
else:
logging.info(f"hemisphere has value: {hemisphere}")
return None

# TODO we could dynamically use the open and close dates of each resort
avg_intensity = integrate_solar_intensity(latitude, day_of_year)
return avg_intensity

def get_solar_intensity_batch(latitudes: pl.Series) -> pl.Series:
return pl.Series([get_solar_intensity(latitude) for latitude in latitudes])
10 changes: 9 additions & 1 deletion openskistats/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ def get_ski_area_frontend_table(story: bool = False) -> pl.DataFrame:
"country_code",
"region",
"locality",
"solar_intensity",
"latitude",
"run_count",
"lift_count",
Expand Down Expand Up @@ -204,6 +205,7 @@ def _percent_diverging_style(ci: reactable.CellInfo) -> dict[str, Any] | None:
# add custom descriptions including overwriting SkiAreaModel descriptions
"latitude": "Hemisphere where the ski area is located, either ℕ for north or 𝕊 for south, "
"as well as the latitude (φ) in decimal degrees.",
"solar_intensity"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syntax issue, should be a key: value pair.

"bin_proportion_4_N": "Proportion of vertical-weighted run segments oriented with a northern cardinal direction.",
"bin_proportion_4_E": "Proportion of vertical-weighted run segments oriented with an eastern cardinal direction.",
"bin_proportion_4_S": "Proportion of vertical-weighted run segments oriented with a southern cardinal direction.",
Expand Down Expand Up @@ -385,6 +387,11 @@ def _ski_area_metric_style(ci: reactable.CellInfo) -> dict[str, Any] | None:
filter_method=reactable.JS("filterLatitude"),
min_width=60,
),
reactable.Column(
id="solar_intensity",
name=f"Solar",
**_column_kwargs_location_str,
),
Comment on lines +390 to +394
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not add to table yet until we have refined and tested this metric.

reactable.Column(
id="run_count",
name="Runs",
Expand Down Expand Up @@ -511,7 +518,7 @@ def _ski_area_metric_style(ci: reactable.CellInfo) -> dict[str, Any] | None:
cell=reactable.JS("cellRose"),
# max_width=45,
class_="border-left",
),
)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the pre-commit formatter would not allow this (and will fix it once enabled)

],
column_groups=[
reactable.ColGroup(
Expand All @@ -521,6 +528,7 @@ def _ski_area_metric_style(ci: reactable.CellInfo) -> dict[str, Any] | None:
name="Location",
columns=[
"latitude",
"solar_intensity",
"country_emoji",
"country",
"region",
Expand Down
5 changes: 5 additions & 0 deletions openskistats/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ class SkiAreaModel(Model): # type: ignore [misc]
hemisphere: Literal["north", "south"] | None = Field(
description="Hemisphere of the ski area.",
)
solar_intensity: float | None = Field(
description="Solar intensity of the ski resort calculated by the declination at that latitude at the solstice",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specify which solstice.

What does solar intensity mean? Is it insolation scaled in some way? E.g. how does one interpret solar_intensity of 60%?

ge=0.0,
le=1.0,
)
min_elevation: Annotated[
float | None,
Field(
Expand Down
Loading