From 4fd6d38187da0d5643f74b6c7c9d351e3d74ddd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 11 Jun 2021 15:53:32 +0200 Subject: [PATCH] create Util for computing intersection --- .../Tests/Util/DateRangeCoveringTest.php | 216 +++++++++++++++++ .../Util/DateRangeCovering.php | 224 ++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 src/Bundle/ChillMainBundle/Tests/Util/DateRangeCoveringTest.php create mode 100644 src/Bundle/ChillMainBundle/Util/DateRangeCovering.php diff --git a/src/Bundle/ChillMainBundle/Tests/Util/DateRangeCoveringTest.php b/src/Bundle/ChillMainBundle/Tests/Util/DateRangeCoveringTest.php new file mode 100644 index 000000000..c06b4a4f4 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Util/DateRangeCoveringTest.php @@ -0,0 +1,216 @@ +add(new \DateTime('2010-01-01'), new \DateTime('2010-12-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2011-06-01'), 2) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-06-01'), 3) + ->compute() + ; + + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(1, $cover->getIntersections()); + $this->assertEquals( + new \DateTime('2010-06-01'), + $cover->getIntersections()[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-12-01'), + $cover->getIntersections()[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($cover->getIntersections()[0][2]); + $this->assertContains(1, $cover->getIntersections()[0][2]); + $this->assertContains(2, $cover->getIntersections()[0][2]); + $this->assertNotContains(3, $cover->getIntersections()[0][2]); + } + + public function testCoveringWithMinCover1WithTwoIntersections() + { + $cover = new DateRangeCovering(1, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-12-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2011-06-01'), 2) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-12-01'), 3) + ->add(new \DateTime('2019-06-01'), new \DateTime('2020-06-01'), 4) + ->compute() + ; + + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(2, $cover->getIntersections()); + + $intersections = $cover->getIntersections(); + + // sort the intersections to compare them in expected order + \usort($intersections, function($a, $b) { + if ($a[0] === $b[0]) { + return $a[1] <=> $b[1]; + } + + return $a[0] <=> $b[0]; + }); + + // first intersection + $this->assertEquals( + new \DateTime('2010-06-01'), + $intersections[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-12-01'), + $intersections[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($intersections[0][2]); + $this->assertContains(1, $intersections[0][2]); + $this->assertContains(2, $intersections[0][2]); + $this->assertNotContains(3, $intersections[0][2]); + $this->assertNotContains(4, $intersections[0][2]); + + // second intersection + $this->assertEquals( + new \DateTime('2019-06-01'), + $intersections[1][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2019-12-01'), + $intersections[1][1], + "assert date end are the intersection" + ); + $this->assertIsArray($intersections[1][2]); + $this->assertContains(3, $intersections[1][2]); + $this->assertContains(4, $intersections[1][2]); + $this->assertNotContains(1, $intersections[1][2]); + $this->assertNotContains(2, $intersections[1][2]); + } + + public function testCoveringWithMinCover2() + { + $cover = new DateRangeCovering(2, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), 2) + ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), 3) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), 4) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), 5) + ->compute() + ; + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(1, $cover->getIntersections()); + + $this->assertEquals( + new \DateTime('2010-06-01'), + $cover->getIntersections()[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-09-01'), + $cover->getIntersections()[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($cover->getIntersections()[0][2]); + $this->assertContains(1, $cover->getIntersections()[0][2]); + $this->assertContains(2, $cover->getIntersections()[0][2]); + $this->assertContains(3, $cover->getIntersections()[0][2]); + $this->assertNotContains(4, $cover->getIntersections()[0][2]); + $this->assertNotContains(5, $cover->getIntersections()[0][2]); + } + + public function testCoveringWithMinCover2AndThreePeriodsCovering() + { + $cover = new DateRangeCovering(2, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), 2) + ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), 3) + ->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), 4) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), 5) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), 6) + ->compute() + ; + + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(1, $cover->getIntersections()); + + $this->assertEquals( + new \DateTime('2010-04-01'), + $cover->getIntersections()[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-09-15'), + $cover->getIntersections()[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($cover->getIntersections()[0][2]); + $this->assertContains(1, $cover->getIntersections()[0][2]); + $this->assertContains(2, $cover->getIntersections()[0][2]); + $this->assertContains(3, $cover->getIntersections()[0][2]); + $this->assertContains(4, $cover->getIntersections()[0][2]); + $this->assertNotContains(5, $cover->getIntersections()[0][2]); + $this->assertNotContains(6, $cover->getIntersections()[0][2]); + } + + public function testCoveringWithMinCover2AndThreePeriodsCoveringWithNullMetadata() + { + $cover = new DateRangeCovering(2, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), null) + ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), null) + ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), null) + ->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), null) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), null) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), null) + ->compute() + ; + + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(1, $cover->getIntersections()); + + $this->assertEquals( + new \DateTime('2010-04-01'), + $cover->getIntersections()[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-09-15'), + $cover->getIntersections()[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($cover->getIntersections()[0][2]); + } + + + public function testCoveringWithMinCover3Absent() + { + $cover = new DateRangeCovering(3, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), 2) + ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), 3) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), 4) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), 5) + ->compute() + ; + $this->assertFalse($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(0, $cover->getIntersections()); + } +} diff --git a/src/Bundle/ChillMainBundle/Util/DateRangeCovering.php b/src/Bundle/ChillMainBundle/Util/DateRangeCovering.php new file mode 100644 index 000000000..2b168f18b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Util/DateRangeCovering.php @@ -0,0 +1,224 @@ +add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), null) + * ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), null) + * ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), null) + * ->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), null) + * ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), null) + * ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), null) + * ->compute() + * ; + * $cover->getIntersections(); + * ``` + */ +class DateRangeCovering +{ + private bool $computed = false; + + private array $intersections = []; + + private array $intervals = []; + + private int $minCover; + + private int $uniqueKeyCounter = 0; + + private array $metadatas = []; + + private array $sequence = []; + + private \DateTimeZone $tz; + + /** + * @param int $minCover the minimum of covering required + */ + public function __construct(int $minCover, \DateTimeZone $tz) + { + if ($minCover < 0) { + throw new \LogicException("argument minCover cannot be lower than 0"); + } + + $this->minCover = $minCover; + $this->tz = $tz; + } + + public function add(\DateTimeInterface $start, \DateTimeInterface $end = null, $metadata = null): self + { + if ($this->computed) { + throw new \LogicException("You cannot add intervals to a computed instance"); + } + + $k = $this->uniqueKeyCounter++; + $this->intervals[$k] = [$start, $end]; + $this->metadatas[$k] = $metadata; + + $this->addToSequence($start->getTimestamp(), $k, null); + $this->addToSequence( + NULL === $end ? PHP_INT_MAX : $end->getTimestamp(), null, $k + ); + + return $this; + } + + private function addToSequence($timestamp, int $start = null, int $end = null) + { + if (!\array_key_exists($timestamp, $this->sequence)) { + $this->sequence[$timestamp] = [ 's' => [], 'e' => [] ]; + } + + if (NULL !== $start) { + $this->sequence[$timestamp]['s'][] = $start; + } + if (NULL !== $end) { + $this->sequence[$timestamp]['e'][] = $end; + } + } + + public function compute(): self + { + \ksort($this->sequence); + + $currentPeriod = []; + $currents = []; + $isOpen = false; + $overs = []; + + foreach ($this->sequence as $ts => $moves) { + $currents = \array_merge($currents, $moves['s']); + $currents = \array_diff($currents, $moves['e']); + + if (count($currents) > $this->minCover && !$isOpen) { + $currentPeriod[0] = $ts; + $currentPeriod[2] = $currents; + $isOpen = true; + } elseif ($isOpen && count($currents) <= $this->minCover) { + $currentPeriod[1] = $ts; + $overs[] = $currentPeriod; + $currentPeriod = []; + $isOpen = false; + } elseif ($isOpen) { + $currentPeriod[2] = \array_merge($currentPeriod[2], $currents); + } + } + + // process metadata + foreach ($overs as list($start, $end, $metadata)) { + $this->intersections[] = [ + (new \DateTimeImmutable('@'.$start)) + ->setTimezone($this->tz), + $end === PHP_INT_MAX ? null : (new \DateTimeImmutable('@'.$end)) + ->setTimezone($this->tz), + \array_values( + \array_intersect_key( + $this->metadatas, + \array_flip(\array_unique($metadata)) + ) + ) + ]; + } + + $this->computed = true; + + return $this; + } + + private function process(array $intersections): array + { + $result = []; + $starts = []; + $ends = []; + $metadatas = []; + + while (null !== ($current = \array_pop($intersections))) { + list($cStart, $cEnd, $cMetadata) = $current; + $n = count($cMetadata); + + foreach ($intersections as list($iStart, $iEnd, $iMetadata)) { + $start = max($cStart, $iStart); + $end = min($cEnd, $iEnd); + + if ($start <= $end) { + if (FALSE !== ($key = \array_search($start, $starts))) { + if ($ends[$key] === $end) { + $metadatas[$key] = \array_unique(\array_merge($metadatas[$key], $iMetadata)); + continue; + } + } + $starts[] = $start; + $ends[] = $end; + $metadatas[] = \array_unique(\array_merge($iMetadata, $cMetadata)); + } + } + } + + // recompose results + foreach ($starts as $k => $start) { + $result[] = [$start, $ends[$k], \array_unique($metadatas[$k])]; + } + + return $result; + } + + private function addToIntersections(array $intersections, array $intersection) + { + $foundExisting = false; + list($nStart, $nEnd, $nMetadata) = $intersection; + + \array_walk($intersections, + function(&$i, $key) use ($nStart, $nEnd, $nMetadata, $foundExisting) { + if ($foundExisting) { + return; + }; + if ($i[0] === $nStart && $i[1] === $nEnd) { + $foundExisting = true; + $i[2] = \array_merge($i[2], $nMetadata); + } + } + ); + + if (!$foundExisting) { + $intersections[] = $intersection; + } + + return $intersections; + } + + public function hasIntersections(): bool + { + if (!$this->computed) { + throw new \LogicException(sprintf("You cannot call the method %s before ". + "'process'", __METHOD)); + } + + return count($this->intersections) > 0; + } + + public function getIntersections(): array + { + if (!$this->computed) { + throw new \LogicException(sprintf("You cannot call the method %s before ". + "'process'", __METHOD)); + } + + return $this->intersections; + } + +}