phase 0-2 complete
This commit is contained in:
51
internal/radar/fetch.go
Normal file
51
internal/radar/fetch.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package radar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// nwsRadarBase is the NWS public product distribution server.
|
||||
// No credentials required; sn.last always holds the most recent scan.
|
||||
const nwsRadarBase = "https://tgftp.nws.noaa.gov/SL.us008001/DF.of/DC.radar"
|
||||
|
||||
// Fetcher downloads NEXRAD Level 3 products from the NWS public server.
|
||||
type Fetcher struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewFetcher(_ context.Context) (*Fetcher, error) {
|
||||
return &Fetcher{client: &http.Client{Timeout: 30 * time.Second}}, nil
|
||||
}
|
||||
|
||||
// FetchLatest downloads the latest Level 3 base reflectivity (0.5°) for site.
|
||||
func (f *Fetcher) FetchLatest(ctx context.Context, site string) ([]byte, error) {
|
||||
return f.fetchDS(ctx, site, "p94r0")
|
||||
}
|
||||
|
||||
// FetchVelocity downloads the latest Level 3 base velocity (0.5°) for site.
|
||||
func (f *Fetcher) FetchVelocity(ctx context.Context, site string) ([]byte, error) {
|
||||
return f.fetchDS(ctx, site, "p99r0")
|
||||
}
|
||||
|
||||
func (f *Fetcher) fetchDS(ctx context.Context, site, ds string) ([]byte, error) {
|
||||
url := fmt.Sprintf("%s/DS.%s/SI.%s/sn.last", nwsRadarBase, ds, strings.ToLower(site))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NWS fetch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("NWS returned %s for %s", resp.Status, url)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
230
internal/radar/parse.go
Normal file
230
internal/radar/parse.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package radar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/bzip2"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RadarProduct holds parsed data from one Level 3 scan.
|
||||
type RadarProduct struct {
|
||||
Site string
|
||||
Product string // "reflectivity" or "velocity"
|
||||
SiteLat float64
|
||||
SiteLon float64
|
||||
Elevation float64
|
||||
Time time.Time
|
||||
Radials []Radial
|
||||
}
|
||||
|
||||
// Radial is one spoke of radar data.
|
||||
type Radial struct {
|
||||
Azimuth float64
|
||||
DeltaAz float64
|
||||
RangeKm float64
|
||||
BinSizeKm float64
|
||||
Values []float32 // dBZ per bin; NaN = no data
|
||||
}
|
||||
|
||||
// GateLL returns the lat/lon center of range bin i in this radial.
|
||||
func (r *Radial) GateLL(siteLat, siteLon float64, i int) (lat, lon float64) {
|
||||
distKm := r.RangeKm + float64(i)*r.BinSizeKm + r.BinSizeKm/2
|
||||
return rangeBearing(siteLat, siteLon, distKm, r.Azimuth)
|
||||
}
|
||||
|
||||
// Parse decodes a NEXRAD Level 3 base reflectivity product (N0Q / product 94).
|
||||
func Parse(site string, data []byte) (*RadarProduct, error) {
|
||||
return parseProduct(site, "reflectivity", data, func(v byte) float32 {
|
||||
if v <= 1 {
|
||||
return float32(math.NaN())
|
||||
}
|
||||
return float32(v-2)*0.5 - 32.0 // dBZ
|
||||
})
|
||||
}
|
||||
|
||||
// ParseVelocity decodes a NEXRAD Level 3 base velocity product (N0U / product 99).
|
||||
func ParseVelocity(site string, data []byte) (*RadarProduct, error) {
|
||||
return parseProduct(site, "velocity", data, func(v byte) float32 {
|
||||
if v <= 1 {
|
||||
return float32(math.NaN())
|
||||
}
|
||||
return float32(v-2)*0.5 - 63.5 // m/s
|
||||
})
|
||||
}
|
||||
|
||||
func parseProduct(site, productType string, data []byte, decode func(byte) float32) (*RadarProduct, error) {
|
||||
data = stripWMOHeader(data)
|
||||
if len(data) < 120 {
|
||||
return nil, fmt.Errorf("file too short after header strip (%d bytes)", len(data))
|
||||
}
|
||||
|
||||
// --- Message Header Block (18 bytes) — skip ---
|
||||
|
||||
// --- Product Description Block (starts at byte 18) ---
|
||||
pdb := data[18:]
|
||||
if len(pdb) < 102 {
|
||||
return nil, fmt.Errorf("product description block truncated")
|
||||
}
|
||||
|
||||
siteLat := float64(int32(binary.BigEndian.Uint32(pdb[2:]))) / 1000.0
|
||||
siteLon := float64(int32(binary.BigEndian.Uint32(pdb[6:]))) / 1000.0
|
||||
volDate := int16(binary.BigEndian.Uint16(pdb[22:]))
|
||||
volTimeSec := int32(binary.BigEndian.Uint32(pdb[24:])) // seconds since midnight
|
||||
scanTime := julianToTime(volDate, volTimeSec)
|
||||
|
||||
// data[120:] is the Symbology Block, bzip2-compressed for digital products (N0Q).
|
||||
// 18 bytes Message Header + 102 bytes PDB = 120 bytes prefix.
|
||||
symData := data[120:]
|
||||
if len(symData) >= 3 && symData[0] == 'B' && symData[1] == 'Z' && symData[2] == 'h' {
|
||||
r := bzip2.NewReader(bytes.NewReader(symData))
|
||||
decompressed, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decompress symbology: %w", err)
|
||||
}
|
||||
symData = decompressed
|
||||
}
|
||||
|
||||
// Find the Symbology Block by scanning for its signature:
|
||||
// block divider (FF FF) + block ID (00 01).
|
||||
symPos := -1
|
||||
for i := 0; i+3 < len(symData); i++ {
|
||||
if symData[i] == 0xFF && symData[i+1] == 0xFF && symData[i+2] == 0x00 && symData[i+3] == 0x01 {
|
||||
symPos = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if symPos < 0 {
|
||||
return nil, fmt.Errorf("symbology block not found in decompressed data")
|
||||
}
|
||||
|
||||
// --- Symbology Block header (10 bytes) ---
|
||||
sym := symData[symPos:]
|
||||
// [0-1]: block divider (-1)
|
||||
// [2-3]: block ID (1)
|
||||
// [4-7]: block length (bytes)
|
||||
// [8-9]: number of layers
|
||||
|
||||
// --- Layer header (6 bytes) ---
|
||||
if 10+6 > len(sym) {
|
||||
return nil, fmt.Errorf("symbology block too short for layer")
|
||||
}
|
||||
layer := sym[10:]
|
||||
// [0-1]: layer divider (-1)
|
||||
// [2-5]: layer length (bytes)
|
||||
|
||||
pkt := layer[6:]
|
||||
packetCode := int16(binary.BigEndian.Uint16(pkt[0:]))
|
||||
if packetCode != 16 {
|
||||
return nil, fmt.Errorf("unsupported packet code %d (want 16)", packetCode)
|
||||
}
|
||||
|
||||
// --- Packet Code 16 header (14 bytes) ---
|
||||
// [0-1]: code (16)
|
||||
// [2-3]: first range bin index
|
||||
// [4-5]: number of range bins
|
||||
// [6-7]: I center
|
||||
// [8-9]: J center
|
||||
// [10-11]: scale factor (thousandths of km per pixel)
|
||||
// [12-13]: number of radials
|
||||
numBins := int(int16(binary.BigEndian.Uint16(pkt[4:])))
|
||||
scaleFactor := int(int16(binary.BigEndian.Uint16(pkt[10:])))
|
||||
numRadials := int(int16(binary.BigEndian.Uint16(pkt[12:])))
|
||||
|
||||
binSizeKm := 1.0
|
||||
if scaleFactor > 0 {
|
||||
binSizeKm = float64(scaleFactor) / 1000.0
|
||||
}
|
||||
|
||||
pos := 14 // offset into pkt
|
||||
radials := make([]Radial, 0, numRadials)
|
||||
for i := 0; i < numRadials; i++ {
|
||||
if pos+6 > len(pkt) {
|
||||
break
|
||||
}
|
||||
runBytes := int(int16(binary.BigEndian.Uint16(pkt[pos:])))
|
||||
startAngle := float64(int16(binary.BigEndian.Uint16(pkt[pos+2:]))) / 10.0
|
||||
deltaAngle := float64(int16(binary.BigEndian.Uint16(pkt[pos+4:]))) / 10.0
|
||||
pos += 6
|
||||
|
||||
if pos+runBytes > len(pkt) {
|
||||
break
|
||||
}
|
||||
raw := pkt[pos : pos+runBytes]
|
||||
pos += runBytes
|
||||
// Pad to even-byte boundary.
|
||||
if runBytes%2 != 0 {
|
||||
pos++
|
||||
}
|
||||
|
||||
values := make([]float32, numBins)
|
||||
for j := 0; j < numBins && j < len(raw); j++ {
|
||||
values[j] = decode(raw[j])
|
||||
}
|
||||
|
||||
radials = append(radials, Radial{
|
||||
Azimuth: startAngle,
|
||||
DeltaAz: deltaAngle,
|
||||
RangeKm: 0.0,
|
||||
BinSizeKm: binSizeKm,
|
||||
Values: values,
|
||||
})
|
||||
}
|
||||
|
||||
if len(radials) == 0 {
|
||||
return nil, fmt.Errorf("no radials parsed")
|
||||
}
|
||||
|
||||
return &RadarProduct{
|
||||
Site: site,
|
||||
Product: productType,
|
||||
SiteLat: siteLat,
|
||||
SiteLon: siteLon,
|
||||
Elevation: 0.5,
|
||||
Time: scanTime,
|
||||
Radials: radials,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// stripWMOHeader removes the variable-length WMO/AFOS text header that NWS
|
||||
// prepends to distributed products. The binary NEXRAD data begins after the
|
||||
// final \r\r\n sequence in the first 100 bytes.
|
||||
func stripWMOHeader(data []byte) []byte {
|
||||
limit := 100
|
||||
if len(data) < limit {
|
||||
limit = len(data)
|
||||
}
|
||||
last := 0
|
||||
for i := 0; i+2 < limit; i++ {
|
||||
if data[i] == '\r' && data[i+1] == '\r' && data[i+2] == '\n' {
|
||||
last = i + 3
|
||||
}
|
||||
}
|
||||
return data[last:]
|
||||
}
|
||||
|
||||
func julianToTime(julianDate int16, secs int32) time.Time {
|
||||
base := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
d := base.AddDate(0, 0, int(julianDate)-1)
|
||||
return d.Add(time.Duration(secs) * time.Second)
|
||||
}
|
||||
|
||||
func rangeBearing(lat0, lon0, distKm, bearing float64) (lat, lon float64) {
|
||||
const earthR = 6371.0
|
||||
lat0r := lat0 * math.Pi / 180
|
||||
lon0r := lon0 * math.Pi / 180
|
||||
br := bearing * math.Pi / 180
|
||||
dr := distKm / earthR
|
||||
|
||||
latr := math.Asin(math.Sin(lat0r)*math.Cos(dr) +
|
||||
math.Cos(lat0r)*math.Sin(dr)*math.Cos(br))
|
||||
lonr := lon0r + math.Atan2(
|
||||
math.Sin(br)*math.Sin(dr)*math.Cos(lat0r),
|
||||
math.Cos(dr)-math.Sin(lat0r)*math.Sin(latr),
|
||||
)
|
||||
return latr * 180 / math.Pi, lonr * 180 / math.Pi
|
||||
}
|
||||
|
||||
390
internal/server/handlers.go
Normal file
390
internal/server/handlers.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/blakeridgway/heloha/internal/radar"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const tileSize = 256
|
||||
|
||||
// TileHandler serves /api/v1/tile/{site}/{z}/{x}/{y}.png (single-site, reflectivity).
|
||||
func TileHandler(store *RadarStore, cache *TileCache) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
site := strings.ToLower(chi.URLParam(r, "site"))
|
||||
z, err1 := strconv.Atoi(chi.URLParam(r, "z"))
|
||||
x, err2 := strconv.Atoi(chi.URLParam(r, "x"))
|
||||
y, err3 := strconv.Atoi(chi.URLParam(r, "y"))
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
http.Error(w, "invalid tile coords", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
key := site + "/refl/latest/" + strconv.Itoa(z) + "/" + strconv.Itoa(x) + "/" + strconv.Itoa(y)
|
||||
if data, ok := cache.Get(key); ok {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Write(data)
|
||||
return
|
||||
}
|
||||
|
||||
product := store.GetLatest(site, "reflectivity")
|
||||
if product == nil {
|
||||
http.Error(w, "no radar data", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
img := renderTile(product, z, x, y, dbzColor)
|
||||
writeTile(w, cache, key, img)
|
||||
}
|
||||
}
|
||||
|
||||
// CompositeTileHandler serves:
|
||||
//
|
||||
// /api/v1/tile/composite/{product}/{frame}/{z}/{x}/{y}.png
|
||||
// /api/v1/tile/composite/{z}/{x}/{y}.png (legacy: product=reflectivity, frame=latest)
|
||||
func CompositeTileHandler(store *RadarStore, cache *TileCache) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
product := chi.URLParam(r, "product")
|
||||
if product == "" {
|
||||
product = "reflectivity"
|
||||
}
|
||||
frameStr := chi.URLParam(r, "frame")
|
||||
|
||||
z, err1 := strconv.Atoi(chi.URLParam(r, "z"))
|
||||
x, err2 := strconv.Atoi(chi.URLParam(r, "x"))
|
||||
y, err3 := strconv.Atoi(chi.URLParam(r, "y"))
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
http.Error(w, "invalid tile coords", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("composite/%s/%s/%d/%d/%d", product, frameStr, z, x, y)
|
||||
if data, ok := cache.Get(key); ok {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Write(data)
|
||||
return
|
||||
}
|
||||
|
||||
var products []*radar.RadarProduct
|
||||
if frameStr == "" || frameStr == "latest" {
|
||||
products = store.GetAllLatest(product)
|
||||
} else {
|
||||
frameIdx, err := strconv.Atoi(frameStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid frame", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
products = store.GetAllAtFrame(product, frameIdx)
|
||||
}
|
||||
|
||||
if len(products) == 0 {
|
||||
http.Error(w, "no radar data", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
colorFn := dbzColor
|
||||
if product == "velocity" {
|
||||
colorFn = velColor
|
||||
}
|
||||
|
||||
img := renderCompositeTile(products, z, x, y, colorFn)
|
||||
writeTile(w, cache, key, img)
|
||||
}
|
||||
}
|
||||
|
||||
// FramesHandler serves /api/v1/frames?product=reflectivity|velocity
|
||||
func FramesHandler(store *RadarStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
product := r.URL.Query().Get("product")
|
||||
if product == "" {
|
||||
product = "reflectivity"
|
||||
}
|
||||
count, frames := store.FrameMeta(product)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"product": product,
|
||||
"frame_count": count,
|
||||
"frames": frames,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// speckleFilter removes pixels with fewer than minNeighbors non-transparent
|
||||
// 8-connected neighbors. Running multiple passes progressively erodes small
|
||||
// noise clusters while large coherent precipitation areas survive intact.
|
||||
func speckleFilter(src *image.RGBA) *image.RGBA {
|
||||
const (
|
||||
passes = 4
|
||||
minNeighbors = 4
|
||||
)
|
||||
img := src
|
||||
for pass := 0; pass < passes; pass++ {
|
||||
dst := image.NewRGBA(img.Bounds())
|
||||
for y := 0; y < tileSize; y++ {
|
||||
for x := 0; x < tileSize; x++ {
|
||||
c := img.RGBAAt(x, y)
|
||||
if c.A == 0 {
|
||||
continue
|
||||
}
|
||||
n := 0
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue
|
||||
}
|
||||
nx, ny := x+dx, y+dy
|
||||
if nx >= 0 && nx < tileSize && ny >= 0 && ny < tileSize &&
|
||||
img.RGBAAt(nx, ny).A > 0 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
if n >= minNeighbors {
|
||||
dst.SetRGBA(x, y, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
img = dst
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func writeTile(w http.ResponseWriter, cache *TileCache, key string, img *image.RGBA) {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
http.Error(w, "encode error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := buf.Bytes()
|
||||
cache.Set(key, data)
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// renderCompositeTile builds a mosaic tile: each pixel gets a value from the
|
||||
// nearest radar site within 230 km, colored by colorFn.
|
||||
func renderCompositeTile(products []*radar.RadarProduct, z, x, y int, colorFn func(float32) color.RGBA) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, tileSize, tileSize))
|
||||
|
||||
north, west := tileToLatLon(z, x, y)
|
||||
south, east := tileToLatLon(z, x+1, y+1)
|
||||
latSpan := north - south
|
||||
lonSpan := east - west
|
||||
|
||||
const maxRangeKm = 230.0
|
||||
|
||||
for py := 0; py < tileSize; py++ {
|
||||
lat := north - float64(py)/tileSize*latSpan
|
||||
for px := 0; px < tileSize; px++ {
|
||||
lon := west + float64(px)/tileSize*lonSpan
|
||||
|
||||
var bestVal float32 = float32(math.NaN())
|
||||
bestDist := math.MaxFloat64
|
||||
|
||||
for _, p := range products {
|
||||
bearing, distKm := bearingDist(p.SiteLat, p.SiteLon, lat, lon)
|
||||
if distKm >= maxRangeKm || distKm >= bestDist {
|
||||
continue
|
||||
}
|
||||
val := interpolateValue(p.Radials, bearing, distKm)
|
||||
bestDist = distKm
|
||||
bestVal = val
|
||||
}
|
||||
|
||||
if math.IsNaN(float64(bestVal)) {
|
||||
continue
|
||||
}
|
||||
img.SetRGBA(px, py, colorFn(bestVal))
|
||||
}
|
||||
}
|
||||
return speckleFilter(img)
|
||||
}
|
||||
|
||||
// renderTile renders a single-site tile.
|
||||
func renderTile(p *radar.RadarProduct, z, x, y int, colorFn func(float32) color.RGBA) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, tileSize, tileSize))
|
||||
|
||||
north, west := tileToLatLon(z, x, y)
|
||||
south, east := tileToLatLon(z, x+1, y+1)
|
||||
latSpan := north - south
|
||||
lonSpan := east - west
|
||||
|
||||
for py := 0; py < tileSize; py++ {
|
||||
lat := north - float64(py)/tileSize*latSpan
|
||||
for px := 0; px < tileSize; px++ {
|
||||
lon := west + float64(px)/tileSize*lonSpan
|
||||
bearing, distKm := bearingDist(p.SiteLat, p.SiteLon, lat, lon)
|
||||
val := interpolateValue(p.Radials, bearing, distKm)
|
||||
if math.IsNaN(float64(val)) {
|
||||
continue
|
||||
}
|
||||
img.SetRGBA(px, py, colorFn(val))
|
||||
}
|
||||
}
|
||||
return speckleFilter(img)
|
||||
}
|
||||
|
||||
// interpolateValue bilinearly interpolates between the two nearest radials and adjacent bins.
|
||||
func interpolateValue(radials []radar.Radial, bearing, distKm float64) float32 {
|
||||
if len(radials) == 0 {
|
||||
return float32(math.NaN())
|
||||
}
|
||||
|
||||
best := 0
|
||||
bestDiff := azDiff(radials[0].Azimuth, bearing)
|
||||
for i := 1; i < len(radials); i++ {
|
||||
if d := azDiff(radials[i].Azimuth, bearing); d < bestDiff {
|
||||
bestDiff = d
|
||||
best = i
|
||||
}
|
||||
}
|
||||
|
||||
diff := bearing - radials[best].Azimuth
|
||||
if diff > 180 {
|
||||
diff -= 360
|
||||
}
|
||||
if diff < -180 {
|
||||
diff += 360
|
||||
}
|
||||
|
||||
var adj int
|
||||
if diff >= 0 {
|
||||
adj = (best + 1) % len(radials)
|
||||
} else {
|
||||
adj = (best - 1 + len(radials)) % len(radials)
|
||||
diff = -diff
|
||||
}
|
||||
|
||||
span := azDiff(radials[best].Azimuth, radials[adj].Azimuth)
|
||||
t := 0.0
|
||||
if span > 0 {
|
||||
t = math.Min(diff/span, 1.0)
|
||||
}
|
||||
|
||||
v1 := radialValueAt(&radials[best], distKm)
|
||||
v2 := radialValueAt(&radials[adj], distKm)
|
||||
|
||||
switch {
|
||||
case math.IsNaN(float64(v1)) && math.IsNaN(float64(v2)):
|
||||
return float32(math.NaN())
|
||||
case math.IsNaN(float64(v1)):
|
||||
return v2
|
||||
case math.IsNaN(float64(v2)):
|
||||
return v1
|
||||
}
|
||||
return float32(float64(v1)*(1-t) + float64(v2)*t)
|
||||
}
|
||||
|
||||
func radialValueAt(r *radar.Radial, distKm float64) float32 {
|
||||
if distKm < r.RangeKm || r.BinSizeKm == 0 {
|
||||
return float32(math.NaN())
|
||||
}
|
||||
exactBin := (distKm - r.RangeKm) / r.BinSizeKm
|
||||
b0 := int(exactBin)
|
||||
if b0 >= len(r.Values) {
|
||||
return float32(math.NaN())
|
||||
}
|
||||
v0 := r.Values[b0]
|
||||
b1 := b0 + 1
|
||||
if b1 >= len(r.Values) || math.IsNaN(float64(v0)) {
|
||||
return v0
|
||||
}
|
||||
v1 := r.Values[b1]
|
||||
if math.IsNaN(float64(v1)) {
|
||||
return v0
|
||||
}
|
||||
frac := float32(exactBin - float64(b0))
|
||||
return v0*(1-frac) + v1*frac
|
||||
}
|
||||
|
||||
func bearingDist(lat1, lon1, lat2, lon2 float64) (bearing, distKm float64) {
|
||||
const earthR = 6371.0
|
||||
lat1r := lat1 * math.Pi / 180
|
||||
lat2r := lat2 * math.Pi / 180
|
||||
dlat := (lat2 - lat1) * math.Pi / 180
|
||||
dlon := (lon2 - lon1) * math.Pi / 180
|
||||
a := math.Sin(dlat/2)*math.Sin(dlat/2) +
|
||||
math.Cos(lat1r)*math.Cos(lat2r)*math.Sin(dlon/2)*math.Sin(dlon/2)
|
||||
distKm = earthR * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
y := math.Sin(dlon) * math.Cos(lat2r)
|
||||
x := math.Cos(lat1r)*math.Sin(lat2r) - math.Sin(lat1r)*math.Cos(lat2r)*math.Cos(dlon)
|
||||
bearing = math.Atan2(y, x) * 180 / math.Pi
|
||||
if bearing < 0 {
|
||||
bearing += 360
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func azDiff(a, b float64) float64 {
|
||||
d := math.Abs(a - b)
|
||||
if d > 180 {
|
||||
d = 360 - d
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func tileToLatLon(z, x, y int) (lat, lon float64) {
|
||||
n := math.Pow(2, float64(z))
|
||||
lon = float64(x)/n*360.0 - 180.0
|
||||
latRad := math.Atan(math.Sinh(math.Pi * (1 - 2*float64(y)/n)))
|
||||
lat = latRad * 180.0 / math.Pi
|
||||
return
|
||||
}
|
||||
|
||||
// dbzColor maps dBZ to RGBA. Below 20 dBZ is transparent — removes AP,
|
||||
// biological returns, and ground clutter while keeping real precipitation.
|
||||
func dbzColor(dbz float32) color.RGBA {
|
||||
switch {
|
||||
case dbz < 20:
|
||||
return color.RGBA{0, 0, 0, 0}
|
||||
case dbz < 25:
|
||||
return color.RGBA{0x02, 0x8e, 0x00, 210}
|
||||
case dbz < 30:
|
||||
return color.RGBA{0x01, 0xb4, 0x4c, 210}
|
||||
case dbz < 35:
|
||||
return color.RGBA{0x9c, 0xe4, 0x00, 220}
|
||||
case dbz < 40:
|
||||
return color.RGBA{0xd8, 0xd8, 0x00, 220}
|
||||
case dbz < 45:
|
||||
return color.RGBA{0xff, 0xaa, 0x00, 230}
|
||||
case dbz < 50:
|
||||
return color.RGBA{0xff, 0x00, 0x00, 230}
|
||||
case dbz < 55:
|
||||
return color.RGBA{0xd0, 0x00, 0x00, 240}
|
||||
case dbz < 60:
|
||||
return color.RGBA{0xc0, 0x00, 0x80, 240}
|
||||
default:
|
||||
return color.RGBA{0xff, 0xf7, 0x00, 255}
|
||||
}
|
||||
}
|
||||
|
||||
// velColor maps radial velocity (m/s) to RGBA.
|
||||
// Negative = toward radar (green), positive = away (red).
|
||||
func velColor(ms float32) color.RGBA {
|
||||
switch {
|
||||
case ms <= -27:
|
||||
return color.RGBA{0xff, 0x00, 0x00, 230} // strong inbound
|
||||
case ms <= -14:
|
||||
return color.RGBA{0xff, 0x66, 0x66, 210}
|
||||
case ms <= -1:
|
||||
return color.RGBA{0xff, 0xaa, 0xaa, 180}
|
||||
case ms < 1:
|
||||
return color.RGBA{0, 0, 0, 0} // near-zero
|
||||
case ms < 14:
|
||||
return color.RGBA{0xaa, 0xff, 0xaa, 180}
|
||||
case ms < 27:
|
||||
return color.RGBA{0x00, 0xcc, 0x00, 210}
|
||||
default:
|
||||
return color.RGBA{0x00, 0x66, 0x00, 230} // strong outbound
|
||||
}
|
||||
}
|
||||
134
internal/server/store.go
Normal file
134
internal/server/store.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/blakeridgway/heloha/internal/radar"
|
||||
)
|
||||
|
||||
const maxFrames = 12
|
||||
|
||||
type productKey struct{ site, product string }
|
||||
|
||||
type frameRing struct {
|
||||
frames [maxFrames]*radar.RadarProduct
|
||||
head int // next write slot
|
||||
count int // filled slots (0..maxFrames)
|
||||
}
|
||||
|
||||
func (r *frameRing) push(p *radar.RadarProduct) {
|
||||
// Deduplicate: skip if same or older scan time as the newest stored frame.
|
||||
if r.count > 0 {
|
||||
newest := r.frames[(r.head-1+maxFrames)%maxFrames]
|
||||
if !newest.Time.Before(p.Time) {
|
||||
return
|
||||
}
|
||||
}
|
||||
r.frames[r.head] = p
|
||||
r.head = (r.head + 1) % maxFrames
|
||||
if r.count < maxFrames {
|
||||
r.count++
|
||||
}
|
||||
}
|
||||
|
||||
// get returns the frame at index i where 0=oldest, count-1=newest.
|
||||
func (r *frameRing) get(i int) *radar.RadarProduct {
|
||||
if i < 0 || i >= r.count {
|
||||
return nil
|
||||
}
|
||||
slot := (r.head - r.count + i + maxFrames) % maxFrames
|
||||
return r.frames[slot]
|
||||
}
|
||||
|
||||
// RadarStore holds multi-site, multi-product frame history.
|
||||
type RadarStore struct {
|
||||
mu sync.RWMutex
|
||||
rings map[productKey]*frameRing
|
||||
}
|
||||
|
||||
func NewRadarStore() *RadarStore {
|
||||
return &RadarStore{rings: make(map[productKey]*frameRing)}
|
||||
}
|
||||
|
||||
func (s *RadarStore) Set(site, product string, p *radar.RadarProduct) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
k := productKey{site: site, product: product}
|
||||
if s.rings[k] == nil {
|
||||
s.rings[k] = &frameRing{}
|
||||
}
|
||||
s.rings[k].push(p)
|
||||
}
|
||||
|
||||
// GetLatest returns the newest frame for a given site and product, or nil.
|
||||
func (s *RadarStore) GetLatest(site, product string) *radar.RadarProduct {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
k := productKey{site: site, product: product}
|
||||
r := s.rings[k]
|
||||
if r == nil || r.count == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.get(r.count - 1)
|
||||
}
|
||||
|
||||
// GetAllLatest returns the newest frame for every site for the given product.
|
||||
func (s *RadarStore) GetAllLatest(product string) []*radar.RadarProduct {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*radar.RadarProduct, 0, 16)
|
||||
for k, r := range s.rings {
|
||||
if k.product == product && r.count > 0 {
|
||||
out = append(out, r.get(r.count-1))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetAllAtFrame returns one frame per site at the given age index (0=oldest, N-1=newest).
|
||||
func (s *RadarStore) GetAllAtFrame(product string, frameIdx int) []*radar.RadarProduct {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*radar.RadarProduct, 0, 16)
|
||||
for k, r := range s.rings {
|
||||
if k.product != product {
|
||||
continue
|
||||
}
|
||||
// Map frameIdx relative to this ring's count.
|
||||
// frameIdx 0 = oldest across all rings → use offset from back.
|
||||
p := r.get(frameIdx)
|
||||
if p != nil {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FrameInfo is one entry in the frames API response.
|
||||
type FrameInfo struct {
|
||||
Index int `json:"index"`
|
||||
Time time.Time `json:"time"`
|
||||
AgeSeconds int `json:"age_seconds"`
|
||||
}
|
||||
|
||||
// FrameMeta returns frame metadata ordered oldest→newest using KTLX as the reference.
|
||||
func (s *RadarStore) FrameMeta(product string) (int, []FrameInfo) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
r := s.rings[productKey{site: "ktlx", product: product}]
|
||||
if r == nil || r.count == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
now := time.Now()
|
||||
out := make([]FrameInfo, r.count)
|
||||
for i := 0; i < r.count; i++ {
|
||||
p := r.get(i)
|
||||
out[i] = FrameInfo{
|
||||
Index: i,
|
||||
Time: p.Time,
|
||||
AgeSeconds: int(now.Sub(p.Time).Seconds()),
|
||||
}
|
||||
}
|
||||
return r.count, out
|
||||
}
|
||||
34
internal/server/tilecache.go
Normal file
34
internal/server/tilecache.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package server
|
||||
|
||||
import "sync"
|
||||
|
||||
// TileCache is a thread-safe in-memory cache for rendered PNG tiles.
|
||||
// Keys are "z/x/y". The entire cache is invalidated on each new radar scan.
|
||||
type TileCache struct {
|
||||
mu sync.RWMutex
|
||||
tiles map[string][]byte
|
||||
}
|
||||
|
||||
func NewTileCache() *TileCache {
|
||||
return &TileCache{tiles: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
func (c *TileCache) Get(key string) ([]byte, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
v, ok := c.tiles[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (c *TileCache) Set(key string, data []byte) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.tiles[key] = data
|
||||
}
|
||||
|
||||
// Invalidate clears all cached tiles. Call this after each new radar product is loaded.
|
||||
func (c *TileCache) Invalidate() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.tiles = make(map[string][]byte)
|
||||
}
|
||||
Reference in New Issue
Block a user