mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-06-07 18:44:08 +00:00
create Util for computing intersection
This commit is contained in:
parent
cbadcb4980
commit
4fd6d38187
216
src/Bundle/ChillMainBundle/Tests/Util/DateRangeCoveringTest.php
Normal file
216
src/Bundle/ChillMainBundle/Tests/Util/DateRangeCoveringTest.php
Normal file
@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace Chill\MainBundle\Tests\Util;
|
||||
|
||||
use Chill\MainBundle\Util\DateRangeCovering;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DateRangeCoveringTest extends TestCase
|
||||
{
|
||||
|
||||
public function testCoveringWithMinCover1()
|
||||
{
|
||||
$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-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());
|
||||
}
|
||||
}
|
224
src/Bundle/ChillMainBundle/Util/DateRangeCovering.php
Normal file
224
src/Bundle/ChillMainBundle/Util/DateRangeCovering.php
Normal file
@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
namespace Chill\MainBundle\Util;
|
||||
|
||||
/**
|
||||
* Utilities to compare date periods
|
||||
*
|
||||
* This class allow to compare periods when there are period covering. The
|
||||
* argument `minCovers` allow to find also when there are more than 2 period
|
||||
* which intersects.
|
||||
*
|
||||
* Example: a team may have maximum 2 leaders on a same period: you will
|
||||
* find here all periods where there are more than 2 leaders.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* ```php
|
||||
* $cover = new DateRangeCovering(2); // 2 means we will have periods
|
||||
* // when there are 2+ periods intersecting
|
||||
* $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()
|
||||
* ;
|
||||
* $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;
|
||||
}
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user