towerops/lib/towerops/capacity.ex
2026-03-26 10:23:26 -05:00

221 lines
7.4 KiB
Elixir

defmodule Towerops.Capacity do
@moduledoc """
Context for backhaul capacity analysis.
Provides throughput calculation from interface stats, utilization percentages,
and site/organization-level capacity summaries.
"""
import Ecto.Query
alias Towerops.Devices.Device
alias Towerops.Repo
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
@doc """
Calculates average throughput for an interface over a time window.
Returns `%{in_bps: float, out_bps: float, max_bps: float}`.
"""
def calculate_throughput(interface_id, since, until \\ DateTime.utc_now()) do
stats =
InterfaceStat
|> where([s], s.interface_id == ^interface_id)
|> where([s], s.checked_at >= ^since and s.checked_at <= ^until)
|> order_by([s], asc: s.checked_at)
|> Repo.all()
calculate_throughput_from_stats(stats)
end
@doc """
Calculates utilization for an interface with configured capacity.
Returns `%{utilization_pct: float, in_pct: float, out_pct: float, throughput: map}` or nil.
"""
def get_utilization(%Interface{configured_capacity_bps: nil}), do: nil
def get_utilization(%Interface{configured_capacity_bps: cap}) when cap <= 0, do: nil
def get_utilization(%Interface{id: id, configured_capacity_bps: cap}) do
since = DateTime.add(DateTime.utc_now(), -900, :second)
throughput = calculate_throughput(id, since)
%{
utilization_pct: throughput.max_bps / cap * 100,
in_pct: throughput.in_bps / cap * 100,
out_pct: throughput.out_bps / cap * 100,
throughput: throughput
}
end
@doc """
Returns a capacity summary for all interfaces at a site.
"""
def get_site_capacity_summary(site_id) do
interfaces = list_capacity_interfaces_for_site(site_id)
interface_summaries =
Enum.map(interfaces, fn {iface, device_name} ->
utilization = get_utilization(iface)
%{
id: iface.id,
name: iface.if_name,
device_name: device_name,
capacity_bps: iface.configured_capacity_bps,
capacity_source: iface.capacity_source,
throughput: if(utilization, do: utilization.throughput, else: zero_throughput()),
utilization_pct: if(utilization, do: utilization.utilization_pct, else: 0.0)
}
end)
total_capacity = Enum.sum(Enum.map(interface_summaries, & &1.capacity_bps))
total_throughput = Enum.sum(Enum.map(interface_summaries, & &1.throughput.max_bps))
utilization_pct =
if total_capacity > 0, do: total_throughput / total_capacity * 100, else: 0.0
%{
total_capacity_bps: total_capacity,
total_throughput_bps: total_throughput,
utilization_pct: utilization_pct,
interfaces: interface_summaries,
status: utilization_status(utilization_pct)
}
end
@doc """
Returns per-site capacity summaries for an organization.
Only includes sites that have at least one interface with capacity configured.
"""
def get_organization_capacity_summary(organization_id) do
site_ids =
Interface
|> join(:inner, [i], sd in Towerops.Snmp.Device, on: i.snmp_device_id == sd.id)
|> join(:inner, [_i, sd], d in Device, on: sd.device_id == d.id)
|> where([_i, _sd, d], d.organization_id == ^organization_id)
|> where([i], not is_nil(i.configured_capacity_bps))
|> select([_i, _sd, d], d.site_id)
|> distinct(true)
|> Repo.all()
|> Enum.reject(&is_nil/1)
sites =
Towerops.Sites.Site
|> where([s], s.id in ^site_ids)
|> Repo.all()
Enum.map(sites, fn site ->
summary = get_site_capacity_summary(site.id)
%{
site_id: site.id,
site_name: site.name,
total_capacity_bps: summary.total_capacity_bps,
total_throughput_bps: summary.total_throughput_bps,
utilization_pct: summary.utilization_pct,
status: summary.status,
interfaces: summary.interfaces,
headroom_bps: max(0, summary.total_capacity_bps - summary.total_throughput_bps)
}
end)
end
defp list_capacity_interfaces_for_site(site_id) do
Interface
|> join(:inner, [i], sd in Towerops.Snmp.Device, on: i.snmp_device_id == sd.id)
|> join(:inner, [_i, sd], d in Device, on: sd.device_id == d.id)
|> where([_i, _sd, d], d.site_id == ^site_id)
|> where([i], not is_nil(i.configured_capacity_bps))
|> select([i, _sd, d], {i, d.name})
|> Repo.all()
end
defp calculate_throughput_from_stats(stats) when length(stats) < 2 do
zero_throughput()
end
defp calculate_throughput_from_stats(stats) do
pairs =
stats
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [s1, s2] ->
time_diff = s2.checked_at |> DateTime.diff(s1.checked_at, :second) |> max(1)
in_bps = calculate_bps(s1.if_in_octets, s2.if_in_octets, time_diff)
out_bps = calculate_bps(s1.if_out_octets, s2.if_out_octets, time_diff)
{in_bps, out_bps}
end)
# Filter out zero-bps pairs (from clamped negative deltas / anomalies)
valid_pairs = Enum.reject(pairs, fn {in_bps, out_bps} -> in_bps == 0.0 and out_bps == 0.0 end)
if Enum.empty?(valid_pairs) do
zero_throughput()
else
in_values = Enum.map(valid_pairs, &elem(&1, 0))
out_values = Enum.map(valid_pairs, &elem(&1, 1))
avg_in = Enum.sum(in_values) / length(in_values)
avg_out = Enum.sum(out_values) / length(out_values)
# Peak values for capacity planning (95th percentile approximation)
peak_in = percentile(in_values, 95)
peak_out = percentile(out_values, 95)
%{
in_bps: Float.round(avg_in, 2),
out_bps: Float.round(avg_out, 2),
max_bps: Float.round(max(avg_in, avg_out), 2),
peak_in_bps: Float.round(peak_in, 2),
peak_out_bps: Float.round(peak_out, 2),
peak_bps: Float.round(max(peak_in, peak_out), 2)
}
end
end
# 400 Gbps — values above this are counter anomalies, not real traffic
@max_reasonable_bps 400_000_000_000
@doc """
Calculates bits per second from two consecutive octet counter readings.
Negative deltas are clamped to zero (matching LibreNMS behavior). A 32-bit
counter wrap is indistinguishable from an HC counter reset, so we discard
the ambiguous data point rather than guess at a correction. Values exceeding
400 Gbps are also discarded as counter anomalies.
"""
def calculate_bps(nil, _, _), do: 0.0
def calculate_bps(_, nil, _), do: 0.0
def calculate_bps(prev_octets, curr_octets, time_diff) do
delta = curr_octets - prev_octets
# Clamp negative deltas to zero — counter wraps and HC resets both produce
# negative deltas and are indistinguishable, so we discard the data point.
bps = max(0, delta) * 8 / time_diff
# Discard values above 400 Gbps as counter anomalies
if bps > @max_reasonable_bps, do: 0.0, else: bps
end
defp zero_throughput do
%{in_bps: 0.0, out_bps: 0.0, max_bps: 0.0, peak_in_bps: 0.0, peak_out_bps: 0.0, peak_bps: 0.0}
end
@doc """
Calculates the nth percentile of a list of numbers.
Uses nearest-rank method.
"""
def percentile([], _n), do: 0.0
def percentile(values, n) when n >= 0 and n <= 100 do
sorted = Enum.sort(values)
rank = (n / 100 * length(sorted)) |> Float.ceil() |> trunc() |> max(1)
Enum.at(sorted, rank - 1)
end
defp utilization_status(pct) when pct >= 90, do: :red
defp utilization_status(pct) when pct >= 70, do: :yellow
defp utilization_status(_), do: :green
end