diff --git a/CHANGELOG.md b/CHANGELOG.md index aa5cc3d..4b48da3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## v3.1.0 + +### Added + +- Add ability to have sectioned microplates to group wells + ## v3.0.0 ### Added diff --git a/src/AbstractMicroplate.php b/src/AbstractMicroplate.php new file mode 100644 index 0000000..d4ae991 --- /dev/null +++ b/src/AbstractMicroplate.php @@ -0,0 +1,107 @@ + + */ +abstract class AbstractMicroplate +{ + public const EMPTY_WELL = null; + + /** + * @var TCoordinateSystem + */ + public CoordinateSystem $coordinateSystem; + + /** + * @param TCoordinateSystem $coordinateSystem + */ + public function __construct(CoordinateSystem $coordinateSystem) + { + $this->coordinateSystem = $coordinateSystem; + } + + /** + * @return WellsCollection + */ + abstract public function wells(): Collection; + + /** + * @param Coordinate $coordinate + * + * @return TWell|null + */ + public function well(Coordinate $coordinate) + { + return $this->wells()[$coordinate->toString()]; + } + + /** + * @param Coordinate $coordinate + */ + public function isWellEmpty(Coordinate $coordinate): bool + { + return self::EMPTY_WELL === $this->well($coordinate); + } + + /** + * @return WellsCollection + */ + public function sortedWells(FlowDirection $flowDirection): Collection + { + return $this->wells()->sortBy( + /** + * @param TWell $value + */ + function ($value, string $key) use ($flowDirection): string { + switch ($flowDirection->getValue()) { + case FlowDirection::ROW: + return $key; + case FlowDirection::COLUMN: + $coordinate = Coordinate::fromString($key, $this->coordinateSystem); + + return $coordinate->column . $coordinate->row; + // @codeCoverageIgnoreStart all Enums are listed and this should never happen + default: + throw new UnexpectedFlowDirection($flowDirection); + // @codeCoverageIgnoreEnd + } + }, + SORT_NATURAL + ); + } + + /** + * @return Collection + */ + public function freeWells(): Collection + { + return $this->wells()->filter( + /** + * @param TWell $value + */ + static fn ($value): bool => self::EMPTY_WELL === $value + ); + } + + /** + * @return Collection + */ + public function filledWells(): Collection + { + return $this->wells()->filter( + /** + * @param TWell $value + */ + static fn ($value): bool => self::EMPTY_WELL !== $value + ); + } +} diff --git a/src/AbstractSection.php b/src/AbstractSection.php new file mode 100644 index 0000000..e80b7a9 --- /dev/null +++ b/src/AbstractSection.php @@ -0,0 +1,35 @@ + + */ + public SectionedMicroplate $sectionedMicroplate; + + /** + * @var Collection + */ + public Collection $sectionItems; + + /** + * @param SectionedMicroplate $sectionedMicroplate + */ + public function __construct(SectionedMicroplate $sectionedMicroplate) + { + $this->sectionedMicroplate = $sectionedMicroplate; + $this->sectionItems = new Collection(); + } + + /** + * @param TSectionWell $content + */ + abstract public function addWell($content): void; +} diff --git a/src/Coordinate.php b/src/Coordinate.php index 46e1b4a..c625092 100644 --- a/src/Coordinate.php +++ b/src/Coordinate.php @@ -136,9 +136,8 @@ public function position(FlowDirection $direction): int private static function assertPositionInRange(CoordinateSystem $coordinateSystem, int $position): void { - $maxPosition = count($coordinateSystem->columns()) * count($coordinateSystem->rows()); - if (! in_array($position, range(self::MIN_POSITION, $maxPosition), true)) { - throw new InvalidArgumentException("Expected a position between 1-{$maxPosition}, got: {$position}."); + if (! in_array($position, range(self::MIN_POSITION, $coordinateSystem->positionsCount()), true)) { + throw new InvalidArgumentException("Expected a position between 1-{$coordinateSystem->positionsCount()}, got: {$position}."); } } } diff --git a/src/CoordinateSystem.php b/src/CoordinateSystem.php index 565d802..b74a2f5 100644 --- a/src/CoordinateSystem.php +++ b/src/CoordinateSystem.php @@ -16,21 +16,48 @@ abstract public function columns(): array; public function rowForRowFlowPosition(int $position): string { - return $this->rows()[floor(($position - 1) / count($this->columns()))]; + return $this->rows()[floor(($position - 1) / $this->columnsCount())]; } public function rowForColumnFlowPosition(int $position): string { - return $this->rows()[($position - 1) % count($this->rows())]; + return $this->rows()[($position - 1) % $this->rowsCount()]; } public function columnForRowFlowPosition(int $position): int { - return $this->columns()[($position - 1) % count($this->columns())]; + return $this->columns()[($position - 1) % $this->columnsCount()]; } public function columnForColumnFlowPosition(int $position): int { - return $this->columns()[floor(($position - 1) / count($this->rows()))]; + return $this->columns()[floor(($position - 1) / $this->rowsCount())]; + } + + public function positionsCount(): int + { + return $this->columnsCount() * $this->rowsCount(); + } + + /** + * @return iterable + */ + public function all(): iterable + { + foreach ($this->columns() as $column) { + foreach ($this->rows() as $row) { + yield new Coordinate($row, $column, $this); + } + } + } + + public function rowsCount(): int + { + return count($this->rows()); + } + + public function columnsCount(): int + { + return count($this->columns()); } } diff --git a/src/Exceptions/MicroplateIsFullException.php b/src/Exceptions/MicroplateIsFullException.php index d3166ee..861db40 100644 --- a/src/Exceptions/MicroplateIsFullException.php +++ b/src/Exceptions/MicroplateIsFullException.php @@ -2,6 +2,10 @@ namespace Mll\Microplate\Exceptions; -class MicroplateIsFullException extends \Exception +class MicroplateIsFullException extends \UnexpectedValueException { + public function __construct() + { + parent::__construct('No free spots left on plate'); + } } diff --git a/src/Exceptions/SectionDoesNotExistException.php b/src/Exceptions/SectionDoesNotExistException.php new file mode 100644 index 0000000..ffd452d --- /dev/null +++ b/src/Exceptions/SectionDoesNotExistException.php @@ -0,0 +1,7 @@ +growSection(); + } + + /** + * @param TSectionWell $content + * + * @throws MicroplateIsFullException + * @throws SectionIsFullException + */ + public function addWell($content): void + { + if ($this->sectionedMicroplate->freeWells()->isEmpty()) { + throw new MicroplateIsFullException(); + } + + $nextReservedWell = $this->nextReservedWell(); + if (false !== $nextReservedWell) { + $this->sectionItems[$nextReservedWell] = $content; + + return; + } + + $this->growSection(); + + /** @var int $nextReservedWell Guaranteed to be found after we grew the section */ + $nextReservedWell = $this->nextReservedWell(); + $this->sectionItems[$nextReservedWell] = $content; + } + + /** + * Grows the section by initializing a new column with empty wells. + * + * @throws SectionIsFullException + */ + private function growSection(): void + { + if (! $this->sectionCanGrow()) { + throw new SectionIsFullException(); + } + + foreach ($this->sectionedMicroplate->coordinateSystem->rows() as $row) { + $this->sectionItems->push(AbstractMicroplate::EMPTY_WELL); + } + } + + /** + * @return false|int + */ + private function nextReservedWell() + { + return $this->sectionItems->search(AbstractMicroplate::EMPTY_WELL); + } + + private function sectionCanGrow(): bool + { + $totalReservedColumns = $this->sectionedMicroplate->sections->sum(fn (self $section) => $section->reservedColumns()); + $availableColumns = $this->sectionedMicroplate->coordinateSystem->columnsCount(); + + return $totalReservedColumns < $availableColumns; + } + + private function reservedColumns(): int + { + return (int) ceil($this->sectionItems->count() / $this->sectionedMicroplate->coordinateSystem->rowsCount()); + } +} diff --git a/src/Microplate.php b/src/Microplate.php index 4c650af..4db2f07 100644 --- a/src/Microplate.php +++ b/src/Microplate.php @@ -5,43 +5,42 @@ use Illuminate\Support\Collection; use Mll\Microplate\Enums\FlowDirection; use Mll\Microplate\Exceptions\MicroplateIsFullException; -use Mll\Microplate\Exceptions\UnexpectedFlowDirection; use Mll\Microplate\Exceptions\WellNotEmptyException; /** * @template TWell * @template TCoordinateSystem of CoordinateSystem * + * @phpstan-extends AbstractMicroplate + * * @phpstan-type WellsCollection Collection */ -class Microplate +class Microplate extends AbstractMicroplate { - public const EMPTY_WELL = null; - - /** - * @var TCoordinateSystem - */ - public CoordinateSystem $coordinateSystem; - /** * @var WellsCollection */ - public Collection $wells; + protected Collection $wells; /** * @param TCoordinateSystem $coordinateSystem */ public function __construct(CoordinateSystem $coordinateSystem) { - $this->coordinateSystem = $coordinateSystem; + parent::__construct($coordinateSystem); $this->clearWells(); } + public function wells(): Collection + { + return $this->wells; + } + /** * @param Coordinate $coordinate */ - public function position(Coordinate $coordinate, FlowDirection $direction): int + public static function position(Coordinate $coordinate, FlowDirection $direction): int { return $coordinate->position($direction); } @@ -59,28 +58,29 @@ public function addWell(Coordinate $coordinate, $content): void } /** - * @param Coordinate $coordinate + * Set the well at the given coordinate to the given content. * - * @return TWell|null + * @param Coordinate $coordinate + * @param TWell $content */ - public function well(Coordinate $coordinate) + public function setWell(Coordinate $coordinate, $content): void { - return $this->wells[$coordinate->toString()]; + $this->wells[$coordinate->toString()] = $content; } /** + * @param Coordinate $coordinate * @param TWell $content * - * @throws MicroplateIsFullException - * - * @return Coordinate + * @throws WellNotEmptyException */ - public function addToNextFreeWell($content, FlowDirection $flowDirection): Coordinate + private function assertIsWellEmpty(Coordinate $coordinate, $content): void { - $coordinate = $this->nextFreeWellCoordinate($flowDirection); - $this->setWell($coordinate, $content); - - return $coordinate; + if (! $this->isWellEmpty($coordinate)) { + throw new WellNotEmptyException( + 'Well with coordinate "' . $coordinate->toString() . '" is not empty. Use setWell() to overwrite the coordinate. Well content "' . serialize($content) . '" was not added.' + ); + } } /** @@ -93,83 +93,24 @@ public function clearWells(): void * a plate but rather a property of the access to the plate. */ $this->wells = new Collection(); - foreach ($this->coordinateSystem->columns() as $column) { - foreach ($this->coordinateSystem->rows() as $row) { - $this->wells[$row . $column] = self::EMPTY_WELL; - } + foreach ($this->coordinateSystem->all() as $coordinate) { + $this->wells[$coordinate->toString()] = self::EMPTY_WELL; } } /** - * @param Coordinate $coordinate - */ - public function isWellEmpty(Coordinate $coordinate): bool - { - return self::EMPTY_WELL === $this->well($coordinate); - } - - /** - * Set the well at the given coordinate to the given content. - * - * @param Coordinate $coordinate * @param TWell $content + * + * @throws MicroplateIsFullException + * + * @return Coordinate */ - public function setWell(Coordinate $coordinate, $content): void + public function addToNextFreeWell($content, FlowDirection $flowDirection): Coordinate { + $coordinate = $this->nextFreeWellCoordinate($flowDirection); $this->wells[$coordinate->toString()] = $content; - } - - /** - * @return WellsCollection - */ - public function sortedWells(FlowDirection $flowDirection): Collection - { - return $this->wells->sortBy( - /** - * @param TWell $value - */ - function ($value, string $key) use ($flowDirection): string { - switch ($flowDirection->getValue()) { - case FlowDirection::ROW: - return $key; - case FlowDirection::COLUMN: - $coordinate = Coordinate::fromString($key, $this->coordinateSystem); - - return $coordinate->column . $coordinate->row; - // @codeCoverageIgnoreStart all Enums are listed and this should never happen - default: - throw new UnexpectedFlowDirection($flowDirection); - // @codeCoverageIgnoreEnd - } - }, - SORT_NATURAL - ); - } - - /** - * @return Collection - */ - public function freeWells(): Collection - { - return $this->wells->filter( - /** - * @param TWell $value - */ - static fn ($value): bool => self::EMPTY_WELL === $value - ); - } - /** - * @return Collection - */ - public function filledWells(): Collection - { - return $this->wells->filter( - /** - * @param TWell $value - */ - static fn ($value): bool => self::EMPTY_WELL !== $value - ); + return $coordinate; } /** @@ -183,24 +124,9 @@ public function nextFreeWellCoordinate(FlowDirection $flowDirection): Coordinate ->search(self::EMPTY_WELL); if (! is_string($coordinateString)) { - throw new MicroplateIsFullException('No free spots left on plate'); + throw new MicroplateIsFullException(); } return Coordinate::fromString($coordinateString, $this->coordinateSystem); } - - /** - * @param Coordinate $coordinate - * @param TWell $content - * - * @throws WellNotEmptyException - */ - private function assertIsWellEmpty(Coordinate $coordinate, $content): void - { - if (! $this->isWellEmpty($coordinate)) { - throw new WellNotEmptyException( - 'Well with coordinate "' . $coordinate->toString() . '" is not empty. Use setWell() to overwrite the coordinate. Well content "' . serialize($content) . '" was not added.' - ); - } - } } diff --git a/src/Section.php b/src/Section.php new file mode 100644 index 0000000..8adc7a7 --- /dev/null +++ b/src/Section.php @@ -0,0 +1,25 @@ +sectionedMicroplate->freeWells()->isEmpty()) { + throw new MicroplateIsFullException(); + } + + $this->sectionItems->push($content); + } +} diff --git a/src/SectionedMicroplate.php b/src/SectionedMicroplate.php new file mode 100644 index 0000000..33d9765 --- /dev/null +++ b/src/SectionedMicroplate.php @@ -0,0 +1,76 @@ + + */ +class SectionedMicroplate extends AbstractMicroplate +{ + /** + * @var Collection + */ + public Collection $sections; + + /** + * @param TCoordinateSystem $coordinateSystem + */ + public function __construct(CoordinateSystem $coordinateSystem) + { + parent::__construct($coordinateSystem); + + $this->clearSections(); + } + + /** + * @param class-string $sectionClass + */ + public function addSection(string $sectionClass): AbstractSection + { + return $this->sections[] = new $sectionClass($this); + } + + /** + * @param TSection $section + */ + public function removeSection(AbstractSection $section): void + { + foreach ($this->sections as $i => $s) { + if ($s === $section) { + unset($this->sections[$i]); + } + } + } + + public function wells(): Collection + { + /** + * @var Collection + */ + $zipped = $this->sections + ->map(fn (AbstractSection $section) => $section->sectionItems) + ->flatten(1) + ->values() + ->zip($this->coordinateSystem->all()) + ->map(fn (Collection $mapping) => $mapping->all()); + + return $zipped->mapWithKeys(function (array $mapping): array { + [$sectionItem, $coordinate] = $mapping; + + return [$coordinate->toString() => $sectionItem]; + }); + } + + public function clearSections(): void + { + $this->sections = new Collection(); + } +} diff --git a/tests/Unit/MicroplateTest.php b/tests/Unit/MicroplateTest.php index 68b20a4..6f2e088 100644 --- a/tests/Unit/MicroplateTest.php +++ b/tests/Unit/MicroplateTest.php @@ -64,7 +64,7 @@ public function testFreeWells(): void $microplate = $this->preparePlate(); self::assertTrue( - $microplate->wells->some( + $microplate->wells()->some( /** * @param mixed|null $value */ @@ -165,55 +165,4 @@ public static function dataProvider12Well(): array { return CoordinateTest::dataProvider12Well(); } - - /** - * @dataProvider dataProvider96Well - */ - public function testPosition96Well(string $row, int $column, int $rowFlowPosition, int $columnFlowPosition): void - { - $coordinateSystem = new CoordinateSystem96Well(); - $coordinate = new Coordinate($row, $column, $coordinateSystem); - - $microplate = new Microplate(new CoordinateSystem96Well()); - self::assertSame( - $columnFlowPosition, - $microplate->position( - $coordinate, - FlowDirection::COLUMN(), - ) - ); - - self::assertSame( - $rowFlowPosition, - $microplate->position( - $coordinate, - FlowDirection::ROW(), - ) - ); - } - - /** - * @dataProvider dataProvider12Well - */ - public function testPosition12Well(string $row, int $column, int $rowFlowPosition, int $columnFlowPosition): void - { - $coordinate = new Coordinate($row, $column, new CoordinateSystem12Well()); - - $microplate = new Microplate(new CoordinateSystem12Well()); - self::assertSame( - $columnFlowPosition, - $microplate->position( - $coordinate, - FlowDirection::COLUMN() - ) - ); - - self::assertSame( - $rowFlowPosition, - $microplate->position( - $coordinate, - FlowDirection::ROW(), - ) - ); - } } diff --git a/tests/Unit/SectionMicroplate/FullColumnSectionTest.php b/tests/Unit/SectionMicroplate/FullColumnSectionTest.php new file mode 100644 index 0000000..db3a23b --- /dev/null +++ b/tests/Unit/SectionMicroplate/FullColumnSectionTest.php @@ -0,0 +1,140 @@ +sections); + + $section = $sectionedMicroplate->addSection(FullColumnSection::class); + self::assertCount(1, $sectionedMicroplate->sections); + self::assertCount(96, $sectionedMicroplate->freeWells()); + + foreach ($coordinateSystem->all() as $i => $coordinate) { + $section->addWell('column' . $i); + self::assertCount($i + 1, $sectionedMicroplate->filledWells()); + } + + self::assertCount(0, $sectionedMicroplate->freeWells()); + $this->expectExceptionObject(new MicroplateIsFullException()); + + $section->addWell('foo'); + } + + public function testCanNotAddFullColumnSectionIfAllColumnsAreReserved(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + $sectionedMicroplate = new SectionedMicroplate($coordinateSystem); + + foreach (range(1, $coordinateSystem->columnsCount()) as $i) { + $sectionedMicroplate->addSection(FullColumnSection::class); + } + + $this->expectExceptionObject(new SectionIsFullException()); + $sectionedMicroplate->addSection(FullColumnSection::class); + } + + public function testCanNotGrowFullColumnSectionIfNoColumnsAreLeft(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + $sectionedMicroplate = new SectionedMicroplate($coordinateSystem); + + foreach (range(1, $coordinateSystem->columnsCount() - 1) as $i) { + $sectionedMicroplate->addSection(FullColumnSection::class); + } + + $lastSection = $sectionedMicroplate->addSection(FullColumnSection::class); + foreach (range(1, $coordinateSystem->rowsCount()) as $i) { + $lastSection->addWell('foo'); + } + + $this->expectExceptionObject(new SectionIsFullException()); + $lastSection->addWell('bar'); + } + + public function testFullColumnSection(): void + { + $coordinateSystem = new CoordinateSystem96Well(); + $sectionedMicroplate = new SectionedMicroplate($coordinateSystem); + self::assertCount(0, $sectionedMicroplate->sections); + + $section1 = $sectionedMicroplate->addSection(FullColumnSection::class); + self::assertCount(1, $sectionedMicroplate->sections); + self::assertCount(96, $sectionedMicroplate->freeWells()); + + foreach (range(1, 4) as $ignored1) { + $section1->addWell('section1'); + } + + $section2 = $sectionedMicroplate->addSection(FullColumnSection::class); + $emptyCoordinateInSection1 = new Coordinate('E', 1, $coordinateSystem); + self::assertNull($sectionedMicroplate->well($emptyCoordinateInSection1)); + + foreach (range(1, 5) as $ignored1) { + $section2->addWell('section2'); + } + self::assertNull($sectionedMicroplate->well($emptyCoordinateInSection1)); + + self::assertSame([ + 'A1' => 'section1', + 'B1' => 'section1', + 'C1' => 'section1', + 'D1' => 'section1', + 'A2' => 'section2', + 'B2' => 'section2', + 'C2' => 'section2', + 'D2' => 'section2', + 'E2' => 'section2', + ], $sectionedMicroplate->filledWells()->toArray()); + + foreach (range(1, 16) as $ignored1) { + $section1->addWell('section1'); + } + + self::assertSame([ + 'A1' => 'section1', + 'B1' => 'section1', + 'C1' => 'section1', + 'D1' => 'section1', + 'E1' => 'section1', + 'F1' => 'section1', + 'G1' => 'section1', + 'H1' => 'section1', + 'A2' => 'section1', + 'B2' => 'section1', + 'C2' => 'section1', + 'D2' => 'section1', + 'E2' => 'section1', + 'F2' => 'section1', + 'G2' => 'section1', + 'H2' => 'section1', + 'A3' => 'section1', + 'B3' => 'section1', + 'C3' => 'section1', + 'D3' => 'section1', + 'A4' => 'section2', + 'B4' => 'section2', + 'C4' => 'section2', + 'D4' => 'section2', + 'E4' => 'section2', + ], $sectionedMicroplate->filledWells()->toArray()); + + $this->expectExceptionObject(new SectionIsFullException()); + + foreach (range(1, 100) as $ignored1) { + $section1->addWell('section1'); + } + } +} diff --git a/tests/Unit/SectionMicroplate/SectionTest.php b/tests/Unit/SectionMicroplate/SectionTest.php new file mode 100644 index 0000000..15e0960 --- /dev/null +++ b/tests/Unit/SectionMicroplate/SectionTest.php @@ -0,0 +1,33 @@ +sections); + + $section = $sectionedMicroplate->addSection(Section::class); + self::assertCount(1, $sectionedMicroplate->sections); + self::assertCount(96, $sectionedMicroplate->freeWells()); + + foreach ($coordinateSystem->all() as $i => $coordinate) { + $section->addWell('column' . $i); + self::assertCount($i + 1, $sectionedMicroplate->filledWells()); + } + + self::assertCount(0, $sectionedMicroplate->freeWells()); + $this->expectExceptionObject(new MicroplateIsFullException()); + + $section->addWell('foo'); + } +} diff --git a/tests/Unit/SectionMicroplate/SectionedMicroplateTest.php b/tests/Unit/SectionMicroplate/SectionedMicroplateTest.php new file mode 100644 index 0000000..0a7eb7c --- /dev/null +++ b/tests/Unit/SectionMicroplate/SectionedMicroplateTest.php @@ -0,0 +1,48 @@ +sections); + + $section1 = $sectionedMicroplate->addSection(Section::class); + self::assertCount(1, $sectionedMicroplate->sections); + + $section2 = $sectionedMicroplate->addSection(Section::class); + self::assertCount(2, $sectionedMicroplate->sections); + + self::assertCount(0, $sectionedMicroplate->filledWells()); + self::assertCount(96, $sectionedMicroplate->freeWells()); + + $content1 = 'content1'; + $section1->addWell($content1); + $content2 = 'content2'; + $content3 = 'content3'; + $section2->addWell($content2); + $section2->addWell($content3); + + self::assertCount(3, $sectionedMicroplate->filledWells()); + self::assertCount(93, $sectionedMicroplate->freeWells()); + + self::assertSame($content1, $section1->sectionItems->first()); + + self::assertSame($content2, $section2->sectionItems->first()); + self::assertSame($content3, $section2->sectionItems->last()); + + $sectionedMicroplate->removeSection($section1); + self::assertCount(1, $sectionedMicroplate->sections); + + self::assertCount(2, $sectionedMicroplate->filledWells()); + self::assertCount(94, $sectionedMicroplate->freeWells()); + } +}