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));
+    }
+}