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.Logger
|
||||||
import org.typelevel.log4cats.slf4j.Slf4jLogger
|
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])
|
final case class WaterTemperatureResponse(source: String, readings: Vector[WaterTemperatureReading])
|
||||||
|
|
||||||
object WaterTemperatureService {
|
object WaterTemperatureService {
|
||||||
@@ -29,23 +30,104 @@ object WaterTemperatureService {
|
|||||||
final class WaterTemperatureService private (logger: Logger[IO], cache: Ref[IO, Option[(Instant, WaterTemperatureResponse)]]) {
|
final class WaterTemperatureService private (logger: Logger[IO], cache: Ref[IO, Option[(Instant, WaterTemperatureResponse)]]) {
|
||||||
private val cacheLifetime = Duration.ofMinutes(15)
|
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(
|
private val observationsUrl = sys.env.getOrElse(
|
||||||
"LVGMC_OPENDATA_HYDRO_URL",
|
"LVGMC_OPENDATA_HYDRO_URL",
|
||||||
"https://data.gov.lv/dati/api/3/action/datastore_search?resource_id=de5f06e9-6f44-497d-8ec2-72a2483608e8"
|
"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
|
private val areaOrder: Vector[String] = Vector("Jūra", "Līcis", "Kurzeme", "Zemgale", "Vidzeme", "Latgale")
|
||||||
// production template, verified 2026-08-23 against LVĢMC's open
|
|
||||||
// hydrological data. Jūra/Līcis are coastal sea-temperature stations
|
// Every LVĢMC hydrological station that reports water temperature (WTEMD
|
||||||
// (SEDUT); the four historical regions use inland river/lake stations
|
// inland/river stations, SEDUT coastal stations), grouped into the six
|
||||||
// (WTEMD). Approximate by design — see docs/UPDATE_ROADMAP.md.
|
// named Ūdens zones. Boundaries follow Latvia's historical regions
|
||||||
private val areaStations: List[(String, String, String)] = List(
|
// approximately, not administrative novadi — the open-data portal has no
|
||||||
("Jūra", "RIVE99PA", "SEDUT"),
|
// region field to key off. A handful of stations near region borders
|
||||||
("Līcis", "RISE99MS", "SEDUT"),
|
// (Jēkabpils, Carnikava, Kolka, the cluster just west of Rīga) were
|
||||||
("Kurzeme", "HD073613", "WTEMD"),
|
// judgment calls; verified against the live station/metadata list on
|
||||||
("Zemgale", "HD073801", "WTEMD"),
|
// 2026-08-23 — see docs/UPDATE_ROADMAP.md.
|
||||||
("Vidzeme", "HD073062", "WTEMD"),
|
private val stationZones: Map[String, String] = Map(
|
||||||
("Latgale", "HD072971", "WTEMD"), // Ludza — Daugavpils's station (HD073141) only reports water level (LIMEN), not temperature
|
// 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] =
|
def fetchWaterTemperatures: IO[WaterTemperatureResponse] =
|
||||||
@@ -75,38 +157,64 @@ final class WaterTemperatureService private (logger: Logger[IO], cache: Ref[IO,
|
|||||||
EmberClientBuilder.default[IO].build.use { client =>
|
EmberClientBuilder.default[IO].build.use { client =>
|
||||||
for {
|
for {
|
||||||
baseUri <- Uri.fromString(observationsUrl).liftTo[IO]
|
baseUri <- Uri.fromString(observationsUrl).liftTo[IO]
|
||||||
stationIds = areaStations.map(_._2).distinct
|
|
||||||
abbreviations = areaStations.map(_._3).distinct
|
|
||||||
filters = Json.obj(
|
filters = Json.obj(
|
||||||
"STATION_ID" -> Json.arr(stationIds.map(Json.fromString): _*),
|
"ABBREVIATION" -> Json.arr(Json.fromString("WTEMD"), Json.fromString("SEDUT")),
|
||||||
"ABBREVIATION" -> Json.arr(abbreviations.map(Json.fromString): _*),
|
|
||||||
).noSpaces
|
).noSpaces
|
||||||
// The underlying resource is LVĢMC's rolling 48h operational window,
|
// The combined WTEMD+SEDUT rolling window currently holds ~3,000
|
||||||
// so this narrow a filter (6 stations, 2 parameters) never needs
|
// rows; 5000 keeps headroom without needing pagination.
|
||||||
// pagination the way the larger station/warning fetches do.
|
uri = baseUri.withQueryParam("limit", "5000").withQueryParam("filters", filters)
|
||||||
uri = baseUri.withQueryParam("limit", "2000").withQueryParam("filters", filters)
|
|
||||||
body <- client.expect[String](uri)
|
body <- client.expect[String](uri)
|
||||||
rows <- parseRecords(body)
|
rows <- parseRecords(body)
|
||||||
readings = areaStations.map { case (area, stationId, abbreviation) =>
|
readings = buildReadings(rows)
|
||||||
latestFor(rows, stationId, abbreviation) match {
|
_ <- logger.info(s"Loaded water temperatures for ${readings.count(_.min.isDefined)} of ${readings.size} zones from ${rows.size} station readings")
|
||||||
case Some((observedAt, value)) => WaterTemperatureReading(area, observedAt, Some(value))
|
} yield WaterTemperatureResponse("LVĢMC", readings)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private def latestFor(rows: Vector[Json], stationId: String, abbreviation: String): Option[(String, Double)] =
|
private case class StationReading(stationId: String, datetime: String, value: Double)
|
||||||
rows.flatMap { row =>
|
|
||||||
|
private def buildReadings(rows: Vector[Json]): Vector[WaterTemperatureReading] = {
|
||||||
|
val parsed = rows.flatMap { row =>
|
||||||
val cursor = row.hcursor
|
val cursor = row.hcursor
|
||||||
for {
|
for {
|
||||||
sid <- cursor.downField("STATION_ID").as[String].toOption if sid == stationId
|
stationId <- cursor.downField("STATION_ID").as[String].toOption
|
||||||
ab <- cursor.downField("ABBREVIATION").as[String].toOption if ab == abbreviation
|
|
||||||
datetime <- cursor.downField("DATETIME").as[String].toOption
|
datetime <- cursor.downField("DATETIME").as[String].toOption
|
||||||
value <- cursor.downField("VALUE").as[Double].toOption
|
value <- cursor.downField("VALUE").as[Double].toOption
|
||||||
} yield datetime -> value
|
} yield StationReading(stationId, datetime, value)
|
||||||
}.sortBy(_._1)(Ordering[String].reverse).headOption
|
}
|
||||||
|
|
||||||
|
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]] =
|
private def parseRecords(body: String): IO[Vector[Json]] =
|
||||||
IO.fromEither(
|
IO.fromEither(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type OutputSize = '1920x1080' | '3840x1440';
|
|||||||
type WaterArea = 'Jūra' | 'Līcis' | 'Kurzeme' | 'Zemgale' | 'Vidzeme' | 'Latgale';
|
type WaterArea = 'Jūra' | 'Līcis' | 'Kurzeme' | 'Zemgale' | 'Vidzeme' | 'Latgale';
|
||||||
type RangeValue = { min: string; max: string };
|
type RangeValue = { min: string; max: string };
|
||||||
type LabelBox = { x: number; y: number; width: number; height: number };
|
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 areaNames: WaterArea[] = ['Jūra', 'Līcis', 'Kurzeme', 'Zemgale', 'Vidzeme', 'Latgale'];
|
||||||
const layouts: Record<OutputSize, { fontSize: number; horizontalPadding: number; boxes: Record<WaterArea, LabelBox> }> = {
|
const layouts: Record<OutputSize, { fontSize: number; horizontalPadding: number; boxes: Record<WaterArea, LabelBox> }> = {
|
||||||
@@ -63,9 +63,8 @@ export function WaterTemperature() {
|
|||||||
if (!readings) return;
|
if (!readings) return;
|
||||||
const next = emptyRanges();
|
const next = emptyRanges();
|
||||||
readings.forEach(reading => {
|
readings.forEach(reading => {
|
||||||
if (reading.value == null) return;
|
if (reading.min == null || reading.max == null) return;
|
||||||
const rounded = Math.round(reading.value).toString();
|
next[reading.area] = { min: Math.round(reading.min).toString(), max: Math.round(reading.max).toString() };
|
||||||
next[reading.area] = { min: rounded, max: rounded };
|
|
||||||
});
|
});
|
||||||
setRanges(next);
|
setRanges(next);
|
||||||
setOverrides(new Set<WaterArea>());
|
setOverrides(new Set<WaterArea>());
|
||||||
@@ -84,8 +83,9 @@ export function WaterTemperature() {
|
|||||||
|
|
||||||
const resetRange = (name: WaterArea) => {
|
const resetRange = (name: WaterArea) => {
|
||||||
const reading = observationsByArea().get(name);
|
const reading = observationsByArea().get(name);
|
||||||
const rounded = reading?.value == null ? '' : Math.round(reading.value).toString();
|
const min = reading?.min == null ? '' : Math.round(reading.min).toString();
|
||||||
setRanges(current => ({ ...current, [name]: { min: rounded, max: rounded } }));
|
const max = reading?.max == null ? '' : Math.round(reading.max).toString();
|
||||||
|
setRanges(current => ({ ...current, [name]: { min, max } }));
|
||||||
setOverrides(current => {
|
setOverrides(current => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
next.delete(name);
|
next.delete(name);
|
||||||
@@ -152,7 +152,7 @@ export function WaterTemperature() {
|
|||||||
<h2>3. Ievadi temperatūras</h2>
|
<h2>3. Ievadi temperatūras</h2>
|
||||||
<div class="rangeGrid"><For each={areaNames}>{name => {
|
<div class="rangeGrid"><For each={areaNames}>{name => {
|
||||||
const observation = () => observationsByArea().get(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>
|
<legend>{name}</legend>
|
||||||
<div class="rangeFieldMeta">
|
<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>
|
<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