Skip to content

Commit 5cf6d8b

Browse files
committed
feat: Environment-Map
1 parent eb255ec commit 5cf6d8b

15 files changed

Lines changed: 1231 additions & 26 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
99

1010
## [Unreleased]
1111

12+
### ✨ Hinzugefügt
13+
14+
#### 🌡️ Umweltdaten auf der Karte
15+
- Neues, in den Einstellungen aktivierbares Feature: Nodes, die Umwelt-Telemetrie liefern, zeigen ihre Messwerte direkt auf der Karte. Funktioniert für **Raster- und Vektor-Karte**.
16+
- **Messwert-Boxen** unter dem jeweiligen Node-Marker (leicht gelbe Box mit themenabhängigem Rand – weiß bei Nacht, schwarz bei Tag) mit allen gemeldeten Werten plus Datum/Uhrzeit der Messung.
17+
- **Heatmap / interpolierte Wertefläche** (nur Vektor-Karte) für die gewählte Metrik — im **Wetterdienst-Stil**: die Farbe entspricht dem **tatsächlichen Messwert** auf einer kalibrierten Skala (nicht der Punktdichte), mit **Legende** am Kartenrand. Zwischen den Sensoren wird per IDW (inverse Distanzgewichtung, ~30 km Reichweite) interpoliert; jenseits der Reichweite bleibt die Fläche transparent. Ältere Messwerte zählen weniger (exponentieller Zeit-Abfall), veraltete Sensoren verlieren an Gewicht. Funktioniert für **alle Metriken** (Temperatur, IAQ, Feuchte, …). Die Karte bleibt darunter sichtbar. Im Raster-Modus ein Hinweis, dass die Fläche nur in der Vektor-Karte verfügbar ist.
18+
- **Voller Sensorsatz**: Temperatur, Feuchte, Luftdruck, IAQ, Gas-Widerstand, Beleuchtung/Weißlicht/UV, Wind (Geschw./Richtung/Böe), Regen (1 h/24 h), Bodenfeuchte/-temperatur, Strahlung, Distanz, Gewicht. Die Telemetrie-DB wird dafür automatisch migriert (bisher wurden nur Temperatur/Feuchte/Luftdruck/IAQ gespeichert).
19+
- **Einzelne Nodes abwählbar** (falls einer Unsinn misst): über den 🌡️-Button an der Karte, wirkt auf Boxen **und** Heatmap.
20+
- Steuerung komplett über den neuen 🌡️-Button (Boxen an/aus, Heatmap an/aus, Metrik-Auswahl, Node-Liste); Einstellungen bleiben erhalten.
21+
1222
---
1323

