feat: RainCalculator with timezone-aware day mapping and month total

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:37:06 +02:00
co-authored by Claude Fable 5.1
parent 12f5f97a53
commit d657fd069f
6 changed files with 252 additions and 1 deletions
+1 -1
View File
@@ -1 +1 @@
{"version":2,"defects":[],"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0.004,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0.001,"Weerscherm\\Tests\\StationMapperTest::testStationMetadata":0,"Weerscherm\\Tests\\StationMapperTest::testOutdoor":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorDefaultOrderAndUnreachableModule":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorConfigDeterminesOrderAndLabels":0,"Weerscherm\\Tests\\StationMapperTest::testRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testWithoutRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testUnreachableOutdoorModuleGivesNulls":0,"Weerscherm\\Tests\\StationMapperTest::testUnknownDeviceIdFallsBackToFirstStation":0,"Weerscherm\\Tests\\StationMapperTest::testEmptyDevicesGivesNullsWithoutCrashing":0}}
{"version":2,"defects":[],"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0.003,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0.001,"Weerscherm\\Tests\\StationMapperTest::testStationMetadata":0,"Weerscherm\\Tests\\StationMapperTest::testOutdoor":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorDefaultOrderAndUnreachableModule":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorConfigDeterminesOrderAndLabels":0,"Weerscherm\\Tests\\StationMapperTest::testRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testWithoutRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testUnreachableOutdoorModuleGivesNulls":0,"Weerscherm\\Tests\\StationMapperTest::testUnknownDeviceIdFallsBackToFirstStation":0,"Weerscherm\\Tests\\StationMapperTest::testEmptyDevicesGivesNullsWithoutCrashing":0,"Weerscherm\\Tests\\RainCalculatorTest::testNormalWeek":0.001,"Weerscherm\\Tests\\RainCalculatorTest::testMissingPreviousDayIsNullAndExcludedFromTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingTodayFallsBackToSumRain24":0,"Weerscherm\\Tests\\RainCalculatorTest::testMonthBoundaryKeepsPreviousDaysButNotInMonthTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testDaylightSavingTransitionMapsTimestampsToLocalDates":0,"Weerscherm\\Tests\\RainCalculatorTest::testEmptyBodyGivesNulls":0,"Weerscherm\\Tests\\RainCalculatorTest::testDateBeginIsMinOfFirstOfMonthAndThreeDaysAgo":0}}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/**
* Berekent regen vandaag, de 3 vorige dagen en het maandtotaal uit een Netatmo getmeasure-body
* (scale=1day, type=sum_rain, optimize=false): een map van unix-timestamp naar [mm].
*
* Elke timestamp wordt gekoppeld aan een lokale datum in de tijdzone van $now. Bij real_time=false
* liggen de timestamps van dagwaarden niet op middernacht, dus daar wordt niet van uitgegaan.
*/
final class RainCalculator
{
private const PREVIOUS_DAYS = 3;
/** 00:00 lokale tijd van min(1e van de maand, vandaag − 3 dagen). */
public static function dateBegin(\DateTimeImmutable $now): \DateTimeImmutable
{
$midnight = $now->setTime(0, 0, 0);
$firstOfMonth = $midnight->setDate((int) $now->format('Y'), (int) $now->format('n'), 1);
$threeDaysAgo = $midnight->modify('-' . self::PREVIOUS_DAYS . ' days');
return $firstOfMonth < $threeDaysAgo ? $firstOfMonth : $threeDaysAgo;
}
/**
* @param array<string|int, mixed> $body
* @return array{today: ?float, previous_days: list<array{date: string, mm: ?float}>, month_total: ?float}
*/
public static function compute(array $body, \DateTimeImmutable $now, ?float $sumRain24): array
{
$tz = $now->getTimezone();
$perDate = [];
foreach ($body as $timestamp => $values) {
if (!is_numeric($timestamp) || !is_array($values) || !isset($values[0]) || !is_numeric($values[0])) {
continue;
}
$date = (new \DateTimeImmutable('@' . (int) $timestamp))->setTimezone($tz)->format('Y-m-d');
$perDate[$date] = ($perDate[$date] ?? 0.0) + (float) $values[0];
}
$today = $now->format('Y-m-d');
$previous = [];
for ($i = self::PREVIOUS_DAYS; $i >= 1; $i--) {
$date = $now->modify("-$i days")->format('Y-m-d');
$previous[] = ['date' => $date, 'mm' => $perDate[$date] ?? null];
}
$monthPrefix = $now->format('Y-m') . '-';
$monthTotal = null;
foreach ($perDate as $date => $mm) {
if (str_starts_with($date, $monthPrefix) && $date <= $today) {
$monthTotal = ($monthTotal ?? 0.0) + $mm;
}
}
return [
'today' => $perDate[$today] ?? $sumRain24,
'previous_days' => $previous,
'month_total' => $monthTotal,
];
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\RainCalculator;
final class RainCalculatorTest extends TestCase
{
private \DateTimeZone $tz;
protected function setUp(): void
{
$this->tz = new \DateTimeZone('Europe/Berlin');
}
/** @return array<string, mixed> */
private function fixture(string $name): array
{
return json_decode((string) file_get_contents(__DIR__ . "/fixtures/$name.json"), true)['body'];
}
private function now(string $s): \DateTimeImmutable
{
return new \DateTimeImmutable($s, $this->tz);
}
public function testNormalWeek(): void
{
$r = RainCalculator::compute($this->fixture('getmeasure'), $this->now('2026-09-11 14:32'), 9.9);
self::assertSame(2.4, $r['today']);
self::assertSame([
['date' => '2026-09-08', 'mm' => 1.2],
['date' => '2026-09-09', 'mm' => 6.8],
['date' => '2026-09-10', 'mm' => 0.0],
], $r['previous_days']);
self::assertEqualsWithDelta(38.5, $r['month_total'], 0.0001);
}
public function testMissingPreviousDayIsNullAndExcludedFromTotal(): void
{
$body = $this->fixture('getmeasure');
$sep9 = (string) $this->now('2026-09-09 03:17')->getTimestamp();
unset($body[$sep9]);
$r = RainCalculator::compute($body, $this->now('2026-09-11 14:32'), null);
self::assertNull($r['previous_days'][1]['mm']);
self::assertSame('2026-09-09', $r['previous_days'][1]['date']);
self::assertEqualsWithDelta(31.7, $r['month_total'], 0.0001);
}
public function testMissingTodayFallsBackToSumRain24(): void
{
$body = $this->fixture('getmeasure');
unset($body[(string) $this->now('2026-09-11 03:17')->getTimestamp()]);
self::assertSame(2.4, RainCalculator::compute($body, $this->now('2026-09-11 14:32'), 2.4)['today']);
self::assertNull(RainCalculator::compute($body, $this->now('2026-09-11 14:32'), null)['today']);
}
public function testMonthBoundaryKeepsPreviousDaysButNotInMonthTotal(): void
{
$r = RainCalculator::compute($this->fixture('getmeasure-month'), $this->now('2026-10-02 09:00'), null);
self::assertSame(5.0, $r['today']);
self::assertSame([
['date' => '2026-09-29', 'mm' => 2.0],
['date' => '2026-09-30', 'mm' => 3.0],
['date' => '2026-10-01', 'mm' => 4.0],
], $r['previous_days']);
self::assertEqualsWithDelta(9.0, $r['month_total'], 0.0001);
}
public function testDaylightSavingTransitionMapsTimestampsToLocalDates(): void
{
$r = RainCalculator::compute($this->fixture('getmeasure-dst'), $this->now('2026-10-25 12:00'), null);
self::assertSame(4.0, $r['today'], 'meting om 01:30 lokaal (nog zomertijd) hoort bij 25 oktober');
self::assertSame([
['date' => '2026-10-22', 'mm' => 1.0],
['date' => '2026-10-23', 'mm' => 2.0],
['date' => '2026-10-24', 'mm' => 3.0],
], $r['previous_days']);
self::assertEqualsWithDelta(10.0, $r['month_total'], 0.0001, '26 oktober telt niet mee, ook al ligt de timestamp op 25 oktober in UTC');
}
public function testEmptyBodyGivesNulls(): void
{
$r = RainCalculator::compute([], $this->now('2026-09-11 14:32'), null);
self::assertNull($r['today']);
self::assertSame([null, null, null], array_column($r['previous_days'], 'mm'));
self::assertNull($r['month_total']);
}
public function testDateBeginIsMinOfFirstOfMonthAndThreeDaysAgo(): void
{
self::assertSame('2026-09-01 00:00:00', RainCalculator::dateBegin($this->now('2026-09-15 14:32'))->format('Y-m-d H:i:s'));
self::assertSame('2026-09-29 00:00:00', RainCalculator::dateBegin($this->now('2026-10-02 09:00'))->format('Y-m-d H:i:s'));
self::assertSame('Europe/Berlin', RainCalculator::dateBegin($this->now('2026-10-02 09:00'))->getTimezone()->getName());
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"body": {
"1792621800": [
1
],
"1792708200": [
2
],
"1792794600": [
3
],
"1792884600": [
4
],
"1792971000": [
9
]
},
"status": "ok"
}
+20
View File
@@ -0,0 +1,20 @@
{
"body": {
"1790558220": [
1
],
"1790644620": [
2
],
"1790731020": [
3
],
"1790817420": [
4
],
"1790903820": [
5
]
},
"status": "ok"
}
+40
View File
@@ -0,0 +1,40 @@
{
"body": {
"1788225420": [
4.3
],
"1788311820": [
0
],
"1788398220": [
3.2
],
"1788484620": [
5.1
],
"1788571020": [
0
],
"1788657420": [
0
],
"1788743820": [
15.5
],
"1788830220": [
1.2
],
"1788916620": [
6.8
],
"1789003020": [
0
],
"1789089420": [
2.4
]
},
"status": "ok",
"time_exec": 0.02,
"time_server": 1789129925
}