From d657fd069f9131e6040d6c7aa2e619d7f5fc199f Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:37:06 +0200 Subject: [PATCH] feat: RainCalculator with timezone-aware day mapping and month total Co-Authored-By: Claude Fable 5.1 --- .phpunit.result.cache | 2 +- src/RainCalculator.php | 65 ++++++++++++++++ tests/RainCalculatorTest.php | 106 +++++++++++++++++++++++++++ tests/fixtures/getmeasure-dst.json | 20 +++++ tests/fixtures/getmeasure-month.json | 20 +++++ tests/fixtures/getmeasure.json | 40 ++++++++++ 6 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 src/RainCalculator.php create mode 100644 tests/RainCalculatorTest.php create mode 100644 tests/fixtures/getmeasure-dst.json create mode 100644 tests/fixtures/getmeasure-month.json create mode 100644 tests/fixtures/getmeasure.json diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 0db9609..fa3bd28 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -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}} \ No newline at end of file +{"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}} \ No newline at end of file diff --git a/src/RainCalculator.php b/src/RainCalculator.php new file mode 100644 index 0000000..3846f52 --- /dev/null +++ b/src/RainCalculator.php @@ -0,0 +1,65 @@ +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 $body + * @return array{today: ?float, previous_days: list, 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, + ]; + } +} diff --git a/tests/RainCalculatorTest.php b/tests/RainCalculatorTest.php new file mode 100644 index 0000000..10f48da --- /dev/null +++ b/tests/RainCalculatorTest.php @@ -0,0 +1,106 @@ +tz = new \DateTimeZone('Europe/Berlin'); + } + + /** @return array */ + 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()); + } +} diff --git a/tests/fixtures/getmeasure-dst.json b/tests/fixtures/getmeasure-dst.json new file mode 100644 index 0000000..c7d85e1 --- /dev/null +++ b/tests/fixtures/getmeasure-dst.json @@ -0,0 +1,20 @@ +{ + "body": { + "1792621800": [ + 1 + ], + "1792708200": [ + 2 + ], + "1792794600": [ + 3 + ], + "1792884600": [ + 4 + ], + "1792971000": [ + 9 + ] + }, + "status": "ok" +} diff --git a/tests/fixtures/getmeasure-month.json b/tests/fixtures/getmeasure-month.json new file mode 100644 index 0000000..48994d6 --- /dev/null +++ b/tests/fixtures/getmeasure-month.json @@ -0,0 +1,20 @@ +{ + "body": { + "1790558220": [ + 1 + ], + "1790644620": [ + 2 + ], + "1790731020": [ + 3 + ], + "1790817420": [ + 4 + ], + "1790903820": [ + 5 + ] + }, + "status": "ok" +} diff --git a/tests/fixtures/getmeasure.json b/tests/fixtures/getmeasure.json new file mode 100644 index 0000000..41e14b9 --- /dev/null +++ b/tests/fixtures/getmeasure.json @@ -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 +}