From 57fccb68b5564bce605d44328cd97b4c8e2c0544 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr <simon.bigelmayr@mll.com> Date: Wed, 18 Sep 2024 14:32:27 +0200 Subject: [PATCH] feat: Add `Mix`-command to tecan worklist --- phpunit.xml | 3 + src/Tecan/AdvancedCommands/Mix.php | 69 +++++++++++ src/Tecan/AdvancedCommands/Str.php | 12 ++ src/Tecan/AdvancedCommands/Volumes.php | 68 +++++++++++ src/Tecan/AdvancedCommands/WellSelection.php | 84 ++++++++++++++ tests/Tecan/AdvancedCommands/MixTest.php | 43 +++++++ tests/Tecan/AdvancedCommands/VolumesTest.php | 47 ++++++++ .../AdvancedCommands/WellSelectionTest.php | 109 ++++++++++++++++++ 8 files changed, 435 insertions(+) create mode 100644 src/Tecan/AdvancedCommands/Mix.php create mode 100644 src/Tecan/AdvancedCommands/Str.php create mode 100644 src/Tecan/AdvancedCommands/Volumes.php create mode 100644 src/Tecan/AdvancedCommands/WellSelection.php create mode 100644 tests/Tecan/AdvancedCommands/MixTest.php create mode 100644 tests/Tecan/AdvancedCommands/VolumesTest.php create mode 100644 tests/Tecan/AdvancedCommands/WellSelectionTest.php diff --git a/phpunit.xml b/phpunit.xml index 2b57a08..acb7da4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -3,6 +3,9 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnTestsThatTriggerDeprecations="true" + displayDetailsOnPhpunitDeprecations="true" > <source> <include> diff --git a/src/Tecan/AdvancedCommands/Mix.php b/src/Tecan/AdvancedCommands/Mix.php new file mode 100644 index 0000000..92382bf --- /dev/null +++ b/src/Tecan/AdvancedCommands/Mix.php @@ -0,0 +1,69 @@ +<?php declare(strict_types=1); + +namespace MLL\Utils\Tecan\AdvancedCommands; + +use MLL\Utils\Tecan\BasicCommands\Command; +use MLL\Utils\Tecan\TecanException; + +class Mix extends Command +{ + private string $liquidClass; + + private Volumes $volumes; + + private int $grid; + + private int $site; + + private int $spacing; + + private string $wellSelection; + + private int $cycles; + + private int $noOfLoopOptions = 0; + + private int $arm; + + public function __construct( + string $liquidClass, + Volumes $volumes, + int $grid, + int $site, + int $spacing, + WellSelection $wellSelection, + int $cycles, + int $arm = 0 + ) { + $this->liquidClass = $liquidClass; + $this->volumes = $volumes; + $this->grid = $grid; + $this->site = $site; + $this->spacing = $spacing; + $this->wellSelection = $wellSelection->toString(); + $this->cycles = $cycles; + $this->arm = $arm; + } + + public function toString(): string + { + if ($this->noOfLoopOptions !== 0) { + throw new TecanException('Loop options are not yet supported'); + } + + $mixParameters = implode(',', [ + $this->volumes->tipMask(), + Str::encloseWithDoubleQuotes($this->liquidClass), + $this->volumes->volumeString(), + $this->grid, + $this->site, + $this->spacing, + Str::encloseWithDoubleQuotes($this->wellSelection), + $this->cycles, + $this->noOfLoopOptions, + $this->arm, + ]); + + return "Mix({$mixParameters})"; + } +} diff --git a/src/Tecan/AdvancedCommands/Str.php b/src/Tecan/AdvancedCommands/Str.php new file mode 100644 index 0000000..ba4f17f --- /dev/null +++ b/src/Tecan/AdvancedCommands/Str.php @@ -0,0 +1,12 @@ +<?php declare(strict_types=1); + +namespace MLL\Utils\Tecan\AdvancedCommands; + +class Str +{ + /** @param int|float|string $value */ + public static function encloseWithDoubleQuotes($value): string + { + return "\"{$value}\""; + } +} diff --git a/src/Tecan/AdvancedCommands/Volumes.php b/src/Tecan/AdvancedCommands/Volumes.php new file mode 100644 index 0000000..66f3088 --- /dev/null +++ b/src/Tecan/AdvancedCommands/Volumes.php @@ -0,0 +1,68 @@ +<?php declare(strict_types=1); + +namespace MLL\Utils\Tecan\AdvancedCommands; + +class Volumes +{ + /** @var array{ + * 0:float|int, + * 1:float|int, + * 2:float|int, + * 3:float|int, + * 4:float|int, + * 5:float|int, + * 6:float|int, + * 7:float|int, + * 8:float|int, + * 9:float|int, + * 10:float|int, + * 11:float|int, + * } */ + private array $volumes; + + /** @param array{ + * 0:float|int, + * 1:float|int, + * 2:float|int, + * 3:float|int, + * 4:float|int, + * 5:float|int, + * 6:float|int, + * 7:float|int, + * 8:float|int, + * 9:float|int, + * 10:float|int, + * 11:float|int, + * } $volumes + */ + public function __construct(array $volumes) + { + $this->volumes = $volumes; + } + + public function volumeString(): string + { + $volumesArray = array_map(fn ($volume): string => $volume === 0.0 || $volume === 0 + ? (string) $volume + : Str::encloseWithDoubleQuotes($volume), $this->volumes); + + return implode(',', $volumesArray); + } + + /** + * Generate tip bitmask from volumes. + * + * @return int Bitmask representing the tip mask + */ + public function tipMask() + { + $bitmask = 0; + foreach ($this->volumes as $index => $volume) { + if ($volume > 0) { + $bitmask |= (1 << $index); + } + } + + return $bitmask; + } +} diff --git a/src/Tecan/AdvancedCommands/WellSelection.php b/src/Tecan/AdvancedCommands/WellSelection.php new file mode 100644 index 0000000..a6b3c7f --- /dev/null +++ b/src/Tecan/AdvancedCommands/WellSelection.php @@ -0,0 +1,84 @@ +<?php declare(strict_types=1); + +namespace MLL\Utils\Tecan\AdvancedCommands; + +class WellSelection +{ + public const MAX_ITEMS_PER_SUBARRAY = 7; + + private int $xWells; + + private int $yWells; + + /** @var array<int, array<int, int>> */ + private array $selFlag; + + /** @param array<int> $positions */ + public function __construct(int $xWells, int $yWells, array $positions) + { + $this->xWells = $xWells; + $this->yWells = $yWells; + $this->selFlag = WellSelection::transformPositionsToSelFlag($positions, $xWells, $yWells); + } + + public function toString(): string + { + if ($this->xWells === 0 || $this->yWells === 0) { + return '0000'; + } + $selString = sprintf('%02X%02X', $this->xWells, $this->yWells); + + $bitCounter = 0; + $bitMask = 0; + + $selFlag = $this->selFlag; + + for ($x = 0; $x < $this->xWells; ++$x) { + for ($y = 0; $y < $this->yWells; ++$y) { + if (isset($selFlag[$x][$y]) && ($selFlag[$x][$y] & 1) !== 0) { + $bitMask |= (1 << $bitCounter); + } + if (++$bitCounter > 6) { + $selString .= chr(ord('0') + $bitMask); + $bitCounter = 0; + $bitMask = 0; + } + } + } + + if ($bitCounter > 0) { + $selString .= chr(ord('0') + $bitMask); + } + + return $selString; + } + + /** + * @param array<int> $positions + * + * @return array<int, array<int, int>> + */ + public static function transformPositionsToSelFlag(array $positions, int $xWells, int $yWells): array + { + // Calculate the total number of wells + $totalWells = $xWells * $yWells; + + // Calculate the number of sub-arrays needed, each with 7 elements + $numSubArrays = (int) ceil($totalWells / self::MAX_ITEMS_PER_SUBARRAY); + + // Initialize the selFlag array with zeros + $selFlag = array_fill(0, $numSubArrays, array_fill(0, self::MAX_ITEMS_PER_SUBARRAY, 0)); + + // Iterate over the positions and set the corresponding selFlag positions to 1 + foreach ($positions as $position) { + $index = $position - 1; + $subArrayIndex = intdiv($index, self::MAX_ITEMS_PER_SUBARRAY); + $bitIndex = $index % self::MAX_ITEMS_PER_SUBARRAY; + if ($subArrayIndex < $numSubArrays) { + $selFlag[$subArrayIndex][$bitIndex] = 1; + } + } + + return $selFlag; + } +} diff --git a/tests/Tecan/AdvancedCommands/MixTest.php b/tests/Tecan/AdvancedCommands/MixTest.php new file mode 100644 index 0000000..5d29123 --- /dev/null +++ b/tests/Tecan/AdvancedCommands/MixTest.php @@ -0,0 +1,43 @@ +<?php declare(strict_types=1); + +namespace MLL\Utils\Tests\Tecan\AdvancedCommands; + +use MLL\Utils\Tecan\AdvancedCommands\Mix; +use MLL\Utils\Tecan\AdvancedCommands\Volumes; +use MLL\Utils\Tecan\AdvancedCommands\WellSelection; +use PHPUnit\Framework\TestCase; + +class MixTest extends TestCase +{ + public function testMixCommandWithValidParameters(): void + { + $mix = new Mix( + 'Water', + new Volumes([5.5, 5.5, 4.5, 4.5, 0, 0, 0, 0, 0, 0, 0, 0]), + 15, + 2, + 1, + new WellSelection(12, 8, [1]), + 1, + ); + + $expected = 'Mix(15,"Water","5.5","5.5","4.5","4.5",0,0,0,0,0,0,0,0,15,2,1,"0C0810000000000000",1,0,0)'; + self::assertEquals($expected, $mix->toString()); + } + + public function testMixCommandWithNoVolumes(): void + { + $mix = new Mix( + 'Water', + new Volumes([0, 0.0, 0.00000, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + 15, + 2, + 1, + new WellSelection(12, 8, [1]), + 1, + ); + + $expected = 'Mix(0,"Water",0,0,0,0,0,0,0,0,0,0,0,0,15,2,1,"0C0810000000000000",1,0,0)'; + self::assertEquals($expected, $mix->toString()); + } +} diff --git a/tests/Tecan/AdvancedCommands/VolumesTest.php b/tests/Tecan/AdvancedCommands/VolumesTest.php new file mode 100644 index 0000000..dd8cff3 --- /dev/null +++ b/tests/Tecan/AdvancedCommands/VolumesTest.php @@ -0,0 +1,47 @@ +<?php declare(strict_types=1); + +namespace MLL\Utils\Tests\Tecan\AdvancedCommands; + +use MLL\Utils\Tecan\AdvancedCommands\Volumes; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +class VolumesTest extends TestCase +{ + /** @return iterable<array{0: array<int, float|int>, 1: string, 2: int}> */ + public static function validVolumesProvider(): iterable + { + yield [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], '0,0,0,0,0,0,0,0,0,0,0,0', 0]; + yield [[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], '"1",0,0,0,0,0,0,0,0,0,0,0', 1]; + yield [[1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], '"1","2",0,0,0,0,0,0,0,0,0,0', 3]; + yield [[1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0], '"1",0,"2",0,0,0,0,0,0,0,0,0', 5]; + yield [[1, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0], '"1",0,0,"2",0,0,0,0,0,0,0,0', 9]; + + yield [[1, 0, 2.5, 0, 0, 3, 0, 4, 0, 0, 0, 0], '"1",0,"2.5",0,0,"3",0,"4",0,0,0,0', 165]; + yield [[1, 2, 3, 4, 5, 6, 7, 8, 0, 0, 0, 0], '"1","2","3","4","5","6","7","8",0,0,0,0', 255]; + } + + /** @dataProvider validVolumesProvider + * @param array{ + * 0:float|int, + * 1:float|int, + * 2:float|int, + * 3:float|int, + * 4:float|int, + * 5:float|int, + * 6:float|int, + * 7:float|int, + * 8:float|int, + * 9:float|int, + * 10:float|int, + * 11:float|int, + * } $volumes + */ + #[DataProvider('validVolumesProvider')] + public function testVolumesStringAndTipMask(array $volumes, string $expectedString, int $expectedMask): void + { + $volumesObj = new Volumes($volumes); + self::assertSame($expectedString, $volumesObj->volumeString()); + self::assertSame($expectedMask, $volumesObj->tipMask()); + } +} diff --git a/tests/Tecan/AdvancedCommands/WellSelectionTest.php b/tests/Tecan/AdvancedCommands/WellSelectionTest.php new file mode 100644 index 0000000..9c29e74 --- /dev/null +++ b/tests/Tecan/AdvancedCommands/WellSelectionTest.php @@ -0,0 +1,109 @@ +<?php declare(strict_types=1); + +namespace MLL\Utils\Tests\Tecan\AdvancedCommands; + +use MLL\Utils\Tecan\AdvancedCommands\WellSelection; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +class WellSelectionTest extends TestCase +{ + /** @return iterable<array{0: int, 1: int, 2: array<int>, 3: string}> */ + public static function wellSelectionProvider(): iterable + { + yield 'returns0000WhenXWellsIsZero' => [0, 5, [], '0000']; + yield 'returns0000WhenYWellsIsZero' => [5, 0, [], '0000']; + yield 'A1 pos 1' => [12, 8, [1], '0C0810000000000000']; + yield 'B1 pos 2' => [12, 8, [2], '0C0820000000000000']; + yield 'C1 pos 3' => [12, 8, [3], '0C0840000000000000']; + yield 'D1 pos 4' => [12, 8, [4], '0C0880000000000000']; + yield 'E1 pos 5' => [12, 8, [5], '0C08@0000000000000']; + yield 'F1 pos 6' => [12, 8, [6], '0C08P0000000000000']; + yield 'G1 pos 7' => [12, 8, [7], '0C08p0000000000000']; + yield 'A1 + E1 pos 1 + 5' => [12, 8, [1, 5], '0C08A0000000000000']; + yield 'no wells selected' => [12, 8, [], '0C0800000000000000']; + } + + /** + * @dataProvider wellSelectionProvider + * + * @param array<int, int> $positions + */ + #[DataProvider('wellSelectionProvider')] + public function testWellSelection(int $xWells, int $yWells, array $positions, string $expected): void + { + $wellSelection = new WellSelection($xWells, $yWells, $positions); + self::assertEquals($expected, $wellSelection->toString()); + } + + /** @return iterable<array{0: array<int>, 1: int, 2: int, 3: array<int, array<int, int>>}> */ + public static function transformPositionsToSelFlagProvider(): iterable + { + yield [ + [1, 5], 12, 8, [ + [1, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + ]; + yield [ + [8, 96], 12, 8, [ + [0, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0], + ], + ]; + yield [ + [15, 40], 12, 8, [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + ]; + } + + /** + * @dataProvider transformPositionsToSelFlagProvider + * + * @param array<int> $positions + * @param array<int, array<int, int>> $expected + */ + #[DataProvider('transformPositionsToSelFlagProvider')] + public function testTransformPositionsToSelFlag(array $positions, int $xWells, int $yWells, array $expected): void + { + self::assertEquals($expected, WellSelection::transformPositionsToSelFlag($positions, $xWells, $yWells)); + } +}