Fetches weather, buoy, and alert data simultaneously for a single beach. Failed calls resolve to None so the rest of the response still returns clean.
@app.get("/beaches/{beach_id}/details")
async def get_beach(beach_id: int):
beach = next((b for b in sample_beaches if b["id"] == beach_id), None)
if not beach:
raise HTTPException(status_code=404, detail="Beach not found")
weather_result, buoy_result, alerts_result = await asyncio.gather(
fetch_weather_conditions(beach["nws_station_id"]),
fetch_ndbc_conditions(beach["buoy_station"]),
fetch_nws_alerts(beach["latitude"], beach["longitude"]),
return_exceptions=True,
)
weather = weather_result if not isinstance(weather_result, Exception) else None
buoy = buoy_result if not isinstance(buoy_result, Exception) else None
alerts = alerts_result if not isinstance(alerts_result, Exception) else None
return {
"beach": beach["name"],
"weather": weather,
"buoy": buoy,
"alerts": alerts,
}
Parses the NDBC buoy API's plain text response into structured data. Handles missing readings marked as MM and validates row length before mapping.
async def fetch_ndbc_conditions(station_id: str) -> WaterConditions:
url = f"https://www.ndbc.noaa.gov/data/realtime2/{station_id}.txt"
headers = {"User-Agent": "MichiganWaterAPI/1.0 (learning project)"}
async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
response = await client.get(url)
response.raise_for_status()
lines = response.text.strip().splitlines()
if len(lines) < 3:
raise ValueError(f"No usable NDBC data found for station {station_id}")
columns = lines[0].split()
first_row = lines[2].split()
if len(first_row) < len(columns):
raise ValueError(f"Malformed NDBC row for station {station_id}")
row = dict(zip(columns, first_row))
return WaterConditions(
station_id=station_id,
water_temp_c=_parse_float(row.get("WTMP", "MM")),
wave_height_m=_parse_float(row.get("WVHT", "MM")),
)
Reads raw metric values from the NWS response and converts each measurement for display. Falls back to "--" when data is missing.
// Wind speed: API returns km/h
if let speedKMH = observation.windSpeed.value {
let mph = speedKMH * 0.621371
windMPH = String(format: "%.1f mph", mph)
} else {
windMPH = "--"
}
// Visibility: API returns meters
if let meters = observation.visibility.value {
let miles = meters / 1609.34
visibility = String(format: "%.1f mi", miles)
} else {
visibility = "--"
}
// Barometric pressure: API returns Pascals
if let pa = observation.barometricPressure?.value {
let hpa = pa / 100.0
pressure = String(format: "%.1f hPa", hpa)
} else {
pressure = nil
}
Stores raw Celsius from the API and recalculates the display string on toggle. The UI reads temperatureDisplay directly so the switch is instant with no refetch.
@Published var useCelsius: Bool = false
private var rawTempCelsius: Double?
func toggleUnit() {
useCelsius.toggle()
updateTemperatureDisplay()
}
private func updateTemperatureDisplay() {
guard let tempC = rawTempCelsius else {
temperatureDisplay = "--"
return
}
if useCelsius {
temperatureDisplay = "\(Int(tempC))°C"
} else {
let tempF = (tempC * 9.0 / 5.0) + 32.0
temperatureDisplay = "\(Int(tempF))°F"
}
}