1424
## [1.6.2.5] - 2026-08-30
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
using System.IO;
2+
using Meshtastic.Protobufs;
3+
using MeshhessenClient.Services;
4+
5+
namespace MeshhessenClient.Tests;
6+
7+
public class EnvironmentMetricInfoTests
8+
{
9+
[Fact]
10+
public void Registry_HasCoreAndExtendedMetrics()
11+
{
12+
Assert.NotNull(EnvironmentMetricInfo.ByKey("temperature"));
13+
Assert.NotNull(EnvironmentMetricInfo.ByKey("iaq"));
14+
Assert.NotNull(EnvironmentMetricInfo.ByKey("gas")); // extended sensor set
15+
Assert.NotNull(EnvironmentMetricInfo.ByKey("wind_speed"));
16+
Assert.Null(EnvironmentMetricInfo.ByKey("does_not_exist"));
17+
}
18+
19+
[Fact]
20+
public void Columns_MatchRegistryOneToOne()
21+
{
22+
Assert.Equal(EnvironmentMetricInfo.All.Count, EnvironmentMetricInfo.Columns.Count);
23+
Assert.Contains("gas_resistance", EnvironmentMetricInfo.Columns);
24+
Assert.True(EnvironmentMetricInfo.IsKnownColumn("relative_humidity"));
25+
Assert.False(EnvironmentMetricInfo.IsKnownColumn("node_id"));
26+
}
27+
28+
[Fact]
29+
public void Format_AppendsUnit()
30+
{
31+
Assert.StartsWith("21", EnvironmentMetricInfo.Format("temperature", 21.4));
32+
Assert.EndsWith("°C", EnvironmentMetricInfo.Format("temperature", 21.4));
33+
Assert.DoesNotContain("°", EnvironmentMetricInfo.Format("iaq", 42)); // iaq is unitless
34+
}
35+
36+
[Fact]
37+
public void Extract_TakesPresentFields_SkipsZeros()
38+
{
39+
var em = new EnvironmentMetrics { Temperature = 21.5f, RelativeHumidity = 60f, Iaq = 42, GasResistance = 12.3f };
40+
var d = EnvironmentMetricInfo.Extract(em);
41+
42+
Assert.Equal(21.5, d["temperature"], 3);
43+
Assert.Equal(60.0, d["humidity"], 3);
44+
Assert.Equal(42.0, d["iaq"], 3);
45+
Assert.Equal(12.3, d["gas"], 3);
46+
Assert.False(d.ContainsKey("pressure")); // 0 = not reported
47+
}
48+
}
49+
50+
public class EnvironmentHeatmapBuilderTests
51+
{
52+
[Fact]
53+
public void NoSamples_ReturnsNull()
54+
{
55+
Assert.Null(EnvironmentHeatmapBuilder.Representative(new List<(double, double)>()));
56+
}
57+
58+
[Fact]
59+
public void FreshReading_HasFullRecency_AndRealValue()
60+
{
61+
var r = EnvironmentHeatmapBuilder.Representative(new[] { (0.0, 25.0) });
62+
Assert.NotNull(r);
63+
Assert.Equal(25.0, r!.Value.Value, 3); // value is NOT normalised
64+
Assert.True(r.Value.Recency > 0.99, $"recency was {r.Value.Recency}");
65+
}
66+
67+
[Fact]
68+
public void RecentReadingsDominateValue()
69+
{
70+
// recent 30 (age 0), old 10 (age 24h): weighted value pulls toward the recent one,
71+
// i.e. above the plain average of 20.
72+
var r = EnvironmentHeatmapBuilder.Representative(new[] { (0.0, 30.0), (24.0, 10.0) });
73+
Assert.NotNull(r);
74+
Assert.True(r!.Value.Value > 20.0, $"value was {r.Value.Value}");
75+
}
76+
77+
[Fact]
78+
public void StaleNode_LosesRecency()
79+
{
80+
var fresh = EnvironmentHeatmapBuilder.Representative(new[] { (0.0, 25.0) })!.Value.Recency;
81+
var stale = EnvironmentHeatmapBuilder.Representative(new[] { (48.0, 25.0) })!.Value.Recency;
82+
Assert.True(stale < fresh);
83+
Assert.True(stale < 0.5, $"stale recency was {stale}");
84+
}
85+
}
86+
87+
public class EnvironmentFieldTests
88+
{
89+
private static EnvironmentField.Grid.Cell Nearest(EnvironmentField.Grid g, double lon, double lat)
90+
{
91+
double best = double.MaxValue; int bi = 0, bj = 0;
92+
for (int j = 0; j < g.Ny; j++)
93+
for (int i = 0; i < g.Nx; i++)
94+
{
95+
double cl = g.Lon0 + (i + 0.5) * g.DLon, ca = g.Lat0 + (j + 0.5) * g.DLat;
96+
double d = (cl - lon) * (cl - lon) + (ca - lat) * (ca - lat);
97+
if (d < best) { best = d; bi = i; bj = j; }
98+
}
99+
int idx = bj * g.Nx + bi;
100+
return new EnvironmentField.Grid.Cell(g.Values[idx], g.Alpha[idx]);
101+
}
102+
103+
[Fact]
104+
public void NoSensors_ReturnsNull()
105+
{
106+
Assert.Null(EnvironmentField.Build(new List<EnvironmentField.Sensor>(), 30));
107+
}
108+
109+
[Fact]
110+
public void InterpolatedValues_StayWithinSensorRange()
111+
{
112+
var sensors = new[]
113+
{
114+
new EnvironmentField.Sensor(8.0, 50.0, 30.0, 1.0), // hot
115+
new EnvironmentField.Sensor(8.3, 50.0, 10.0, 1.0), // cold, ~21 km east
116+
};
117+
var g = EnvironmentField.Build(sensors, 30);
118+
Assert.NotNull(g);
119+
120+
foreach (var v in g!.Values)
121+
if (!double.IsNaN(v))
122+
Assert.True(v >= 10.0 - 1e-6 && v <= 30.0 + 1e-6, $"value out of range: {v}"); // IDW weighted average
123+
}
124+
125+
[Fact]
126+
public void CellAtSensor_TakesThatSensorsValue()
127+
{
128+
var sensors = new[]
129+
{
130+
new EnvironmentField.Sensor(8.0, 50.0, 30.0, 1.0),
131+
new EnvironmentField.Sensor(8.3, 50.0, 10.0, 1.0),
132+
};
133+
var g = EnvironmentField.Build(sensors, 30)!;
134+
var hot = Nearest(g, 8.0, 50.0);
135+
Assert.True(hot.Value > 29.0, $"value at hot sensor was {hot.Value}");
136+
Assert.True(hot.Alpha > 0.0);
137+
}
138+
}
139+
140+
public class EnvironmentDbRoundTripTests
141+
{
142+
[Fact]
143+
public void InsertAndReadBack_FullSensorSet()
144+
{
145+
var dbPath = Path.Combine(Path.GetTempPath(), $"mh_envtest_{Guid.NewGuid():N}.db");
146+
try
147+
{
148+
using (var db = new TelemetryDatabaseService(dbPath))
149+
{
150+
db.InsertEnvironmentTelemetry(123u, DateTime.UtcNow, new Dictionary<string, double>
151+
{
152+
["temperature"] = 21.5,
153+
["humidity"] = 55,
154+
["gas"] = 12.3, // extended column added by migration
155+
["wind_speed"] = 3.2,
156+
});
157+
158+
var latest = db.GetLatestEnvironmentPerNode();
159+
var e = Assert.Single(latest);
160+
Assert.Equal(123u, e.NodeId);
161+
Assert.Equal(21.5, e.Values["temperature"], 3);
162+
Assert.Equal(12.3, e.Values["gas"], 3);
163+
Assert.True(e.Values.ContainsKey("wind_speed"));
164+
Assert.False(e.Values.ContainsKey("pressure")); // never inserted
165+
166+
var series = db.GetTimeSeries(new[] { 123u }, "gas", 1);
167+
Assert.Single(series);
168+
Assert.Equal(12.3, series[0].Value, 3);
169+
}
170+
}
171+
finally
172+
{
173+
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
174+
foreach (var p in new[] { dbPath, dbPath + "-wal", dbPath + "-shm" })
175+
try { if (File.Exists(p)) File.Delete(p); } catch { /* best-effort cleanup */ }
176+
}
177+
}
178+
}

0 commit comments

Comments
 (0)