package fetch.lvgmc import cats.effect.{IO, Ref} import cats.syntax.all._ import io.circe.Json import io.circe.parser import org.http4s.Uri import org.http4s.ember.client.EmberClientBuilder import org.typelevel.log4cats.Logger import org.typelevel.log4cats.slf4j.Slf4jLogger import java.time.{Duration, Instant, LocalDateTime, ZoneId, ZoneOffset} import scala.util.Try final case class WaterTemperatureReading(area: String, observedAt: String, min: Option[Double], max: Option[Double]) final case class WaterTemperatureResponse(source: String, readings: Vector[WaterTemperatureReading]) object WaterTemperatureService { def of: IO[WaterTemperatureService] = for { logger <- Slf4jLogger.create[IO] cache <- Ref.of[IO, Option[(Instant, WaterTemperatureResponse)]](None) } yield new WaterTemperatureService(logger, cache) } // Real-time only, by design: the Ūdens workspace has never persisted history // (its ranges are manual editorial inputs), and this follows the same // pattern — an in-memory cache, no database table, mirroring // fetch.warnings.WarningService rather than the station-observation path. final class WaterTemperatureService private (logger: Logger[IO], cache: Ref[IO, Option[(Instant, WaterTemperatureResponse)]]) { private val cacheLifetime = Duration.ofMinutes(15) // Marine (SEDUT) stations report less often than inland gauges — 12h // comfortably covers that gap while still dropping genuinely stuck // sensors (one inland station was found reporting a value 14h stale // while its neighbors were all current). private val recencyWindow = Duration.ofHours(12) private val observationsUrl = sys.env.getOrElse( "LVGMC_OPENDATA_HYDRO_URL", "https://data.gov.lv/dati/api/3/action/datastore_search?resource_id=de5f06e9-6f44-497d-8ec2-72a2483608e8" ) private val areaOrder: Vector[String] = Vector("Jūra", "Līcis", "Kurzeme", "Zemgale", "Vidzeme", "Latgale") // This dataset's DATETIME field is UTC (same source/portal as the station // observations feed, same verified offset); converted to local so the // observedAt label shown on Ūdens matches the newsroom's own clock instead // of reading 2-3h behind. private val rigaZone = ZoneId.of("Europe/Riga") private def toLocal(utcDatetime: String): Option[String] = Try(LocalDateTime.parse(utcDatetime).atZone(ZoneOffset.UTC).withZoneSameInstant(rigaZone).toLocalDateTime.toString).toOption // Every LVĢMC hydrological station that reports water temperature (WTEMD // inland/river stations, SEDUT coastal stations), grouped into the six // named Ūdens zones. Boundaries follow Latvia's historical regions // approximately, not administrative novadi — the open-data portal has no // region field to key off. A handful of stations near region borders // (Jēkabpils, Carnikava, Kolka, the cluster just west of Rīga) were // judgment calls; verified against the live station/metadata list on // 2026-08-23 — see docs/UPDATE_ROADMAP.md. private val stationZones: Map[String, String] = Map( // Jūra — open Baltic coast, west side of Kurzeme "RILP99PA" -> "Jūra", // Liepāja "RIVE99PA" -> "Jūra", // Ventspils // Līcis — Gulf of Riga coast "RIKO99PA" -> "Līcis", // Kolka "RIME99MS" -> "Līcis", // Mērsrags "SERO99MS" -> "Līcis", // Roja "RISE99MS" -> "Līcis", // Skulte "SALACGRI" -> "Līcis", // Salacgrīva "DAUGAVGR" -> "Līcis", // Daugavgrīva "SEJU99MS" -> "Līcis", // Lielupes grīva "HD073813" -> "Līcis", // Carnikava — inland gauge, but right at the Gulf coast // Kurzeme "HD073580" -> "Kurzeme", // Rojupe "HD073605" -> "Kurzeme", // Vārdava "HD073613" -> "Kurzeme", // Kuldīga "HD073616" -> "Kurzeme", // Vendzava "HD073641" -> "Kurzeme", // Pakuļu HES "HD073659" -> "Kurzeme", // Tērande "HD073682" -> "Kurzeme", // Dūkupji "HD073709" -> "Kurzeme", // Cīrava "HD073726" -> "Kurzeme", // Alši "HD073982" -> "Kurzeme", // Usma "HD073529" -> "Kurzeme", // Lielveisi // Zemgale "HD073151" -> "Zemgale", // Jēkabpils "HD073411" -> "Zemgale", // Zaķi "HD073418" -> "Zemgale", // Stariņi "HD073422" -> "Zemgale", // Mežotne "HD073424" -> "Zemgale", // Staļģene "HD073461" -> "Zemgale", // Sudrabkalni "HD073482" -> "Zemgale", // Bauska "HD073548" -> "Zemgale", // Bramberģe "HD073552" -> "Zemgale", // Baloži "HD073801" -> "Zemgale", // Jelgava "HD073802" -> "Zemgale", // Kalnciems // Vidzeme "HD073003" -> "Vidzeme", // Mazsalaca "HD073009" -> "Vidzeme", // Lagaste "HD073025" -> "Vidzeme", // Oleri "HD073044" -> "Vidzeme", // Velēna "HD073052" -> "Vidzeme", // Valmiera "HD073062" -> "Vidzeme", // Sigulda "HD073071" -> "Vidzeme", // Zosēni "HD073075" -> "Vidzeme", // Taurene "HD073079" -> "Vidzeme", // Lejasciems "HD073081" -> "Vidzeme", // Vilkzemnieki "HD073098" -> "Vidzeme", // Melturi "HD073345" -> "Vidzeme", // Lubāna "HD073352" -> "Vidzeme", // Aiviekstes HES "HD073366" -> "Vidzeme", // Litene "HD073400" -> "Vidzeme", // Lielpeči "HD073410" -> "Vidzeme", // Alderi "HD073727" -> "Vidzeme", // Meņģele "HD073810" -> "Vidzeme", // Rīga "HD073902" -> "Vidzeme", // Zeļķi "HD073904" -> "Vidzeme", // Pļaviņas "HD073970" -> "Vidzeme", // Alūksne // Latgale "HD072694" -> "Latgale", // Lozdova "HD072971" -> "Latgale", // Ludza "HD073135" -> "Latgale", // Piedruja "HD073137" -> "Latgale", // Krāslava "HD073144" -> "Latgale", // Vaikuļāni "HD073146" -> "Latgale", // Jersika "HD073334" -> "Latgale", // Kūlenieki "HD073380" -> "Latgale", // Griškāni "HD073720" -> "Latgale", // Brūnuļi "HD073721" -> "Latgale", // Lenderņa "HD073953" -> "Latgale", // Spīdoles "HD073972" -> "Latgale", // Kaunata ) def fetchWaterTemperatures: IO[WaterTemperatureResponse] = for { now <- IO.delay(Instant.now) cached <- cache.get response <- cached match { case Some((storedAt, value)) if Duration.between(storedAt, now).compareTo(cacheLifetime) < 0 => IO.pure(value) case stale => fetchFresh.attempt.flatMap { case Right(value) => cache.set(Some(now -> value)).as(value) case Left(error) => stale match { // Re-stamp with `now` even on failure so a dead upstream is // retried at most once per cache lifetime, not on every request // (see the identical fix in WarningService). case Some((_, value)) => logger.warn(error)("Using stale water temperature data; will not retry until the cache lifetime elapses") *> cache.set(Some(now -> value)).as(value) case None => IO.raiseError(error) } } } } yield response private def fetchFresh: IO[WaterTemperatureResponse] = EmberClientBuilder.default[IO].build.use { client => for { baseUri <- Uri.fromString(observationsUrl).liftTo[IO] filters = Json.obj( "ABBREVIATION" -> Json.arr(Json.fromString("WTEMD"), Json.fromString("SEDUT")), ).noSpaces // The combined WTEMD+SEDUT rolling window currently holds ~3,000 // rows; 5000 keeps headroom without needing pagination. uri = baseUri.withQueryParam("limit", "5000").withQueryParam("filters", filters) body <- client.expect[String](uri) rows <- parseRecords(body) readings = buildReadings(rows) _ <- logger.info(s"Loaded water temperatures for ${readings.count(_.min.isDefined)} of ${readings.size} zones from ${rows.size} station readings") } yield WaterTemperatureResponse("LVĢMC", readings) } private case class StationReading(stationId: String, datetime: String, value: Double) private def buildReadings(rows: Vector[Json]): Vector[WaterTemperatureReading] = { val parsed = rows.flatMap { row => val cursor = row.hcursor for { stationId <- cursor.downField("STATION_ID").as[String].toOption rawDatetime <- cursor.downField("DATETIME").as[String].toOption datetime <- toLocal(rawDatetime) value <- cursor.downField("VALUE").as[Double].toOption } yield StationReading(stationId, datetime, value) } if (parsed.isEmpty) { areaOrder.map(WaterTemperatureReading(_, "", None, None)) } else { // "Recent" is relative to the freshest timestamp this fetch actually // returned, not wall-clock time — sidesteps any timezone ambiguity // between the API and the app, since both readings come from the // same source clock. val latestTimestamp = parsed.map(_.datetime).max val latestPerStation = parsed.groupBy(_.stationId).values.map(_.maxBy(_.datetime)) val fresh = latestPerStation.filter(reading => isRecent(reading.datetime, latestTimestamp)) val byZone = fresh .flatMap(reading => stationZones.get(reading.stationId).map(_ -> reading)) .groupBy(_._1) .view.mapValues(_.map(_._2)) .toMap areaOrder.map { area => byZone.getOrElse(area, Iterable.empty).toVector match { case values if values.nonEmpty => WaterTemperatureReading(area, values.map(_.datetime).max, Some(values.map(_.value).min), Some(values.map(_.value).max)) case _ => WaterTemperatureReading(area, "", None, None) } } } } private def isRecent(datetime: String, referenceTimestamp: String): Boolean = (for { observed <- Try(LocalDateTime.parse(datetime)).toOption reference <- Try(LocalDateTime.parse(referenceTimestamp)).toOption } yield Duration.between(observed, reference).compareTo(recencyWindow) <= 0).getOrElse(false) private def parseRecords(body: String): IO[Vector[Json]] = IO.fromEither( parser.parse(body) .leftMap(error => new RuntimeException(s"Unable to parse water temperature data: ${error.message}")) .flatMap { json => json.hcursor.downField("result").downField("records").as[Vector[Json]] .leftMap(error => new RuntimeException(s"Invalid water temperature response: ${error.message}")) } ) }