From 9eca9ebaf361d158fd96edf14f431017598693de Mon Sep 17 00:00:00 2001 From: b0txec Date: Sun, 23 Aug 2026 11:45:43 +0300 Subject: [PATCH] Show real regional water-temperature ranges, not one station duplicated as both bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classify all 65 LVĢMC stations reporting water temperature (56 inland WTEMD + 9 coastal SEDUT) into the six Ūdens zones by geography, and report the real min/max across each zone's currently-reporting stations instead of one hand-picked station's single value shown twice. Drops readings older than 12h so a stuck sensor can't skew a zone's range. --- .../fetch/lvgmc/WaterTemperatureService.scala | 180 ++++++++++++++---- .../water-temperature/WaterTemperature.tsx | 14 +- 2 files changed, 151 insertions(+), 43 deletions(-) diff --git a/src/main/scala/fetch/lvgmc/WaterTemperatureService.scala b/src/main/scala/fetch/lvgmc/WaterTemperatureService.scala index 671be7b..505c218 100644 --- a/src/main/scala/fetch/lvgmc/WaterTemperatureService.scala +++ b/src/main/scala/fetch/lvgmc/WaterTemperatureService.scala @@ -9,9 +9,10 @@ import org.http4s.ember.client.EmberClientBuilder import org.typelevel.log4cats.Logger import org.typelevel.log4cats.slf4j.Slf4jLogger -import java.time.{Duration, Instant} +import java.time.{Duration, Instant, LocalDateTime} +import scala.util.Try -final case class WaterTemperatureReading(area: String, observedAt: String, value: Option[Double]) +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 { @@ -29,23 +30,104 @@ object WaterTemperatureService { 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" ) - // One representative real station per named water area on the Ūdens - // production template, verified 2026-08-23 against LVĢMC's open - // hydrological data. Jūra/Līcis are coastal sea-temperature stations - // (SEDUT); the four historical regions use inland river/lake stations - // (WTEMD). Approximate by design — see docs/UPDATE_ROADMAP.md. - private val areaStations: List[(String, String, String)] = List( - ("Jūra", "RIVE99PA", "SEDUT"), - ("Līcis", "RISE99MS", "SEDUT"), - ("Kurzeme", "HD073613", "WTEMD"), - ("Zemgale", "HD073801", "WTEMD"), - ("Vidzeme", "HD073062", "WTEMD"), - ("Latgale", "HD072971", "WTEMD"), // Ludza — Daugavpils's station (HD073141) only reports water level (LIMEN), not temperature + private val areaOrder: Vector[String] = Vector("Jūra", "Līcis", "Kurzeme", "Zemgale", "Vidzeme", "Latgale") + + // 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] = @@ -75,38 +157,64 @@ final class WaterTemperatureService private (logger: Logger[IO], cache: Ref[IO, EmberClientBuilder.default[IO].build.use { client => for { baseUri <- Uri.fromString(observationsUrl).liftTo[IO] - stationIds = areaStations.map(_._2).distinct - abbreviations = areaStations.map(_._3).distinct filters = Json.obj( - "STATION_ID" -> Json.arr(stationIds.map(Json.fromString): _*), - "ABBREVIATION" -> Json.arr(abbreviations.map(Json.fromString): _*), + "ABBREVIATION" -> Json.arr(Json.fromString("WTEMD"), Json.fromString("SEDUT")), ).noSpaces - // The underlying resource is LVĢMC's rolling 48h operational window, - // so this narrow a filter (6 stations, 2 parameters) never needs - // pagination the way the larger station/warning fetches do. - uri = baseUri.withQueryParam("limit", "2000").withQueryParam("filters", filters) + // 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 = areaStations.map { case (area, stationId, abbreviation) => - latestFor(rows, stationId, abbreviation) match { - case Some((observedAt, value)) => WaterTemperatureReading(area, observedAt, Some(value)) - case None => WaterTemperatureReading(area, "", None) - } - } - _ <- logger.info(s"Loaded water temperatures for ${readings.count(_.value.isDefined)} of ${readings.size} areas") - } yield WaterTemperatureResponse("LVĢMC", readings.toVector) + 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 def latestFor(rows: Vector[Json], stationId: String, abbreviation: String): Option[(String, Double)] = - rows.flatMap { row => + 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 { - sid <- cursor.downField("STATION_ID").as[String].toOption if sid == stationId - ab <- cursor.downField("ABBREVIATION").as[String].toOption if ab == abbreviation + stationId <- cursor.downField("STATION_ID").as[String].toOption datetime <- cursor.downField("DATETIME").as[String].toOption value <- cursor.downField("VALUE").as[Double].toOption - } yield datetime -> value - }.sortBy(_._1)(Ordering[String].reverse).headOption + } 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( diff --git a/web/src/pages/water-temperature/WaterTemperature.tsx b/web/src/pages/water-temperature/WaterTemperature.tsx index 203f1c6..38780d9 100644 --- a/web/src/pages/water-temperature/WaterTemperature.tsx +++ b/web/src/pages/water-temperature/WaterTemperature.tsx @@ -11,7 +11,7 @@ type OutputSize = '1920x1080' | '3840x1440'; type WaterArea = 'Jūra' | 'Līcis' | 'Kurzeme' | 'Zemgale' | 'Vidzeme' | 'Latgale'; type RangeValue = { min: string; max: string }; type LabelBox = { x: number; y: number; width: number; height: number }; -type WaterReading = { area: WaterArea; observedAt: string; value: number | null }; +type WaterReading = { area: WaterArea; observedAt: string; min: number | null; max: number | null }; const areaNames: WaterArea[] = ['Jūra', 'Līcis', 'Kurzeme', 'Zemgale', 'Vidzeme', 'Latgale']; const layouts: Record }> = { @@ -63,9 +63,8 @@ export function WaterTemperature() { if (!readings) return; const next = emptyRanges(); readings.forEach(reading => { - if (reading.value == null) return; - const rounded = Math.round(reading.value).toString(); - next[reading.area] = { min: rounded, max: rounded }; + if (reading.min == null || reading.max == null) return; + next[reading.area] = { min: Math.round(reading.min).toString(), max: Math.round(reading.max).toString() }; }); setRanges(next); setOverrides(new Set()); @@ -84,8 +83,9 @@ export function WaterTemperature() { const resetRange = (name: WaterArea) => { const reading = observationsByArea().get(name); - const rounded = reading?.value == null ? '' : Math.round(reading.value).toString(); - setRanges(current => ({ ...current, [name]: { min: rounded, max: rounded } })); + const min = reading?.min == null ? '' : Math.round(reading.min).toString(); + const max = reading?.max == null ? '' : Math.round(reading.max).toString(); + setRanges(current => ({ ...current, [name]: { min, max } })); setOverrides(current => { const next = new Set(current); next.delete(name); @@ -152,7 +152,7 @@ export function WaterTemperature() {

3. Ievadi temperatūras

{name => { const observation = () => observationsByArea().get(name); - return
+ return
{name}
{overrides().has(name) ? 'Manuāli' : observation()?.observedAt ? new Date(observation()!.observedAt).toLocaleTimeString('lv-LV', { hour: '2-digit', minute: '2-digit' }) : 'Nav datu'}