The pandas data processing is blocking code, but the rest of the API is async. asyncio.to_thread() lets me run it without rewriting it. return_exceptions=True means if one source fails, the rest of the response still comes back clean.
alerts_result, water_quality = await asyncio.gather(
get_beach_alerts_safe(beach["lake"]),
asyncio.to_thread(get_water_quality_safe, beach_id),
return_exceptions=True,
)
alerts = alerts_result if not isinstance(alerts_result, Exception) else []
wq = water_quality if not isinstance(water_quality, Exception) else None
NOAA sends buoy readings as plain text and uses "MM" to mean a value is missing. Using row.get("WTMP", "MM") means even if a column is completely missing from the response, it still falls back to "MM" and gets handled the same way as a real missing reading.
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(
water_temp_c=_parse_float(row.get("WTMP", "MM")),
wave_height_m=_parse_float(row.get("WVHT", "MM"))
)
Takes the WeatherKit forecast, buoy water temp, and a CoreML model I trained and runs each day of the week through the crowd predictor. The whole thing runs on-device so there's no extra API call.
func loadCrowdPredictions(response: BeachDetailResponse) {
let waterTemp = buoyData?.waterTempC.map { $0 * 9/5 + 32 }
guard let today = weatherKitService.dailyForecast.first else { return }
todayCrowd = crowdPredictor.predict(
for: .now,
tempMax: Double(today.highF), tempMin: Double(today.lowF),
precipitation: today.chanceOfPrecipitation,
windMax: today.windSpeed.converted(to: .milesPerHour).value,
waterTemp: waterTemp, isHoliday: response.holiday
)
forecastCrowd = weatherKitService.dailyForecast.map { day in
crowdPredictor.predict(
for: day.date,
tempMax: Double(day.highF), tempMin: Double(day.lowF),
precipitation: day.chanceOfPrecipitation,
windMax: day.windSpeed.converted(to: .milesPerHour).value,
waterTemp: waterTemp, isHoliday: response.holiday
)
}
}
When the app refreshes, it fetches weather and alerts for all your saved beaches at the same time instead of one by one. Once everything comes back it checks if any beaches are worth a notification - a great beach day, conditions hitting a threshold, or a severe weather alert.
func refresh(
favorites: [Beach], scoringService: BeachScoringService,
weatherService: WeatherKitService, apiService: MichiganWaterAPIService,
userLocation: CLLocation?, at time: Date
) async {
guard !favorites.isEmpty else { cancelAll(); return }
var conditions: [Int: BeachConditions] = [:]
var alertsByBeach: [Int: [AlertFeature]] = [:]
await withTaskGroup(of: (Int, BeachConditions?, [AlertFeature]).self) { group in
for beach in favorites {
group.addTask {
async let weather = weatherService.fetchConditions(
latitude: beach.coordinates.latitude,
longitude: beach.coordinates.longitude)
async let details = try? apiService.fetchBeachDetails(beachID: beach.id)
let (w, d) = await (weather, details)
return (beach.id, w, d?.alerts ?? [])
}
}
for await (id, condition, alerts) in group {
if let condition { conditions[id] = condition }
alertsByBeach[id] = alerts
}
}
cancelAll()
scheduleTopFavoriteAlert(...)
scheduleThresholdAlert(...)
scheduleSevereAlertIfNeeded(alertsByBeach: alertsByBeach, favorites: favorites)
}