towerops/lib/towerops/weather.ex
Graham McIntire 3ca0834ef0 tests: raise coverage to 70% via helper promotion + new unit/property tests
Promoted pure presentation and utility helpers from `defp` to `def @doc false`
across ~20 LiveViews, Oban workers, and sync modules so they're reachable from
unit tests. Refactored several `cond` blocks into idiomatic function heads with
guards. Added ~250 new test cases in new files under test/towerops and
test/towerops_web, including DB-backed tests for CnMaestro.Sync and
AlertNotificationWorker, and removed dead LiveView tab components and
CapacityLive (no callers anywhere in lib/test).

Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party
code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types,
Phoenix HTML modules, Inspect protocol impls) from coverage calculations —
these are not our project code.

Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
2026-04-24 09:49:06 -05:00

239 lines
7.4 KiB
Elixir

defmodule Towerops.Weather do
@moduledoc """
Weather context for site-correlated weather data.
Provides current conditions, historical observations, and weather alerts
tied to tower site locations. Used for outage correlation and operational
awareness.
"""
import Ecto.Query
alias Towerops.Repo
alias Towerops.Weather.Alert, as: WeatherAlert
alias Towerops.Weather.Client
alias Towerops.Weather.Observation
require Logger
# ── Observations ─────────────────────────────────────────────────────
@doc """
Records a weather observation for a site.
"""
def create_observation(attrs) do
%Observation{}
|> Observation.changeset(attrs)
|> Repo.insert()
end
@doc """
Gets the latest weather observation for a site.
"""
def latest_observation(site_id) do
Observation
|> where(site_id: ^site_id)
|> order_by(desc: :observed_at)
|> limit(1)
|> Repo.one()
end
@doc """
Gets the latest weather observation for each site in an organization.
Returns a map of site_id => observation.
"""
def latest_observations_for_org(organization_id) do
# Subquery: latest observed_at per site (IDs are UUIDs, so max(id) is invalid).
latest_timestamps =
from(o in Observation,
join: s in assoc(o, :site),
where: s.organization_id == ^organization_id,
group_by: o.site_id,
select: %{site_id: o.site_id, max_observed_at: max(o.observed_at)}
)
from(o in Observation,
join: l in subquery(latest_timestamps),
on: o.site_id == l.site_id and o.observed_at == l.max_observed_at
)
|> Repo.all()
|> Map.new(fn obs -> {obs.site_id, obs} end)
end
@doc """
Gets weather observations for a site within a time range.
Useful for correlating weather with outage events.
"""
def observations_for_site(site_id, opts \\ []) do
after_time = opts[:after] || DateTime.add(DateTime.utc_now(), -24, :hour)
limit = opts[:limit] || 100
Observation
|> where(site_id: ^site_id)
|> where([o], o.observed_at >= ^after_time)
|> order_by(desc: :observed_at)
|> limit(^limit)
|> Repo.all()
end
@doc """
Cleans up old weather observations. Keeps the last `keep_days` days.
"""
def cleanup_old_observations(keep_days \\ 30) do
cutoff = DateTime.add(DateTime.utc_now(), -keep_days, :day)
{count, _} = Repo.delete_all(from(o in Observation, where: o.observed_at < ^cutoff))
Logger.info("Cleaned up #{count} old weather observations")
{:ok, count}
end
# ── Weather Alerts ───────────────────────────────────────────────────
@doc """
Creates or updates a weather alert for a site.
"""
def upsert_alert(attrs) do
%WeatherAlert{}
|> WeatherAlert.changeset(attrs)
|> Repo.insert()
end
@doc """
Gets active weather alerts for a site (not yet expired).
"""
def active_alerts_for_site(site_id) do
now = DateTime.utc_now()
WeatherAlert
|> where(site_id: ^site_id)
|> where([a], is_nil(a.ends_at) or a.ends_at > ^now)
|> order_by(desc: :inserted_at)
|> Repo.all()
end
@doc """
Gets active weather alerts for all sites in an organization.
"""
def active_alerts_for_org(organization_id) do
now = DateTime.utc_now()
Repo.all(
from(a in WeatherAlert,
join: s in assoc(a, :site),
where: s.organization_id == ^organization_id,
where: is_nil(a.ends_at) or a.ends_at > ^now,
order_by: [desc: a.inserted_at],
preload: [:site]
)
)
end
# ── Fetching ─────────────────────────────────────────────────────────
@doc """
Fetches and stores current weather for a site.
Requires the site to have latitude and longitude set.
"""
def fetch_and_store(%{latitude: lat, longitude: lon} = site) when not is_nil(lat) and not is_nil(lon) do
case Client.get_current_weather(lat, lon) do
{:ok, data} ->
site.id
|> Observation.from_owm(data)
|> create_observation()
{:error, reason} ->
Logger.warning("Failed to fetch weather for site #{site.name}: #{inspect(reason)}")
{:error, reason}
end
end
def fetch_and_store(_site), do: {:error, :no_coordinates}
# ── Correlation Helpers ──────────────────────────────────────────────
@doc """
Checks if there was severe weather at a site around a given time.
Returns observations with high wind, heavy rain, or storms.
Useful for determining if an outage was likely weather-related.
"""
def severe_weather_near?(site_id, time, window_minutes \\ 60) do
start_time = DateTime.add(time, -window_minutes, :minute)
end_time = DateTime.add(time, window_minutes, :minute)
observations =
Observation
|> where(site_id: ^site_id)
|> where([o], o.observed_at >= ^start_time and o.observed_at <= ^end_time)
|> Repo.all()
Enum.any?(observations, &severe?/1)
end
@doc """
Returns a weather summary for a site at a point in time.
Finds the closest observation within 30 minutes.
"""
def weather_at(site_id, time) do
start_time = DateTime.add(time, -30, :minute)
end_time = DateTime.add(time, 30, :minute)
Observation
|> where(site_id: ^site_id)
|> where([o], o.observed_at >= ^start_time and o.observed_at <= ^end_time)
|> order_by([o], fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", o.observed_at, ^time))
|> limit(1)
|> Repo.one()
end
@doc """
Determines if an observation represents severe weather conditions.
Thresholds relevant to WISP operations:
- Wind > 15 m/s (33 mph) — affects radio alignment
- Wind gusts > 20 m/s (45 mph) — risk of hardware damage
- Rain > 10mm/h — signal attenuation on wireless links
- Thunderstorm conditions
- Snow > 5mm/h — ice loading on equipment
"""
def severe?(%Observation{} = obs) do
high_wind?(obs) or strong_gust?(obs) or heavy_rain?(obs) or
heavy_snow?(obs) or thunderstorm?(obs)
end
def severe?(_), do: false
@doc """
Returns a severity level for weather conditions.
:clear, :mild, :moderate, :severe
"""
def severity(%Observation{} = obs) do
classify_severity({severe_severity?(obs), moderate_severity?(obs), mild_severity?(obs)})
end
def severity(_), do: :clear
defp classify_severity({true, _, _}), do: :severe
defp classify_severity({_, true, _}), do: :moderate
defp classify_severity({_, _, true}), do: :mild
defp classify_severity(_), do: :clear
defp high_wind?(obs), do: (obs.wind_speed_ms || 0) > 15
defp strong_gust?(obs), do: (obs.wind_gust_ms || 0) > 20
defp heavy_rain?(obs), do: (obs.rain_1h_mm || 0) > 10
defp heavy_snow?(obs), do: (obs.snow_1h_mm || 0) > 5
defp thunderstorm?(obs), do: obs.condition in ["Thunderstorm"]
defp severe_severity?(obs) do
(obs.wind_gust_ms || 0) > 25 or thunderstorm?(obs) or (obs.rain_1h_mm || 0) > 20
end
defp moderate_severity?(obs) do
high_wind?(obs) or strong_gust?(obs) or heavy_rain?(obs) or heavy_snow?(obs)
end
defp mild_severity?(obs) do
obs.condition in ["Rain", "Drizzle", "Snow"] or (obs.clouds_pct || 0) > 80
end
end