Show real regional water-temperature ranges, not one station duplicated as both bounds
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.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<OutputSize, { fontSize: number; horizontalPadding: number; boxes: Record<WaterArea, LabelBox> }> = {
|
||||
@@ -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<WaterArea>());
|
||||
@@ -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() {
|
||||
<h2>3. Ievadi temperatūras</h2>
|
||||
<div class="rangeGrid"><For each={areaNames}>{name => {
|
||||
const observation = () => observationsByArea().get(name);
|
||||
return <fieldset class="rangeField" classList={{ manual: overrides().has(name), missing: observation()?.value == null }}>
|
||||
return <fieldset class="rangeField" classList={{ manual: overrides().has(name), missing: observation()?.min == null }}>
|
||||
<legend>{name}</legend>
|
||||
<div class="rangeFieldMeta">
|
||||
<span>{overrides().has(name) ? 'Manuāli' : observation()?.observedAt ? new Date(observation()!.observedAt).toLocaleTimeString('lv-LV', { hour: '2-digit', minute: '2-digit' }) : 'Nav datu'}</span>
|
||||
|
||||
Reference in New Issue
Block a user