Skip to main content

src/sonnenstand.erl

-module(sonnenstand).

-moduledoc """
Solar position calculations.

Given a date/time and a location (latitude/longitude in degrees), this module
computes the position of the sun as **elevation** (height above the horizon)
and **azimuth** (compass direction).

The formulas follow the algorithm described at
<http://www.geoastro.de/SME/tk/index.htm>. They are approximations intended
for everyday use (shading, solar-panel orientation, photography), not for
high-precision astronomy.

## Conventions

- Angles are in **degrees**.
- Latitude is positive **north**, longitude positive **east**.
- Times are interpreted as **UTC**.
- Azimuth is measured **clockwise from north**: `0.0` = north, `90.0` = east,
  `180.0` = south, `270.0` = west.

## Example

```erlang
1> DateTime = {{2024, 6, 21}, {12, 0, 0}}.
2> sonnenstand:elevation(DateTime, 52.5, 13.4).
60.91...
3> sonnenstand:azimuth(DateTime, 52.5, 13.4).
176.13...
```
""".

-export([day_of_year/1,
         declination/1,
         equation_of_time/1,
         hour_angle/2,
         elevation/3,
         azimuth/3]).

-type latitude() :: number().
%% Geographic latitude in degrees, positive north (range -90.0..90.0).

-type longitude() :: number().
%% Geographic longitude in degrees, positive east (range -180.0..180.0).

-type degrees() :: float().
%% An angle expressed in degrees.

-export_type([latitude/0, longitude/0, degrees/0]).

%%====================================================================
%% Public API
%%====================================================================

-doc """
Returns the ordinal day of the year for `Date` (1 for 1 January).

```erlang
1> sonnenstand:day_of_year({2024, 1, 1}).
1
2> sonnenstand:day_of_year({2024, 12, 31}).
366
```
""".
-spec day_of_year(calendar:date()) -> pos_integer().
day_of_year({Year, _Month, _Day} = Date) ->
    FirstOfYear = {Year, 1, 1},
    Midnight = {0, 0, 0},
    {Days, _} = calendar:time_difference({FirstOfYear, Midnight},
                                         {Date, Midnight}),
    Days + 1.

-doc """
Returns the sun's declination in degrees for `Date`.

The declination is the angle between the sun's rays and the equatorial plane,
ranging from about `-23.45` at the December solstice to `+23.45` at the June
solstice.
""".
-spec declination(calendar:date()) -> degrees().
declination(Date) ->
    -23.45 * math:cos(deg2rad(360 * (day_of_year(Date) + 10) / 365)).

-doc """
Returns the equation of time in **minutes** for `Date`.

This is the difference between apparent solar time and mean solar time, caused
by the eccentricity of Earth's orbit and the tilt of its axis.
""".
-spec equation_of_time(calendar:date()) -> float().
equation_of_time(Date) ->
    N = day_of_year(Date),
    60 * (-0.171 * math:sin(0.0337 * N + 0.465)
          - 0.1299 * math:sin(0.01787 * N - 0.168)).

-doc """
Returns the sun's hour angle in degrees for `DateTime` (UTC) at `Longitude`.

The hour angle is negative before solar noon (morning), zero at solar noon, and
positive afterwards (afternoon).
""".
-spec hour_angle(calendar:datetime(), longitude()) -> degrees().
hour_angle({Date, {Hour, Minute, Second}}, Longitude) ->
    Clock = Hour + Minute / 60 + Second / 3600,
    15 * (Clock + Longitude / 15 - 12 + equation_of_time(Date) / 60).

-doc """
Returns the sun's elevation in degrees for `DateTime` at the given location.

The elevation (or altitude) is the angle of the sun above the horizon:
positive when the sun is up, negative when it is below the horizon.
""".
-spec elevation(calendar:datetime(), latitude(), longitude()) -> degrees().
elevation(DateTime, Latitude, Longitude) ->
    rad2deg(math:asin(sin_elevation(DateTime, Latitude, Longitude))).

-doc """
Returns the sun's azimuth in degrees for `DateTime` at the given location.

The azimuth is measured clockwise from north (`0.0` = north, `90.0` = east,
`180.0` = south, `270.0` = west). Values before solar noon fall in the eastern
half (`0.0`..`180.0`); values afterwards fall in the western half
(`180.0`..`360.0`).
""".
-spec azimuth(calendar:datetime(), latitude(), longitude()) -> degrees().
azimuth(DateTime, Latitude, Longitude) ->
    Base = rad2deg(math:acos(cos_azimuth(DateTime, Latitude, Longitude))),
    %% acos only yields 0..180 degrees, which covers the eastern (morning) sky.
    %% In the afternoon (positive hour angle) the sun is in the west, so the
    %% azimuth is mirrored across the north-south meridian.
    case hour_angle(DateTime, Longitude) > 0 of
        true -> 360.0 - Base;
        false -> Base
    end.

%%====================================================================
%% Internal functions
%%====================================================================

%% sin(elevation): the third coordinate of the sun's unit vector.
-spec sin_elevation(calendar:datetime(), latitude(), longitude()) -> float().
sin_elevation({Date, _Time} = DateTime, Latitude, Longitude) ->
    Decl = declination(Date),
    math:sin(deg2rad(Latitude)) * math:sin(deg2rad(Decl))
        + math:cos(deg2rad(Latitude)) * math:cos(deg2rad(Decl))
              * math:cos(deg2rad(hour_angle(DateTime, Longitude))).

%% cos(azimuth), clamped to [-1.0, 1.0] to keep acos well-defined against
%% floating-point rounding near the horizon.
-spec cos_azimuth(calendar:datetime(), latitude(), longitude()) -> float().
cos_azimuth({Date, _Time} = DateTime, Latitude, Longitude) ->
    Decl = declination(Date),
    Elev = elevation(DateTime, Latitude, Longitude),
    Y = -(math:sin(deg2rad(Latitude)) * math:sin(deg2rad(Elev))
          - math:sin(deg2rad(Decl)))
        / (math:cos(deg2rad(Latitude))
           * math:sin(math:acos(math:sin(deg2rad(Elev))))),
    max(-1.0, min(1.0, Y)).

-spec deg2rad(number()) -> float().
deg2rad(Degrees) ->
    Degrees * math:pi() / 180.

-spec rad2deg(number()) -> float().
rad2deg(Radians) ->
    Radians * 180 / math:pi().