From 9aeaf12629b5f7f1c74a0bc0c57126942e782fc0 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Fri, 24 Apr 2026 10:58:00 +0200 Subject: [PATCH 01/25] feat: implement interop --- src/InterOp/ClusterStatistic.php | 26 ++ src/InterOp/DeviationValue.php | 34 +++ src/InterOp/InterOpException.php | 5 + src/InterOp/InterOpResult.php | 73 ++++++ src/InterOp/LaneResult.php | 120 +++++++++ tests/GenomicRegionTest.php | 2 +- tests/InterOp/DeviationValueTest.php | 36 +++ tests/InterOp/InterOpResultTest.php | 69 +++++ tests/InterOp/meta-info-i100.json | 256 +++++++++++++++++++ tests/InterOp/meta-info-miseq.json | 360 +++++++++++++++++++++++++++ 10 files changed, 980 insertions(+), 1 deletion(-) create mode 100644 src/InterOp/ClusterStatistic.php create mode 100644 src/InterOp/DeviationValue.php create mode 100644 src/InterOp/InterOpException.php create mode 100644 src/InterOp/InterOpResult.php create mode 100644 src/InterOp/LaneResult.php create mode 100644 tests/InterOp/DeviationValueTest.php create mode 100644 tests/InterOp/InterOpResultTest.php create mode 100644 tests/InterOp/meta-info-i100.json create mode 100644 tests/InterOp/meta-info-miseq.json diff --git a/src/InterOp/ClusterStatistic.php b/src/InterOp/ClusterStatistic.php new file mode 100644 index 0000000..2876f51 --- /dev/null +++ b/src/InterOp/ClusterStatistic.php @@ -0,0 +1,26 @@ +density = $density; + $this->clusterPF = $clusterPF; + $this->clusterCount = $clusterCount; + $this->clusterCountPF = $clusterCountPF; + } +} diff --git a/src/InterOp/DeviationValue.php b/src/InterOp/DeviationValue.php new file mode 100644 index 0000000..0167d2c --- /dev/null +++ b/src/InterOp/DeviationValue.php @@ -0,0 +1,34 @@ +value = $value; + $this->deviation = $deviation; + } + + /** + * Parses strings like "851 +/- 32" into value and deviation. + * + * Returns null for "nan +/- nan" (occurs for index reads). + */ + public static function parse(string $raw): ?self + { + if (preg_match('/^([\d.]+)\s*\+\/-\s*([\d.]+)$/', $raw, $matches) !== 1) { + return null; + } + + return new self((float) $matches[1], (float) $matches[2]); + } +} diff --git a/src/InterOp/InterOpException.php b/src/InterOp/InterOpException.php new file mode 100644 index 0000000..d0a90be --- /dev/null +++ b/src/InterOp/InterOpException.php @@ -0,0 +1,5 @@ +> $summary interop summary rows + * @param array>> $reads interop reads keyed by read name + */ + public function __construct(array $summary, array $reads) + { + [$firstDataRead, $lastDataRead] = self::findDataReads($summary); + + $read1Rows = $reads[$firstDataRead] ?? null; + if ($read1Rows === null) { + throw new InterOpException("Reads data missing for '{$firstDataRead}'."); + } + + $read2Rows = $reads[$lastDataRead] ?? null; + if ($read2Rows === null) { + throw new InterOpException("Reads data missing for '{$lastDataRead}'."); + } + + $this->resultsForRead1 = LaneResult::fromInterOpRow($read1Rows[0]); + $this->resultsForRead2 = LaneResult::fromInterOpRow($read2Rows[0]); + $this->resultsForRun = RunResult::fromLaneResults($this->resultsForRead1, $this->resultsForRead2); + } + + /** + * Finds the first and last data (non-index) reads from summary entries. + * + * Index reads have "(I)" suffix in their Level field (e.g. "Read 2 (I)"). + * Data reads lack this suffix. The first and last non-index entries are the + * two data reads, regardless of device type: + * - MiSeq single-index: Read 1, Read 3 + * - MiSeq dual-index: Read 1, Read 4 + * - i100: Read 3, Read 4 + * + * @param array> $summary + * + * @return array{0: string, 1: string} + */ + public static function findDataReads(array $summary): array + { + $dataReads = []; + foreach ($summary as $entry) { + $level = $entry['Level']; + if ($level === 'Non-indexed' || $level === 'Total') { + continue; + } + + if (substr($level, -3) !== '(I)') { + $dataReads[] = $level; + } + } + + if (count($dataReads) < 2) { + throw new InterOpException('Expected at least 2 data reads, found ' . count($dataReads) . '.'); + } + + return [$dataReads[0], $dataReads[count($dataReads) - 1]]; + } +} diff --git a/src/InterOp/LaneResult.php b/src/InterOp/LaneResult.php new file mode 100644 index 0000000..08b8c7d --- /dev/null +++ b/src/InterOp/LaneResult.php @@ -0,0 +1,120 @@ +clusterStatistic = $clusterStatistic; + $this->sequencingQualityControl = $sequencingQualityControl; + $this->intensityCycle = $intensityCycle; + $this->yield = $yield; + } + + /** + * Builds a LaneResult from the first row (Surface "-") of a reads entry. + * + * @param array $row single lane row from interop reads data + */ + public static function fromInterOpRow(array $row): self + { + $density = DeviationValue::parse($row['Density']); + assert($density instanceof DeviationValue, "Expected parseable Density, got: {$row['Density']}"); + + $clusterPF = DeviationValue::parse($row['Cluster PF']); + assert($clusterPF instanceof DeviationValue, "Expected parseable Cluster PF, got: {$row['Cluster PF']}"); + + $aligned = DeviationValue::parse($row['Aligned']); + assert($aligned instanceof DeviationValue, "Expected parseable Aligned, got: {$row['Aligned']}"); + + $error = DeviationValue::parse($row['Error']); + assert($error instanceof DeviationValue, "Expected parseable Error, got: {$row['Error']}"); + + $intensityCycle = DeviationValue::parse($row['Intensity C1']); + assert($intensityCycle instanceof DeviationValue, "Expected parseable Intensity C1, got: {$row['Intensity C1']}"); + + $phasingParts = explode(' / ', $row['Legacy Phasing/Prephasing Rate']); + assert(count($phasingParts) === 2, "Expected 'phasing / prephasing' format, got: {$row['Legacy Phasing/Prephasing Rate']}"); + assert($phasingParts[0] !== 'nan', 'Unexpected nan phasing rate for data read'); + + $clusterStatistic = new ClusterStatistic( + $density, + $clusterPF, + (float) $row['Reads'], + (float) $row['Reads PF'] + ); + + $sequencingQualityControl = new SequencingQualityControl( + (float) $row['%>=Q30'], + (float) $phasingParts[0], + (float) $phasingParts[1], + $aligned, + $error + ); + + return new self( + $clusterStatistic, + $sequencingQualityControl, + (int) $intensityCycle->value, + (int) ((float) $row['Yield'] * 1000000) + ); + } + + public static function aggregate(self $a, self $b): self + { + $density = new DeviationValue( + ($a->clusterStatistic->density->value + $b->clusterStatistic->density->value) / 2, + ($a->clusterStatistic->density->deviation + $b->clusterStatistic->density->deviation) / 2 + ); + + $clusterPF = new DeviationValue( + ($a->clusterStatistic->clusterPF->value + $b->clusterStatistic->clusterPF->value) / 2, + ($a->clusterStatistic->clusterPF->deviation + $b->clusterStatistic->clusterPF->deviation) / 2 + ); + + $clusterStatistic = new ClusterStatistic( + $density, + $clusterPF, + $a->clusterStatistic->clusterCount + $b->clusterStatistic->clusterCount, + $a->clusterStatistic->clusterCountPF + $b->clusterStatistic->clusterCountPF + ); + + $aligned = new DeviationValue( + ($a->sequencingQualityControl->aligned->value + $b->sequencingQualityControl->aligned->value) / 2, + ($a->sequencingQualityControl->aligned->deviation + $b->sequencingQualityControl->aligned->deviation) / 2 + ); + + $error = new DeviationValue( + ($a->sequencingQualityControl->error->value + $b->sequencingQualityControl->error->value) / 2, + ($a->sequencingQualityControl->error->deviation + $b->sequencingQualityControl->error->deviation) / 2 + ); + + $sequencingQualityControl = new SequencingQualityControl( + ($a->sequencingQualityControl->q30 + $b->sequencingQualityControl->q30) / 2, + ($a->sequencingQualityControl->phasing + $b->sequencingQualityControl->phasing) / 2, + ($a->sequencingQualityControl->prephasing + $b->sequencingQualityControl->prephasing) / 2, + $aligned, + $error + ); + + return new self( + $clusterStatistic, + $sequencingQualityControl, + (int) (($a->intensityCycle + $b->intensityCycle) / 2), + $a->yield + $b->yield + ); + } +} diff --git a/tests/GenomicRegionTest.php b/tests/GenomicRegionTest.php index 7ad4e73..244b63a 100644 --- a/tests/GenomicRegionTest.php +++ b/tests/GenomicRegionTest.php @@ -268,7 +268,7 @@ public function testGenomicPositions(): void $positions = $region->genomicPositions(); self::assertCount(4, $positions); - self::assertEquals($region->length(), count($positions)); + self::assertCount($region->length(), $positions); self::assertTrue($positions[0]->equals(GenomicPosition::parseOneBased('chr11:10'))); self::assertTrue($positions[1]->equals(GenomicPosition::parseOneBased('chr11:11'))); diff --git a/tests/InterOp/DeviationValueTest.php b/tests/InterOp/DeviationValueTest.php new file mode 100644 index 0000000..e1eaf79 --- /dev/null +++ b/tests/InterOp/DeviationValueTest.php @@ -0,0 +1,36 @@ +value); + self::assertSame($expectedDeviation, $result->deviation); + } + + /** @return iterable */ + public static function parseProvider(): iterable + { + yield 'integer values' => ['851 +/- 32', 851.0, 32.0]; + yield 'decimal values' => ['96.54 +/- 0.25', 96.54, 0.25]; + yield 'nan returns null' => ['nan +/- nan', null, null]; + yield 'small decimal values' => ['0.085 +/- 0.020', 0.085, 0.02]; + } +} diff --git a/tests/InterOp/InterOpResultTest.php b/tests/InterOp/InterOpResultTest.php new file mode 100644 index 0000000..09b6559 --- /dev/null +++ b/tests/InterOp/InterOpResultTest.php @@ -0,0 +1,69 @@ +> $summary + */ + #[DataProvider('dataReadDetectionProvider')] + public function testFindDataReads(string $description, array $summary, string $expectedFirst, string $expectedLast): void + { + [$first, $last] = InterOpResult::findDataReads($summary); + + self::assertSame($expectedFirst, $first, "{$description}: first data read"); + self::assertSame($expectedLast, $last, "{$description}: last data read"); + } + + /** @return iterable>, string, string}> */ + public static function dataReadDetectionProvider(): iterable + { + yield 'MiSeq single-index' => [ + 'MiSeq with one index read', + [ + ['Level' => 'Read 1'], + ['Level' => 'Read 2 (I)'], + ['Level' => 'Read 3'], + ['Level' => 'Non-indexed'], + ['Level' => 'Total'], + ], + 'Read 1', + 'Read 3', + ]; + + yield 'MiSeq dual-index' => [ + 'MiSeq with two index reads', + [ + ['Level' => 'Read 1'], + ['Level' => 'Read 2 (I)'], + ['Level' => 'Read 3 (I)'], + ['Level' => 'Read 4'], + ['Level' => 'Non-indexed'], + ['Level' => 'Total'], + ], + 'Read 1', + 'Read 4', + ]; + + yield 'i100 dual-index' => [ + 'i100 with index reads first', + [ + ['Level' => 'Read 1 (I)'], + ['Level' => 'Read 2 (I)'], + ['Level' => 'Read 3'], + ['Level' => 'Read 4'], + ['Level' => 'Non-indexed'], + ['Level' => 'Total'], + ], + 'Read 3', + 'Read 4', + ]; + } +} diff --git a/tests/InterOp/meta-info-i100.json b/tests/InterOp/meta-info-i100.json new file mode 100644 index 0000000..a7fc762 --- /dev/null +++ b/tests/InterOp/meta-info-i100.json @@ -0,0 +1,256 @@ +{ + "runParameters": { + "xml": { + "@version": 1, + "@encoding": "utf-8" + }, + "RunParameters": { + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "@xmlns:xsd": "http://www.w3.org/2001/XMLSchema", + "Application": "MiSeqi100Series Control Software", + "SystemSuiteVersion": "1.1.0.26158", + "OutputFolder": "//192.168.0.228/miseq_active/miSeqi100/20260205_SH01038_0007_ASC2139476-SC3", + "CustomPrimerSelections": { + "ReadOnePrimer": false, + "ReadTwoPrimer": false, + "IndexOnePrimer": false, + "IndexTwoPrimer": false + }, + "CloudUploadMode": "InstrumentPerformance", + "RunSetupMode": "Manual", + "SecondaryAnalysisMode": "ManualModeLocalAnalysis", + "InstrumentType": "MiSeqi100Plus", + "InstrumentSerialNumber": "SH01038", + "RunId": "20260205_SH01038_0007_ASC2139476-SC3", + "SampleSheetName": "SampleSheet.csv", + "ConsumableInfo": { + "ConsumableInfo": [ + { + "SerialNumber": "SC2139476-SC3", + "LotNumber": 20987817, + "PartNumber": 20115216, + "ExpirationDate": "2026-07-29T00:00:00+02:00", + "Type": "DryCartridge", + "Mode": "5M", + "Version": 1 + }, + { + "SerialNumber": "SC2157208-SC2", + "LotNumber": 20994302, + "PartNumber": 20079747, + "ExpirationDate": "2026-10-17T00:00:00+02:00", + "Type": "WetCartridge", + "Mode": "B", + "Version": 1 + }, + { + "SerialNumber": "BXA23202-0617", + "Type": "FlowCell_1", + "Mode": 1, + "Version": 1 + } + ] + }, + "PlannedReads": { + "Read": [ + { + "@ReadName": "Index1", + "@Cycles": 6, + "#text": null + }, + { + "@ReadName": "Index2", + "@Cycles": 8, + "#text": null + }, + { + "@ReadName": "Read1", + "@Cycles": 301, + "#text": null + }, + { + "@ReadName": "Read2", + "@Cycles": 301, + "#text": null + } + ] + }, + "SecondaryAnalysisInfo": { + "SecondaryAnalysisInfo": { + "SecondaryAnalysisPlatformVersion": "4.4.6", + "SecondaryAnalysisWorkflow": { + "string": "DRAGEN BCL Convert" + } + } + }, + "RunCounter": 7, + "RecipeName": "5M/600_B_Recipe", + "RecipeVersion": "2.2_IF", + "ExperimentName": "Run9619", + "PurgeReagentCartridge": true + } + }, + "interop": { + "summary": [ + { + "Level": "Read 1 (I)", + "Yield": "0.02", + "Projected Yield": "0.02", + "Aligned": "0.00", + "Error Rate": "nan", + "Intensity C1": "3031", + "%>=Q30": "92.66", + "% Occupied": "74.22" + }, + { + "Level": "Read 2 (I)", + "Yield": "0.03", + "Projected Yield": "0.03", + "Aligned": "0.00", + "Error Rate": "nan", + "Intensity C1": "2979", + "%>=Q30": "92.00", + "% Occupied": "74.22" + }, + { + "Level": "Read 3", + "Yield": "1.49", + "Projected Yield": "1.49", + "Aligned": "28.66", + "Error Rate": "0.27", + "Intensity C1": "3534", + "%>=Q30": "95.35", + "% Occupied": "74.22" + }, + { + "Level": "Read 4", + "Yield": "1.49", + "Projected Yield": "1.49", + "Aligned": "28.27", + "Error Rate": "0.32", + "Intensity C1": "3486", + "%>=Q30": "96.32", + "% Occupied": "74.22" + }, + { + "Level": "Non-indexed", + "Yield": "2.97", + "Projected Yield": "2.97", + "Aligned": "28.47", + "Error Rate": "0.29", + "Intensity C1": "3510", + "%>=Q30": "95.83", + "% Occupied": "74.22" + }, + { + "Level": "Total", + "Yield": "3.03", + "Projected Yield": "3.03", + "Aligned": "28.47", + "Error Rate": "0.29", + "Intensity C1": "3257", + "%>=Q30": "95.76", + "% Occupied": "74.22" + } + ], + "reads": { + "Read 1 (I)": [ + { + "Lane": "1", + "Surface": "-", + "Tiles": "18", + "Density": "1000 +/- 0", + "Cluster PF": "62.27 +/- 0.43", + "Legacy Phasing/Prephasing Rate": "nan / nan", + "Phasing slope/offset": "nan / nan", + "Prephasing slope/offset": "nan / nan", + "Reads": "7.91", + "Reads PF": "4.92", + "%>=Q30": "92.66", + "Yield": "0.02", + "Cycles Error": "0", + "Aligned": "nan +/- nan", + "Error": "nan +/- nan", + "Error (35)": "nan +/- nan", + "Error (75)": "nan +/- nan", + "Error (100)": "nan +/- nan", + "% Occupied": "74.22 +/- 0.34", + "Intensity C1": "3031 +/- 83" + } + ], + "Read 2 (I)": [ + { + "Lane": "1", + "Surface": "-", + "Tiles": "18", + "Density": "1000 +/- 0", + "Cluster PF": "62.27 +/- 0.43", + "Legacy Phasing/Prephasing Rate": "nan / nan", + "Phasing slope/offset": "nan / nan", + "Prephasing slope/offset": "nan / nan", + "Reads": "7.91", + "Reads PF": "4.92", + "%>=Q30": "92.00", + "Yield": "0.03", + "Cycles Error": "0", + "Aligned": "nan +/- nan", + "Error": "nan +/- nan", + "Error (35)": "nan +/- nan", + "Error (75)": "nan +/- nan", + "Error (100)": "nan +/- nan", + "% Occupied": "74.22 +/- 0.34", + "Intensity C1": "2978 +/- 61" + } + ], + "Read 3": [ + { + "Lane": "1", + "Surface": "-", + "Tiles": "18", + "Density": "1000 +/- 0", + "Cluster PF": "62.27 +/- 0.43", + "Legacy Phasing/Prephasing Rate": "0.075 / 0.042", + "Phasing slope/offset": "0.060 / -0.469", + "Prephasing slope/offset": "0.023 / 0.863", + "Reads": "7.91", + "Reads PF": "4.92", + "%>=Q30": "95.35", + "Yield": "1.49", + "Cycles Error": "300", + "Aligned": "28.66 +/- 0.56", + "Error": "0.27 +/- 0.01", + "Error (35)": "0.09 +/- 0.01", + "Error (75)": "0.08 +/- 0.00", + "Error (100)": "0.11 +/- 0.00", + "% Occupied": "74.22 +/- 0.34", + "Intensity C1": "3534 +/- 79" + } + ], + "Read 4": [ + { + "Lane": "1", + "Surface": "-", + "Tiles": "18", + "Density": "1000 +/- 0", + "Cluster PF": "62.27 +/- 0.43", + "Legacy Phasing/Prephasing Rate": "0.025 / 0.001", + "Phasing slope/offset": "0.065 / -0.990", + "Prephasing slope/offset": "0.029 / 0.350", + "Reads": "7.91", + "Reads PF": "4.92", + "%>=Q30": "96.32", + "Yield": "1.49", + "Cycles Error": "300", + "Aligned": "28.27 +/- 0.56", + "Error": "0.32 +/- 0.01", + "Error (35)": "0.09 +/- 0.00", + "Error (75)": "0.21 +/- 0.01", + "Error (100)": "0.26 +/- 0.01", + "% Occupied": "74.22 +/- 0.34", + "Intensity C1": "3486 +/- 89" + } + ] + } + }, + "uncPath": "mllsrv20/miseq_active\\miSeqi100\\20260205_SH01038_0007_ASC2139476-SC3\\meta-info.json" +} \ No newline at end of file diff --git a/tests/InterOp/meta-info-miseq.json b/tests/InterOp/meta-info-miseq.json new file mode 100644 index 0000000..d69b8e0 --- /dev/null +++ b/tests/InterOp/meta-info-miseq.json @@ -0,0 +1,360 @@ +{ + "runParameters": { + "xml": { + "@version": 1 + }, + "RunParameters": { + "@xmlns:xsd": "http://www.w3.org/2001/XMLSchema", + "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "EnableCloud": false, + "RunParametersVersion": "MiSeq_1_1", + "CopyManifests": true, + "FlowcellRFIDTag": { + "SerialNumber": "000000000-KT6CY", + "PartNumber": 15028382, + "ExpirationDate": "2023-09-27T00:00:00" + }, + "PR2BottleRFIDTag": { + "SerialNumber": "MS3471919-00PR2", + "PartNumber": 15041807, + "ExpirationDate": "2023-09-28T00:00:00" + }, + "ReagentKitRFIDTag": { + "SerialNumber": "MS3233206-600V3", + "PartNumber": 15043962, + "ExpirationDate": "2023-09-16T00:00:00" + }, + "Resumable": true, + "ManifestFiles": null, + "AfterRunWashMethod": "Post-Run Wash", + "Setup": { + "SupportMultipleSurfacesInUI": true, + "ApplicationVersion": "2.6.2.2", + "NumTilesPerSwath": 19, + "NumSwaths": 1, + "NumLanes": 1, + "ApplicationName": "MiSeq Control Software" + }, + "RunID": "230421_M02074_0859_000000000-KT6CY", + "ScannerID": "M02074", + "RunNumber": 859, + "FPGAVersion": "9.5.12", + "MCSVersion": "2.6.2.2", + "RTAVersion": "1.18.54", + "Barcode": "000000000-KT6CY", + "PR2BottleBarcode": "MS3471919-00PR2", + "ReagentKitPartNumberEntered": 15043962, + "ReagentKitVersion": "Version3", + "ReagentKitBarcode": "MS3233206-600V3", + "PreviousPR2BottleBarcode": null, + "PreviousReagentKitBarcode": null, + "Chemistry": "Default", + "Username": "sbsuser", + "Workflow": { + "Analysis": "GenerateFASTQ" + }, + "EnableAnalysis": false, + "Reads": { + "RunInfoRead": [ + { + "@Number": 1, + "@NumCycles": 301, + "@IsIndexedRead": "N", + "#text": null + }, + { + "@Number": 2, + "@NumCycles": 6, + "@IsIndexedRead": "Y", + "#text": null + }, + { + "@Number": 3, + "@NumCycles": 301, + "@IsIndexedRead": "N", + "#text": null + } + ] + }, + "TempFolder": "D:\\Illumina\\MiSeqTemp\\230421_M02074_0859_000000000-KT6CY", + "AnalysisFolder": "D:\\Illumina\\MiSeqAnalysis\\230421_M02074_0859_000000000-KT6CY", + "RunStartDate": 230421, + "MostRecentWashType": "PostRun", + "RecipeFolder": "D:\\Illumina\\MiSeq Control Software\\CustomRecipe", + "ILMNOnlyRecipeFolder": "C:\\Illumina\\MiSeq Control Software\\Recipe", + "SampleSheetName": "Run7054-IMMUNORECEPTOR", + "SampleSheetFolder": "Z:\\samplesheets", + "ManifestFolder": "Z:\\manifests", + "OutputFolder": "Z:\\230421_M02074_0859_000000000-KT6CY", + "FocusMethod": "AutoFocus", + "SurfaceToScan": "Both", + "SaveFocusImages": false, + "SaveScanImages": true, + "CloudUsername": null, + "RunManagementType": "Standalone", + "CloudRunId": 257422183, + "SendInstrumentHealthToILMN": true + } + }, + "interop": { + "summary": [ + { + "Level": "Read 1", + "Yield": "6.14", + "Projected Yield": "6.14", + "Aligned": "6.18", + "Error Rate": "2.88", + "Intensity C1": "139", + "%>=Q30": "88.20", + "% Occupied": "nan" + }, + { + "Level": "Read 2 (I)", + "Yield": "0.10", + "Projected Yield": "0.10", + "Aligned": "0.00", + "Error Rate": "nan", + "Intensity C1": "343", + "%>=Q30": "91.89", + "% Occupied": "nan" + }, + { + "Level": "Read 3", + "Yield": "6.14", + "Projected Yield": "6.14", + "Aligned": "6.04", + "Error Rate": "3.18", + "Intensity C1": "46", + "%>=Q30": "78.19", + "% Occupied": "nan" + }, + { + "Level": "Non-indexed", + "Yield": "12.29", + "Projected Yield": "12.29", + "Aligned": "6.11", + "Error Rate": "3.03", + "Intensity C1": "92", + "%>=Q30": "83.20", + "% Occupied": "nan" + }, + { + "Level": "Total", + "Yield": "12.39", + "Projected Yield": "12.39", + "Aligned": "6.11", + "Error Rate": "3.03", + "Intensity C1": "176", + "%>=Q30": "83.27", + "% Occupied": "nan" + } + ], + "reads": { + "Read 1": [ + { + "Lane": "1", + "Surface": "-", + "Tiles": "38", + "Density": "851 +/- 32", + "Cluster PF": "96.54 +/- 0.25", + "Legacy Phasing/Prephasing Rate": "0.085 / 0.020", + "Phasing slope/offset": "nan / nan", + "Prephasing slope/offset": "nan / nan", + "Reads": "21.22", + "Reads PF": "20.48", + "%>=Q30": "88.20", + "Yield": "6.14", + "Cycles Error": "300", + "Aligned": "6.18 +/- 0.05", + "Error": "2.88 +/- 0.07", + "Error (35)": "0.15 +/- 0.03", + "Error (75)": "0.19 +/- 0.02", + "Error (100)": "0.27 +/- 0.03", + "% Occupied": "nan +/- nan", + "Intensity C1": "139 +/- 12" + }, + { + "Lane": "1", + "Surface": "1", + "Tiles": "19", + "Density": "869 +/- 29", + "Cluster PF": "96.54 +/- 0.30", + "Legacy Phasing/Prephasing Rate": "0.085 / 0.015", + "Phasing slope/offset": "nan / nan", + "Prephasing slope/offset": "nan / nan", + "Reads": "10.81", + "Reads PF": "10.44", + "%>=Q30": "88.34", + "Yield": "3.13", + "Cycles Error": "-", + "Aligned": "6.18 +/- 0.05", + "Error": "2.92 +/- 0.06", + "Error (35)": "0.15 +/- 0.03", + "Error (75)": "0.20 +/- 0.02", + "Error (100)": "0.29 +/- 0.03", + "% Occupied": "nan +/- nan", + "Intensity C1": "151 +/- 3" + }, + { + "Lane": "1", + "Surface": "2", + "Tiles": "19", + "Density": "834 +/- 25", + "Cluster PF": "96.53 +/- 0.19", + "Legacy Phasing/Prephasing Rate": "0.085 / 0.025", + "Phasing slope/offset": "nan / nan", + "Prephasing slope/offset": "nan / nan", + "Reads": "10.40", + "Reads PF": "10.04", + "%>=Q30": "88.05", + "Yield": "3.01", + "Cycles Error": "-", + "Aligned": "6.17 +/- 0.05", + "Error": "2.83 +/- 0.04", + "Error (35)": "0.15 +/- 0.02", + "Error (75)": "0.19 +/- 0.02", + "Error (100)": "0.26 +/- 0.02", + "% Occupied": "nan +/- nan", + "Intensity C1": "128 +/- 3" + } + ], + "Read 2 (I)": [ + { + "Lane": "1", + "Surface": "-", + "Tiles": "38", + "Density": "851 +/- 32", + "Cluster PF": "96.54 +/- 0.25", + "Legacy Phasing/Prephasing Rate": "0.000 / 0.000", + "Phasing slope/offset": "nan / nan", + "Prephasing slope/offset": "nan / nan", + "Reads": "21.22", + "Reads PF": "20.48", + "%>=Q30": "91.89", + "Yield": "0.10", + "Cycles Error": "0", + "Aligned": "nan +/- nan", + "Error": "nan +/- nan", + "Error (35)": "nan +/- nan", + "Error (75)": "nan +/- nan", + "Error (100)": "nan +/- nan", + "% Occupied": "nan +/- nan", + "Intensity C1": "343 +/- 26" + }, + { + "Lane": "1", + "Surface": "1", + "Tiles": "19", + "Density": "869 +/- 29", + "Cluster PF": "96.54 +/- 0.30", + "Legacy Phasing/Prephasing Rate": "nan / nan", + "Phasing slope/offset": "nan / nan", + "Prephasing slope/offset": "nan / nan", + "Reads": "10.81", + "Reads PF": "10.44", + "%>=Q30": "91.99", + "Yield": "0.05", + "Cycles Error": "-", + "Aligned": "nan +/- nan", + "Error": "nan +/- nan", + "Error (35)": "nan +/- nan", + "Error (75)": "nan +/- nan", + "Error (100)": "nan +/- nan", + "% Occupied": "nan +/- nan", + "Intensity C1": "368 +/- 6" + }, + { + "Lane": "1", + "Surface": "2", + "Tiles": "19", + "Density": "834 +/- 25", + "Cluster PF": "96.53 +/- 0.19", + "Legacy Phasing/Prephasing Rate": "nan / nan", + "Phasing slope/offset": "nan / nan", + "Prephasing slope/offset": "nan / nan", + "Reads": "10.40", + "Reads PF": "10.04", + "%>=Q30": "91.79", + "Yield": "0.05", + "Cycles Error": "-", + "Aligned": "nan +/- nan", + "Error": "nan +/- nan", + "Error (35)": "nan +/- nan", + "Error (75)": "nan +/- nan", + "Error (100)": "nan +/- nan", + "% Occupied": "nan +/- nan", + "Intensity C1": "318 +/- 5" + } + ], + "Read 3": [ + { + "Lane": "1", + "Surface": "-", + "Tiles": "38", + "Density": "851 +/- 32", + "Cluster PF": "96.54 +/- 0.25", + "Legacy Phasing/Prephasing Rate": "0.045 / 0.016", + "Phasing slope/offset": "nan / nan", + "Prephasing slope/offset": "nan / nan", + "Reads": "21.22", + "Reads PF": "20.48", + "%>=Q30": "78.19", + "Yield": "6.14", + "Cycles Error": "300", + "Aligned": "6.04 +/- 0.06", + "Error": "3.18 +/- 0.06", + "Error (35)": "0.25 +/- 0.02", + "Error (75)": "0.30 +/- 0.02", + "Error (100)": "0.38 +/- 0.02", + "% Occupied": "nan +/- nan", + "Intensity C1": "46 +/- 4" + }, + { + "Lane": "1", + "Surface": "1", + "Tiles": "19", + "Density": "869 +/- 29", + "Cluster PF": "96.54 +/- 0.30", + "Legacy Phasing/Prephasing Rate": "0.058 / 0.028", + "Phasing slope/offset": "nan / nan", + "Prephasing slope/offset": "nan / nan", + "Reads": "10.81", + "Reads PF": "10.44", + "%>=Q30": "78.61", + "Yield": "3.13", + "Cycles Error": "-", + "Aligned": "6.05 +/- 0.05", + "Error": "3.16 +/- 0.06", + "Error (35)": "0.24 +/- 0.02", + "Error (75)": "0.31 +/- 0.01", + "Error (100)": "0.38 +/- 0.02", + "% Occupied": "nan +/- nan", + "Intensity C1": "50 +/- 2" + }, + { + "Lane": "1", + "Surface": "2", + "Tiles": "19", + "Density": "834 +/- 25", + "Cluster PF": "96.53 +/- 0.19", + "Legacy Phasing/Prephasing Rate": "0.031 / 0.003", + "Phasing slope/offset": "nan / nan", + "Prephasing slope/offset": "nan / nan", + "Reads": "10.40", + "Reads PF": "10.04", + "%>=Q30": "77.76", + "Yield": "3.01", + "Cycles Error": "-", + "Aligned": "6.02 +/- 0.05", + "Error": "3.20 +/- 0.06", + "Error (35)": "0.25 +/- 0.03", + "Error (75)": "0.30 +/- 0.02", + "Error (100)": "0.37 +/- 0.02", + "% Occupied": "nan +/- nan", + "Intensity C1": "41 +/- 1" + } + ] + } + }, + "uncPath": "mllsrv20/miseq_active\\230421_M02074_0859_000000000-KT6CY\\meta-info.json" +} \ No newline at end of file From e27b9e7b62d15944778e8941b685917628fdf4d7 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Fri, 24 Apr 2026 10:58:09 +0200 Subject: [PATCH 02/25] feat: implement interop --- src/InterOp/MetaInfo.php | 31 +++++ src/InterOp/RunParameters.php | 148 +++++++++++++++++++++++ src/InterOp/RunResult.php | 25 ++++ src/InterOp/SequencingQualityControl.php | 30 +++++ tests/InterOp/MetaInfoTest.php | 93 ++++++++++++++ 5 files changed, 327 insertions(+) create mode 100644 src/InterOp/MetaInfo.php create mode 100644 src/InterOp/RunParameters.php create mode 100644 src/InterOp/RunResult.php create mode 100644 src/InterOp/SequencingQualityControl.php create mode 100644 tests/InterOp/MetaInfoTest.php diff --git a/src/InterOp/MetaInfo.php b/src/InterOp/MetaInfo.php new file mode 100644 index 0000000..ef5ec8a --- /dev/null +++ b/src/InterOp/MetaInfo.php @@ -0,0 +1,31 @@ +>, reads: array>>}, uncPath: string} $data */ + $data = json_decode($json, true); + + $this->runParameters = new RunParameters($data['runParameters']['RunParameters']); + $this->interOpResult = new InterOpResult($data['interop']['summary'], $data['interop']['reads']); + $this->uncPath = $data['uncPath']; + } +} diff --git a/src/InterOp/RunParameters.php b/src/InterOp/RunParameters.php new file mode 100644 index 0000000..0eaf4d1 --- /dev/null +++ b/src/InterOp/RunParameters.php @@ -0,0 +1,148 @@ +}} + */ +class RunParameters +{ + public const APPLICATION_MISEQ = 'MiSeq Control Software'; + public const APPLICATION_MISEQ_I100 = 'MiSeqi100Series Control Software'; + + /** @var string */ + public $application; + + /** @var Carbon */ + public $runDate; + + /** @var string */ + public $flowcell; + + /** @var string */ + public $flowcellExpirationDate; + + /** @var string */ + public $rta; + + /** @var string RunID / RunId. */ + public $info; + + /** @var string MCS / system suite version. */ + public $mcs; + + /** @var array */ + public $reagents; + + /** @param MiSeqParams|I100Params $params */ + public function __construct(array $params) + { + if (isset($params['Setup']['ApplicationName'])) { + $this->parseMiSeq($params); + } elseif (isset($params['Application'])) { + $this->parseMiSeqI100($params); + } else { + throw new InterOpException('Unable to determine device type from RunParameters.'); + } + } + + /** @param MiSeqParams $params */ + private function parseMiSeq(array $params): void + { + $this->application = $params['Setup']['ApplicationName']; + $this->info = $params['RunID']; + $this->mcs = $params['MCSVersion']; + $this->rta = $params['RTAVersion']; + + $runStartDate = (string) $params['RunStartDate']; + $date = Carbon::createFromFormat('!ymd', $runStartDate); + assert($date instanceof Carbon, "Failed to parse MiSeq RunStartDate: {$runStartDate}"); + $this->runDate = $date; + + $this->flowcell = $this->stripZeroPrefix($params['FlowcellRFIDTag']['SerialNumber']); + $this->flowcellExpirationDate = $this->formatExpirationDate($params['FlowcellRFIDTag']['ExpirationDate']); + + $this->reagents = []; + + if (isset($params['PR2BottleRFIDTag'])) { + $this->reagents[] = [ + 'name' => $params['PR2BottleRFIDTag']['SerialNumber'], + 'expire_date' => $this->formatExpirationDate($params['PR2BottleRFIDTag']['ExpirationDate']), + ]; + } + + if (isset($params['ReagentKitRFIDTag'])) { + $this->reagents[] = [ + 'name' => $params['ReagentKitRFIDTag']['SerialNumber'], + 'expire_date' => $this->formatExpirationDate($params['ReagentKitRFIDTag']['ExpirationDate']), + ]; + } + } + + /** @param I100Params $params */ + private function parseMiSeqI100(array $params): void + { + $this->application = $params['Application']; + $this->info = $params['RunId']; + $this->mcs = $params['SystemSuiteVersion']; + $this->rta = ''; + + $runID = $params['RunId']; + $dateString = substr($runID, 0, 8); + $date = Carbon::createFromFormat('!Ymd', $dateString); + assert($date instanceof Carbon, "Failed to parse i100 run date from RunId: {$runID}"); + $this->runDate = $date; + + $consumables = $params['ConsumableInfo']['ConsumableInfo']; + $this->flowcell = ''; + $this->flowcellExpirationDate = ''; + $this->reagents = []; + + foreach ($consumables as $consumable) { + $type = $consumable['Type']; + + if ($type === 'DryCartridge') { + $this->flowcell = $consumable['SerialNumber']; + if (isset($consumable['ExpirationDate'])) { + $this->flowcellExpirationDate = $this->formatExpirationDate($consumable['ExpirationDate']); + } + } + + if ($type === 'DryCartridge' || $type === 'WetCartridge') { + $this->reagents[] = [ + 'name' => $consumable['SerialNumber'], + 'expire_date' => isset($consumable['ExpirationDate']) + ? $this->formatExpirationDate($consumable['ExpirationDate']) + : '', + ]; + } + } + } + + private function stripZeroPrefix(string $serial): string + { + $pos = strpos($serial, '-'); + if ($pos === false) { + return $serial; + } + + $prefix = substr($serial, 0, $pos); + if ($prefix !== '' && trim($prefix, '0') === '') { + return substr($serial, $pos + 1); + } + + return $serial; + } + + private function formatExpirationDate(string $dateTime): string + { + $date = Carbon::parse($dateTime); + + return $date->format('Y-m-d'); + } +} diff --git a/src/InterOp/RunResult.php b/src/InterOp/RunResult.php new file mode 100644 index 0000000..a496dff --- /dev/null +++ b/src/InterOp/RunResult.php @@ -0,0 +1,25 @@ +clusterStatistic = $clusterStatistic; + $this->sequencingQualityControl = $sequencingQualityControl; + } + + public static function fromLaneResults(LaneResult $read1, LaneResult $read2): self + { + $aggregated = LaneResult::aggregate($read1, $read2); + + return new self($aggregated->clusterStatistic, $aggregated->sequencingQualityControl); + } +} diff --git a/src/InterOp/SequencingQualityControl.php b/src/InterOp/SequencingQualityControl.php new file mode 100644 index 0000000..52b8389 --- /dev/null +++ b/src/InterOp/SequencingQualityControl.php @@ -0,0 +1,30 @@ += Q30. */ + public $q30; + + /** @var float Phasing rate. */ + public $phasing; + + /** @var float Prephasing rate. */ + public $prephasing; + + /** @var DeviationValue Alignment percentage. */ + public $aligned; + + /** @var DeviationValue Error rate. */ + public $error; + + public function __construct(float $q30, float $phasing, float $prephasing, DeviationValue $aligned, DeviationValue $error) + { + $this->q30 = $q30; + $this->phasing = $phasing; + $this->prephasing = $prephasing; + $this->aligned = $aligned; + $this->error = $error; + } +} diff --git a/tests/InterOp/MetaInfoTest.php b/tests/InterOp/MetaInfoTest.php new file mode 100644 index 0000000..145de60 --- /dev/null +++ b/tests/InterOp/MetaInfoTest.php @@ -0,0 +1,93 @@ +runParameters->application); + self::assertSame('2023-04-21', $metaInfo->runParameters->runDate->format('Y-m-d')); + self::assertSame('KT6CY', $metaInfo->runParameters->flowcell); + self::assertSame('2023-09-27', $metaInfo->runParameters->flowcellExpirationDate); + self::assertSame('1.18.54', $metaInfo->runParameters->rta); + self::assertSame('2.6.2.2', $metaInfo->runParameters->mcs); + self::assertSame('230421_M02074_0859_000000000-KT6CY', $metaInfo->runParameters->info); + + self::assertCount(2, $metaInfo->runParameters->reagents); + self::assertSame('MS3471919-00PR2', $metaInfo->runParameters->reagents[0]['name']); + self::assertSame('2023-09-28', $metaInfo->runParameters->reagents[0]['expire_date']); + self::assertSame('MS3233206-600V3', $metaInfo->runParameters->reagents[1]['name']); + self::assertSame('2023-09-16', $metaInfo->runParameters->reagents[1]['expire_date']); + + self::assertSame(851.0, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->density->value); + self::assertSame(32.0, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->density->deviation); + self::assertSame(96.54, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterPF->value); + self::assertSame(21.22, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterCount); + self::assertSame(20.48, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterCountPF); + + self::assertSame(88.2, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->q30); + self::assertSame(0.085, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->phasing); + self::assertSame(0.02, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->prephasing); + self::assertSame(6.18, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->aligned->value); + self::assertSame(2.88, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->error->value); + self::assertSame(139, $metaInfo->interOpResult->resultsForRead1->intensityCycle); + self::assertSame(6140000, $metaInfo->interOpResult->resultsForRead1->yield); + + self::assertSame(78.19, $metaInfo->interOpResult->resultsForRead2->sequencingQualityControl->q30); + self::assertSame(0.045, $metaInfo->interOpResult->resultsForRead2->sequencingQualityControl->phasing); + self::assertSame(46, $metaInfo->interOpResult->resultsForRead2->intensityCycle); + self::assertSame(6140000, $metaInfo->interOpResult->resultsForRead2->yield); + + $runQC = $metaInfo->interOpResult->resultsForRun->sequencingQualityControl; + self::assertEqualsWithDelta(83.195, $runQC->q30, 0.001); + self::assertEqualsWithDelta(0.065, $runQC->phasing, 0.001); + + self::assertSame('mllsrv20/miseq_active\\230421_M02074_0859_000000000-KT6CY\\meta-info.json', $metaInfo->uncPath); + } + + public function testParseMiSeqI100(): void + { + $json = file_get_contents(__DIR__ . '/meta-info-i100.json'); + $metaInfo = new MetaInfo($json); + + self::assertSame(RunParameters::APPLICATION_MISEQ_I100, $metaInfo->runParameters->application); + self::assertSame('2026-02-05', $metaInfo->runParameters->runDate->format('Y-m-d')); + self::assertSame('SC2139476-SC3', $metaInfo->runParameters->flowcell); + self::assertSame('2026-07-29', $metaInfo->runParameters->flowcellExpirationDate); + self::assertSame('', $metaInfo->runParameters->rta); + self::assertSame('1.1.0.26158', $metaInfo->runParameters->mcs); + self::assertSame('20260205_SH01038_0007_ASC2139476-SC3', $metaInfo->runParameters->info); + + self::assertCount(2, $metaInfo->runParameters->reagents); + self::assertSame('SC2139476-SC3', $metaInfo->runParameters->reagents[0]['name']); + self::assertSame('2026-07-29', $metaInfo->runParameters->reagents[0]['expire_date']); + self::assertSame('SC2157208-SC2', $metaInfo->runParameters->reagents[1]['name']); + self::assertSame('2026-10-17', $metaInfo->runParameters->reagents[1]['expire_date']); + + self::assertSame(1000.0, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->density->value); + self::assertSame(62.27, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterPF->value); + self::assertSame(95.35, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->q30); + self::assertSame(0.075, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->phasing); + self::assertSame(0.042, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->prephasing); + self::assertSame(28.66, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->aligned->value); + self::assertSame(0.27, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->error->value); + self::assertSame(3534, $metaInfo->interOpResult->resultsForRead1->intensityCycle); + self::assertSame(1490000, $metaInfo->interOpResult->resultsForRead1->yield); + + self::assertSame(96.32, $metaInfo->interOpResult->resultsForRead2->sequencingQualityControl->q30); + self::assertSame(3486, $metaInfo->interOpResult->resultsForRead2->intensityCycle); + self::assertSame(1490000, $metaInfo->interOpResult->resultsForRead2->yield); + + self::assertSame('mllsrv20/miseq_active\\miSeqi100\\20260205_SH01038_0007_ASC2139476-SC3\\meta-info.json', $metaInfo->uncPath); + } +} From 029de10b6b610a2a0f3abb9cfba4e45f4bafaa05 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Fri, 24 Apr 2026 11:18:00 +0200 Subject: [PATCH 03/25] feat: implement interop --- src/InterOp/InterOpResult.php | 4 ++-- src/InterOp/LaneResult.php | 14 +++++++------- src/InterOp/RunParameters.php | 4 ++-- tests/InterOp/MetaInfoTest.php | 5 ++--- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/InterOp/InterOpResult.php b/src/InterOp/InterOpResult.php index e697441..5cbe4cb 100644 --- a/src/InterOp/InterOpResult.php +++ b/src/InterOp/InterOpResult.php @@ -23,12 +23,12 @@ public function __construct(array $summary, array $reads) $read1Rows = $reads[$firstDataRead] ?? null; if ($read1Rows === null) { - throw new InterOpException("Reads data missing for '{$firstDataRead}'."); + throw new InterOpException("Reads data missing for: {$firstDataRead}."); } $read2Rows = $reads[$lastDataRead] ?? null; if ($read2Rows === null) { - throw new InterOpException("Reads data missing for '{$lastDataRead}'."); + throw new InterOpException("Reads data missing for: {$lastDataRead}."); } $this->resultsForRead1 = LaneResult::fromInterOpRow($read1Rows[0]); diff --git a/src/InterOp/LaneResult.php b/src/InterOp/LaneResult.php index 08b8c7d..bba9afc 100644 --- a/src/InterOp/LaneResult.php +++ b/src/InterOp/LaneResult.php @@ -32,23 +32,23 @@ public function __construct(ClusterStatistic $clusterStatistic, SequencingQualit public static function fromInterOpRow(array $row): self { $density = DeviationValue::parse($row['Density']); - assert($density instanceof DeviationValue, "Expected parseable Density, got: {$row['Density']}"); + assert($density instanceof DeviationValue, "Expected parseable Density, got: {$row['Density']}."); $clusterPF = DeviationValue::parse($row['Cluster PF']); - assert($clusterPF instanceof DeviationValue, "Expected parseable Cluster PF, got: {$row['Cluster PF']}"); + assert($clusterPF instanceof DeviationValue, "Expected parseable Cluster PF, got: {$row['Cluster PF']}."); $aligned = DeviationValue::parse($row['Aligned']); - assert($aligned instanceof DeviationValue, "Expected parseable Aligned, got: {$row['Aligned']}"); + assert($aligned instanceof DeviationValue, "Expected parseable Aligned, got: {$row['Aligned']}."); $error = DeviationValue::parse($row['Error']); - assert($error instanceof DeviationValue, "Expected parseable Error, got: {$row['Error']}"); + assert($error instanceof DeviationValue, "Expected parseable Error, got: {$row['Error']}."); $intensityCycle = DeviationValue::parse($row['Intensity C1']); - assert($intensityCycle instanceof DeviationValue, "Expected parseable Intensity C1, got: {$row['Intensity C1']}"); + assert($intensityCycle instanceof DeviationValue, "Expected parseable Intensity C1, got: {$row['Intensity C1']}."); $phasingParts = explode(' / ', $row['Legacy Phasing/Prephasing Rate']); - assert(count($phasingParts) === 2, "Expected 'phasing / prephasing' format, got: {$row['Legacy Phasing/Prephasing Rate']}"); - assert($phasingParts[0] !== 'nan', 'Unexpected nan phasing rate for data read'); + assert(count($phasingParts) === 2, "Expected 'phasing / prephasing' format, got: {$row['Legacy Phasing/Prephasing Rate']}."); + assert($phasingParts[0] !== 'nan', 'Unexpected nan phasing rate for data read.'); $clusterStatistic = new ClusterStatistic( $density, diff --git a/src/InterOp/RunParameters.php b/src/InterOp/RunParameters.php index 0eaf4d1..f7f9839 100644 --- a/src/InterOp/RunParameters.php +++ b/src/InterOp/RunParameters.php @@ -61,7 +61,7 @@ private function parseMiSeq(array $params): void $runStartDate = (string) $params['RunStartDate']; $date = Carbon::createFromFormat('!ymd', $runStartDate); - assert($date instanceof Carbon, "Failed to parse MiSeq RunStartDate: {$runStartDate}"); + assert($date instanceof Carbon, "Failed to parse MiSeq RunStartDate: {$runStartDate}."); $this->runDate = $date; $this->flowcell = $this->stripZeroPrefix($params['FlowcellRFIDTag']['SerialNumber']); @@ -95,7 +95,7 @@ private function parseMiSeqI100(array $params): void $runID = $params['RunId']; $dateString = substr($runID, 0, 8); $date = Carbon::createFromFormat('!Ymd', $dateString); - assert($date instanceof Carbon, "Failed to parse i100 run date from RunId: {$runID}"); + assert($date instanceof Carbon, "Failed to parse i100 run date from RunId: {$runID}."); $this->runDate = $date; $consumables = $params['ConsumableInfo']['ConsumableInfo']; diff --git a/tests/InterOp/MetaInfoTest.php b/tests/InterOp/MetaInfoTest.php index 145de60..b20603a 100644 --- a/tests/InterOp/MetaInfoTest.php +++ b/tests/InterOp/MetaInfoTest.php @@ -3,7 +3,6 @@ namespace MLL\Utils\Tests\InterOp; use MLL\Utils\InterOp\MetaInfo; -use MLL\Utils\InterOp\RunParameters; use PHPUnit\Framework\TestCase; use function Safe\file_get_contents; @@ -15,7 +14,7 @@ public function testParseMiSeq(): void $json = file_get_contents(__DIR__ . '/meta-info-miseq.json'); $metaInfo = new MetaInfo($json); - self::assertSame(RunParameters::APPLICATION_MISEQ, $metaInfo->runParameters->application); + self::assertSame('MiSeq Control Software', $metaInfo->runParameters->application); self::assertSame('2023-04-21', $metaInfo->runParameters->runDate->format('Y-m-d')); self::assertSame('KT6CY', $metaInfo->runParameters->flowcell); self::assertSame('2023-09-27', $metaInfo->runParameters->flowcellExpirationDate); @@ -60,7 +59,7 @@ public function testParseMiSeqI100(): void $json = file_get_contents(__DIR__ . '/meta-info-i100.json'); $metaInfo = new MetaInfo($json); - self::assertSame(RunParameters::APPLICATION_MISEQ_I100, $metaInfo->runParameters->application); + self::assertSame('MiSeqi100Series Control Software', $metaInfo->runParameters->application); self::assertSame('2026-02-05', $metaInfo->runParameters->runDate->format('Y-m-d')); self::assertSame('SC2139476-SC3', $metaInfo->runParameters->flowcell); self::assertSame('2026-07-29', $metaInfo->runParameters->flowcellExpirationDate); From d245f5514577a87acfaa87c044dcd2a0a979ee88 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Fri, 24 Apr 2026 14:41:56 +0200 Subject: [PATCH 04/25] Claude review --- src/InterOp/ClusterStatistic.php | 12 ++-- src/InterOp/DeviationValue.php | 21 ++++-- src/InterOp/InterOpResult.php | 28 ++++---- src/InterOp/LaneResult.php | 88 +++++++++--------------- src/InterOp/MetaInfo.php | 20 ++++-- src/InterOp/RunParameters.php | 46 +++++-------- src/InterOp/RunResult.php | 6 +- src/InterOp/SequencingQualityControl.php | 15 ++-- tests/InterOp/MetaInfoTest.php | 6 +- tests/InterOp/meta-info-i100.json | 6 +- tests/InterOp/meta-info-miseq.json | 4 +- 11 files changed, 116 insertions(+), 136 deletions(-) diff --git a/src/InterOp/ClusterStatistic.php b/src/InterOp/ClusterStatistic.php index 2876f51..7e01f53 100644 --- a/src/InterOp/ClusterStatistic.php +++ b/src/InterOp/ClusterStatistic.php @@ -4,17 +4,13 @@ class ClusterStatistic { - /** @var DeviationValue Cluster density (K/mm²). */ - public $density; + public DeviationValue $density; - /** @var DeviationValue Percentage of clusters passing filter. */ - public $clusterPF; + public DeviationValue $clusterPF; - /** @var float Total cluster count (millions). */ - public $clusterCount; + public float $clusterCount; - /** @var float Clusters passing filter (millions). */ - public $clusterCountPF; + public float $clusterCountPF; public function __construct(DeviationValue $density, DeviationValue $clusterPF, float $clusterCount, float $clusterCountPF) { diff --git a/src/InterOp/DeviationValue.php b/src/InterOp/DeviationValue.php index 0167d2c..91131d2 100644 --- a/src/InterOp/DeviationValue.php +++ b/src/InterOp/DeviationValue.php @@ -2,15 +2,15 @@ namespace MLL\Utils\InterOp; +use MLL\Utils\SafeCast; + use function Safe\preg_match; class DeviationValue { - /** @var float */ - public $value; + public float $value; - /** @var float */ - public $deviation; + public float $deviation; public function __construct(float $value, float $deviation) { @@ -29,6 +29,17 @@ public static function parse(string $raw): ?self return null; } - return new self((float) $matches[1], (float) $matches[2]); + return new self( + SafeCast::toFloat($matches[1]), + SafeCast::toFloat($matches[2]) + ); + } + + public static function average(self $a, self $b): self + { + return new self( + ($a->value + $b->value) / 2, + ($a->deviation + $b->deviation) / 2 + ); } } diff --git a/src/InterOp/InterOpResult.php b/src/InterOp/InterOpResult.php index 5cbe4cb..ddaab8c 100644 --- a/src/InterOp/InterOpResult.php +++ b/src/InterOp/InterOpResult.php @@ -4,14 +4,11 @@ class InterOpResult { - /** @var LaneResult */ - public $resultsForRead1; + public LaneResult $resultsForRead1; - /** @var LaneResult */ - public $resultsForRead2; + public LaneResult $resultsForRead2; - /** @var RunResult */ - public $resultsForRun; + public RunResult $resultsForRun; /** * @param array> $summary interop summary rows @@ -22,15 +19,16 @@ public function __construct(array $summary, array $reads) [$firstDataRead, $lastDataRead] = self::findDataReads($summary); $read1Rows = $reads[$firstDataRead] ?? null; - if ($read1Rows === null) { - throw new InterOpException("Reads data missing for: {$firstDataRead}."); + if ($read1Rows === null || $read1Rows === []) { + throw new InterOpException("Reads data missing or empty for: {$firstDataRead}."); } $read2Rows = $reads[$lastDataRead] ?? null; - if ($read2Rows === null) { - throw new InterOpException("Reads data missing for: {$lastDataRead}."); + if ($read2Rows === null || $read2Rows === []) { + throw new InterOpException("Reads data missing or empty for: {$lastDataRead}."); } + // First row per read key is the Surface "-" aggregate across all tiles $this->resultsForRead1 = LaneResult::fromInterOpRow($read1Rows[0]); $this->resultsForRead2 = LaneResult::fromInterOpRow($read2Rows[0]); $this->resultsForRun = RunResult::fromLaneResults($this->resultsForRead1, $this->resultsForRead2); @@ -64,10 +62,14 @@ public static function findDataReads(array $summary): array } } - if (count($dataReads) < 2) { - throw new InterOpException('Expected at least 2 data reads, found ' . count($dataReads) . '.'); + $count = count($dataReads); + if ($count < 2) { + throw new InterOpException("Expected at least 2 data reads, found {$count}."); } - return [$dataReads[0], $dataReads[count($dataReads) - 1]]; + $lastKey = array_key_last($dataReads); + assert($lastKey !== null, 'array_key_last() returned null despite count >= 2.'); + + return [$dataReads[0], $dataReads[$lastKey]]; } } diff --git a/src/InterOp/LaneResult.php b/src/InterOp/LaneResult.php index bba9afc..60f580e 100644 --- a/src/InterOp/LaneResult.php +++ b/src/InterOp/LaneResult.php @@ -2,19 +2,18 @@ namespace MLL\Utils\InterOp; +use MLL\Utils\SafeCast; + class LaneResult { - /** @var ClusterStatistic */ - public $clusterStatistic; + public ClusterStatistic $clusterStatistic; - /** @var SequencingQualityControl */ - public $sequencingQualityControl; + public SequencingQualityControl $sequencingQualityControl; - /** @var int Intensity at cycle 1. */ - public $intensityCycle; + public int $intensityCycle; - /** @var int Yield in bases (JSON float in gigabases * 1_000_000). */ - public $yield; + /** Yield in kilobases (JSON float in gigabases * 1_000_000). */ + public int $yield; public function __construct(ClusterStatistic $clusterStatistic, SequencingQualityControl $sequencingQualityControl, int $intensityCycle, int $yield) { @@ -49,72 +48,53 @@ public static function fromInterOpRow(array $row): self $phasingParts = explode(' / ', $row['Legacy Phasing/Prephasing Rate']); assert(count($phasingParts) === 2, "Expected 'phasing / prephasing' format, got: {$row['Legacy Phasing/Prephasing Rate']}."); assert($phasingParts[0] !== 'nan', 'Unexpected nan phasing rate for data read.'); + assert($phasingParts[1] !== 'nan', 'Unexpected nan prephasing rate for data read.'); $clusterStatistic = new ClusterStatistic( - $density, - $clusterPF, - (float) $row['Reads'], - (float) $row['Reads PF'] + density: $density, + clusterPF: $clusterPF, + clusterCount: SafeCast::toFloat($row['Reads']), + clusterCountPF: SafeCast::toFloat($row['Reads PF']) ); $sequencingQualityControl = new SequencingQualityControl( - (float) $row['%>=Q30'], - (float) $phasingParts[0], - (float) $phasingParts[1], - $aligned, - $error + q30: SafeCast::toFloat($row['%>=Q30']), + phasing: SafeCast::toFloat($phasingParts[0]), + prephasing: SafeCast::toFloat($phasingParts[1]), + aligned: $aligned, + error: $error ); return new self( - $clusterStatistic, - $sequencingQualityControl, - (int) $intensityCycle->value, - (int) ((float) $row['Yield'] * 1000000) + clusterStatistic: $clusterStatistic, + sequencingQualityControl: $sequencingQualityControl, + intensityCycle: SafeCast::toInt($intensityCycle->value), + yield: SafeCast::toInt(SafeCast::toFloat($row['Yield']) * 1000000) ); } public static function aggregate(self $a, self $b): self { - $density = new DeviationValue( - ($a->clusterStatistic->density->value + $b->clusterStatistic->density->value) / 2, - ($a->clusterStatistic->density->deviation + $b->clusterStatistic->density->deviation) / 2 - ); - - $clusterPF = new DeviationValue( - ($a->clusterStatistic->clusterPF->value + $b->clusterStatistic->clusterPF->value) / 2, - ($a->clusterStatistic->clusterPF->deviation + $b->clusterStatistic->clusterPF->deviation) / 2 - ); - $clusterStatistic = new ClusterStatistic( - $density, - $clusterPF, - $a->clusterStatistic->clusterCount + $b->clusterStatistic->clusterCount, - $a->clusterStatistic->clusterCountPF + $b->clusterStatistic->clusterCountPF - ); - - $aligned = new DeviationValue( - ($a->sequencingQualityControl->aligned->value + $b->sequencingQualityControl->aligned->value) / 2, - ($a->sequencingQualityControl->aligned->deviation + $b->sequencingQualityControl->aligned->deviation) / 2 - ); - - $error = new DeviationValue( - ($a->sequencingQualityControl->error->value + $b->sequencingQualityControl->error->value) / 2, - ($a->sequencingQualityControl->error->deviation + $b->sequencingQualityControl->error->deviation) / 2 + density: DeviationValue::average($a->clusterStatistic->density, $b->clusterStatistic->density), + clusterPF: DeviationValue::average($a->clusterStatistic->clusterPF, $b->clusterStatistic->clusterPF), + clusterCount: $a->clusterStatistic->clusterCount + $b->clusterStatistic->clusterCount, + clusterCountPF: $a->clusterStatistic->clusterCountPF + $b->clusterStatistic->clusterCountPF ); $sequencingQualityControl = new SequencingQualityControl( - ($a->sequencingQualityControl->q30 + $b->sequencingQualityControl->q30) / 2, - ($a->sequencingQualityControl->phasing + $b->sequencingQualityControl->phasing) / 2, - ($a->sequencingQualityControl->prephasing + $b->sequencingQualityControl->prephasing) / 2, - $aligned, - $error + q30: ($a->sequencingQualityControl->q30 + $b->sequencingQualityControl->q30) / 2, + phasing: ($a->sequencingQualityControl->phasing + $b->sequencingQualityControl->phasing) / 2, + prephasing: ($a->sequencingQualityControl->prephasing + $b->sequencingQualityControl->prephasing) / 2, + aligned: DeviationValue::average($a->sequencingQualityControl->aligned, $b->sequencingQualityControl->aligned), + error: DeviationValue::average($a->sequencingQualityControl->error, $b->sequencingQualityControl->error) ); return new self( - $clusterStatistic, - $sequencingQualityControl, - (int) (($a->intensityCycle + $b->intensityCycle) / 2), - $a->yield + $b->yield + clusterStatistic: $clusterStatistic, + sequencingQualityControl: $sequencingQualityControl, + intensityCycle: intdiv($a->intensityCycle + $b->intensityCycle, 2), + yield: $a->yield + $b->yield ); } } diff --git a/src/InterOp/MetaInfo.php b/src/InterOp/MetaInfo.php index ef5ec8a..d866bf0 100644 --- a/src/InterOp/MetaInfo.php +++ b/src/InterOp/MetaInfo.php @@ -10,18 +10,24 @@ */ class MetaInfo { - /** @var RunParameters */ - public $runParameters; + public RunParameters $runParameters; - /** @var InterOpResult */ - public $interOpResult; + public InterOpResult $interOpResult; - /** @var string */ - public $uncPath; + public string $uncPath; public function __construct(string $json) { - /** @var array{runParameters: array{RunParameters: MiSeqParams|I100Params}, interop: array{summary: array>, reads: array>>}, uncPath: string} $data */ + /** + * @var array{ + * runParameters: array{RunParameters: MiSeqParams|I100Params}, + * interop: array{ + * summary: array>, + * reads: array>>, + * }, + * uncPath: string, + * } $data + */ $data = json_decode($json, true); $this->runParameters = new RunParameters($data['runParameters']['RunParameters']); diff --git a/src/InterOp/RunParameters.php b/src/InterOp/RunParameters.php index f7f9839..46daef8 100644 --- a/src/InterOp/RunParameters.php +++ b/src/InterOp/RunParameters.php @@ -15,29 +15,22 @@ class RunParameters public const APPLICATION_MISEQ = 'MiSeq Control Software'; public const APPLICATION_MISEQ_I100 = 'MiSeqi100Series Control Software'; - /** @var string */ - public $application; + public string $application; - /** @var Carbon */ - public $runDate; + public Carbon $runDate; - /** @var string */ - public $flowcell; + public string $flowcell; - /** @var string */ - public $flowcellExpirationDate; + public ?string $flowcellExpirationDate = null; - /** @var string */ - public $rta; + public ?string $rta = null; - /** @var string RunID / RunId. */ - public $info; + public string $info; - /** @var string MCS / system suite version. */ - public $mcs; + public string $mcs; /** @var array */ - public $reagents; + public array $reagents; /** @param MiSeqParams|I100Params $params */ public function __construct(array $params) @@ -90,29 +83,21 @@ private function parseMiSeqI100(array $params): void $this->application = $params['Application']; $this->info = $params['RunId']; $this->mcs = $params['SystemSuiteVersion']; - $this->rta = ''; + $this->rta = null; - $runID = $params['RunId']; - $dateString = substr($runID, 0, 8); + $dateString = substr($this->info, 0, 8); $date = Carbon::createFromFormat('!Ymd', $dateString); - assert($date instanceof Carbon, "Failed to parse i100 run date from RunId: {$runID}."); + assert($date instanceof Carbon, "Failed to parse i100 run date from RunId: {$this->info}."); $this->runDate = $date; $consumables = $params['ConsumableInfo']['ConsumableInfo']; $this->flowcell = ''; - $this->flowcellExpirationDate = ''; + $this->flowcellExpirationDate = null; $this->reagents = []; foreach ($consumables as $consumable) { $type = $consumable['Type']; - if ($type === 'DryCartridge') { - $this->flowcell = $consumable['SerialNumber']; - if (isset($consumable['ExpirationDate'])) { - $this->flowcellExpirationDate = $this->formatExpirationDate($consumable['ExpirationDate']); - } - } - if ($type === 'DryCartridge' || $type === 'WetCartridge') { $this->reagents[] = [ 'name' => $consumable['SerialNumber'], @@ -120,6 +105,13 @@ private function parseMiSeqI100(array $params): void ? $this->formatExpirationDate($consumable['ExpirationDate']) : '', ]; + + if ($type === 'DryCartridge') { + $this->flowcell = $consumable['SerialNumber']; + if (isset($consumable['ExpirationDate'])) { + $this->flowcellExpirationDate = $this->formatExpirationDate($consumable['ExpirationDate']); + } + } } } } diff --git a/src/InterOp/RunResult.php b/src/InterOp/RunResult.php index a496dff..f8e4d69 100644 --- a/src/InterOp/RunResult.php +++ b/src/InterOp/RunResult.php @@ -4,11 +4,9 @@ class RunResult { - /** @var ClusterStatistic */ - public $clusterStatistic; + public ClusterStatistic $clusterStatistic; - /** @var SequencingQualityControl */ - public $sequencingQualityControl; + public SequencingQualityControl $sequencingQualityControl; public function __construct(ClusterStatistic $clusterStatistic, SequencingQualityControl $sequencingQualityControl) { diff --git a/src/InterOp/SequencingQualityControl.php b/src/InterOp/SequencingQualityControl.php index 52b8389..056c314 100644 --- a/src/InterOp/SequencingQualityControl.php +++ b/src/InterOp/SequencingQualityControl.php @@ -4,20 +4,15 @@ class SequencingQualityControl { - /** @var float Percentage of bases with quality score >= Q30. */ - public $q30; + public float $q30; - /** @var float Phasing rate. */ - public $phasing; + public float $phasing; - /** @var float Prephasing rate. */ - public $prephasing; + public float $prephasing; - /** @var DeviationValue Alignment percentage. */ - public $aligned; + public DeviationValue $aligned; - /** @var DeviationValue Error rate. */ - public $error; + public DeviationValue $error; public function __construct(float $q30, float $phasing, float $prephasing, DeviationValue $aligned, DeviationValue $error) { diff --git a/tests/InterOp/MetaInfoTest.php b/tests/InterOp/MetaInfoTest.php index b20603a..6f9c17f 100644 --- a/tests/InterOp/MetaInfoTest.php +++ b/tests/InterOp/MetaInfoTest.php @@ -51,7 +51,7 @@ public function testParseMiSeq(): void self::assertEqualsWithDelta(83.195, $runQC->q30, 0.001); self::assertEqualsWithDelta(0.065, $runQC->phasing, 0.001); - self::assertSame('mllsrv20/miseq_active\\230421_M02074_0859_000000000-KT6CY\\meta-info.json', $metaInfo->uncPath); + self::assertSame('example-server/miseq_active\\230421_M02074_0859_000000000-KT6CY\\meta-info.json', $metaInfo->uncPath); } public function testParseMiSeqI100(): void @@ -63,7 +63,7 @@ public function testParseMiSeqI100(): void self::assertSame('2026-02-05', $metaInfo->runParameters->runDate->format('Y-m-d')); self::assertSame('SC2139476-SC3', $metaInfo->runParameters->flowcell); self::assertSame('2026-07-29', $metaInfo->runParameters->flowcellExpirationDate); - self::assertSame('', $metaInfo->runParameters->rta); + self::assertNull($metaInfo->runParameters->rta); self::assertSame('1.1.0.26158', $metaInfo->runParameters->mcs); self::assertSame('20260205_SH01038_0007_ASC2139476-SC3', $metaInfo->runParameters->info); @@ -87,6 +87,6 @@ public function testParseMiSeqI100(): void self::assertSame(3486, $metaInfo->interOpResult->resultsForRead2->intensityCycle); self::assertSame(1490000, $metaInfo->interOpResult->resultsForRead2->yield); - self::assertSame('mllsrv20/miseq_active\\miSeqi100\\20260205_SH01038_0007_ASC2139476-SC3\\meta-info.json', $metaInfo->uncPath); + self::assertSame('example-server/miseq_active\\miSeqi100\\20260205_SH01038_0007_ASC2139476-SC3\\meta-info.json', $metaInfo->uncPath); } } diff --git a/tests/InterOp/meta-info-i100.json b/tests/InterOp/meta-info-i100.json index a7fc762..fc23d26 100644 --- a/tests/InterOp/meta-info-i100.json +++ b/tests/InterOp/meta-info-i100.json @@ -9,7 +9,7 @@ "@xmlns:xsd": "http://www.w3.org/2001/XMLSchema", "Application": "MiSeqi100Series Control Software", "SystemSuiteVersion": "1.1.0.26158", - "OutputFolder": "//192.168.0.228/miseq_active/miSeqi100/20260205_SH01038_0007_ASC2139476-SC3", + "OutputFolder": "//192.0.2.1/miseq_active/miSeqi100/20260205_SH01038_0007_ASC2139476-SC3", "CustomPrimerSelections": { "ReadOnePrimer": false, "ReadTwoPrimer": false, @@ -252,5 +252,5 @@ ] } }, - "uncPath": "mllsrv20/miseq_active\\miSeqi100\\20260205_SH01038_0007_ASC2139476-SC3\\meta-info.json" -} \ No newline at end of file + "uncPath": "example-server/miseq_active\\miSeqi100\\20260205_SH01038_0007_ASC2139476-SC3\\meta-info.json" +} diff --git a/tests/InterOp/meta-info-miseq.json b/tests/InterOp/meta-info-miseq.json index d69b8e0..6d210d3 100644 --- a/tests/InterOp/meta-info-miseq.json +++ b/tests/InterOp/meta-info-miseq.json @@ -356,5 +356,5 @@ ] } }, - "uncPath": "mllsrv20/miseq_active\\230421_M02074_0859_000000000-KT6CY\\meta-info.json" -} \ No newline at end of file + "uncPath": "example-server/miseq_active\\230421_M02074_0859_000000000-KT6CY\\meta-info.json" +} From e05a7820c87f2e8e2c0fe53a0efcaf908265ed68 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Fri, 24 Apr 2026 14:42:01 +0200 Subject: [PATCH 05/25] Claude review --- tests/InterOp/DeviationValueTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/InterOp/DeviationValueTest.php b/tests/InterOp/DeviationValueTest.php index e1eaf79..84da30f 100644 --- a/tests/InterOp/DeviationValueTest.php +++ b/tests/InterOp/DeviationValueTest.php @@ -25,12 +25,12 @@ public function testParse(string $input, ?float $expectedValue, ?float $expected self::assertSame($expectedDeviation, $result->deviation); } - /** @return iterable */ + /** @return iterable */ public static function parseProvider(): iterable { - yield 'integer values' => ['851 +/- 32', 851.0, 32.0]; - yield 'decimal values' => ['96.54 +/- 0.25', 96.54, 0.25]; - yield 'nan returns null' => ['nan +/- nan', null, null]; - yield 'small decimal values' => ['0.085 +/- 0.020', 0.085, 0.02]; + yield 'integer values' => ['input' => '851 +/- 32', 'expectedValue' => 851.0, 'expectedDeviation' => 32.0]; + yield 'decimal values' => ['input' => '96.54 +/- 0.25', 'expectedValue' => 96.54, 'expectedDeviation' => 0.25]; + yield 'nan returns null' => ['input' => 'nan +/- nan', 'expectedValue' => null, 'expectedDeviation' => null]; + yield 'small decimal values' => ['input' => '0.085 +/- 0.020', 'expectedValue' => 0.085, 'expectedDeviation' => 0.02]; } } From 3c47bf548360c75abb73c5e9ce6987aa79553ed6 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Fri, 24 Apr 2026 15:05:40 +0200 Subject: [PATCH 06/25] Claude review --- tests/InterOp/InterOpResultTest.php | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/InterOp/InterOpResultTest.php b/tests/InterOp/InterOpResultTest.php index 09b6559..f917557 100644 --- a/tests/InterOp/InterOpResultTest.php +++ b/tests/InterOp/InterOpResultTest.php @@ -22,25 +22,25 @@ public function testFindDataReads(string $description, array $summary, string $e self::assertSame($expectedLast, $last, "{$description}: last data read"); } - /** @return iterable>, string, string}> */ + /** @return iterable>, expectedFirst: string, expectedLast: string}> */ public static function dataReadDetectionProvider(): iterable { yield 'MiSeq single-index' => [ - 'MiSeq with one index read', - [ + 'description' => 'MiSeq with one index read', + 'summary' => [ ['Level' => 'Read 1'], ['Level' => 'Read 2 (I)'], ['Level' => 'Read 3'], ['Level' => 'Non-indexed'], ['Level' => 'Total'], ], - 'Read 1', - 'Read 3', + 'expectedFirst' => 'Read 1', + 'expectedLast' => 'Read 3', ]; yield 'MiSeq dual-index' => [ - 'MiSeq with two index reads', - [ + 'description' => 'MiSeq with two index reads', + 'summary' => [ ['Level' => 'Read 1'], ['Level' => 'Read 2 (I)'], ['Level' => 'Read 3 (I)'], @@ -48,13 +48,13 @@ public static function dataReadDetectionProvider(): iterable ['Level' => 'Non-indexed'], ['Level' => 'Total'], ], - 'Read 1', - 'Read 4', + 'expectedFirst' => 'Read 1', + 'expectedLast' => 'Read 4', ]; yield 'i100 dual-index' => [ - 'i100 with index reads first', - [ + 'description' => 'i100 with index reads first', + 'summary' => [ ['Level' => 'Read 1 (I)'], ['Level' => 'Read 2 (I)'], ['Level' => 'Read 3'], @@ -62,8 +62,8 @@ public static function dataReadDetectionProvider(): iterable ['Level' => 'Non-indexed'], ['Level' => 'Total'], ], - 'Read 3', - 'Read 4', + 'expectedFirst' => 'Read 3', + 'expectedLast' => 'Read 4', ]; } } From 3eac2ab6fadf2578cc7eff393fab8e998078b955 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Fri, 24 Apr 2026 17:11:49 +0200 Subject: [PATCH 07/25] Update shortcuts use fully names --- src/InterOp/ClusterStatistic.php | 10 +++++----- src/InterOp/LaneResult.php | 12 ++++++------ src/InterOp/RunParameters.php | 12 ++++++------ tests/InterOp/MetaInfoTest.php | 14 +++++++------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/InterOp/ClusterStatistic.php b/src/InterOp/ClusterStatistic.php index 7e01f53..06b196e 100644 --- a/src/InterOp/ClusterStatistic.php +++ b/src/InterOp/ClusterStatistic.php @@ -6,17 +6,17 @@ class ClusterStatistic { public DeviationValue $density; - public DeviationValue $clusterPF; + public DeviationValue $clusterPassingFilter; public float $clusterCount; - public float $clusterCountPF; + public float $clusterCountPassingFilter; - public function __construct(DeviationValue $density, DeviationValue $clusterPF, float $clusterCount, float $clusterCountPF) + public function __construct(DeviationValue $density, DeviationValue $clusterPassingFilter, float $clusterCount, float $clusterCountPassingFilter) { $this->density = $density; - $this->clusterPF = $clusterPF; + $this->clusterPassingFilter = $clusterPassingFilter; $this->clusterCount = $clusterCount; - $this->clusterCountPF = $clusterCountPF; + $this->clusterCountPassingFilter = $clusterCountPassingFilter; } } diff --git a/src/InterOp/LaneResult.php b/src/InterOp/LaneResult.php index 60f580e..013b2b7 100644 --- a/src/InterOp/LaneResult.php +++ b/src/InterOp/LaneResult.php @@ -33,8 +33,8 @@ public static function fromInterOpRow(array $row): self $density = DeviationValue::parse($row['Density']); assert($density instanceof DeviationValue, "Expected parseable Density, got: {$row['Density']}."); - $clusterPF = DeviationValue::parse($row['Cluster PF']); - assert($clusterPF instanceof DeviationValue, "Expected parseable Cluster PF, got: {$row['Cluster PF']}."); + $clusterPassingFilter = DeviationValue::parse($row['Cluster PF']); + assert($clusterPassingFilter instanceof DeviationValue, "Expected parseable Cluster PF, got: {$row['Cluster PF']}."); $aligned = DeviationValue::parse($row['Aligned']); assert($aligned instanceof DeviationValue, "Expected parseable Aligned, got: {$row['Aligned']}."); @@ -52,9 +52,9 @@ public static function fromInterOpRow(array $row): self $clusterStatistic = new ClusterStatistic( density: $density, - clusterPF: $clusterPF, + clusterPassingFilter: $clusterPassingFilter, clusterCount: SafeCast::toFloat($row['Reads']), - clusterCountPF: SafeCast::toFloat($row['Reads PF']) + clusterCountPassingFilter: SafeCast::toFloat($row['Reads PF']) ); $sequencingQualityControl = new SequencingQualityControl( @@ -77,9 +77,9 @@ public static function aggregate(self $a, self $b): self { $clusterStatistic = new ClusterStatistic( density: DeviationValue::average($a->clusterStatistic->density, $b->clusterStatistic->density), - clusterPF: DeviationValue::average($a->clusterStatistic->clusterPF, $b->clusterStatistic->clusterPF), + clusterPassingFilter: DeviationValue::average($a->clusterStatistic->clusterPassingFilter, $b->clusterStatistic->clusterPassingFilter), clusterCount: $a->clusterStatistic->clusterCount + $b->clusterStatistic->clusterCount, - clusterCountPF: $a->clusterStatistic->clusterCountPF + $b->clusterStatistic->clusterCountPF + clusterCountPassingFilter: $a->clusterStatistic->clusterCountPassingFilter + $b->clusterStatistic->clusterCountPassingFilter ); $sequencingQualityControl = new SequencingQualityControl( diff --git a/src/InterOp/RunParameters.php b/src/InterOp/RunParameters.php index 46daef8..196c20a 100644 --- a/src/InterOp/RunParameters.php +++ b/src/InterOp/RunParameters.php @@ -23,11 +23,11 @@ class RunParameters public ?string $flowcellExpirationDate = null; - public ?string $rta = null; + public ?string $realTimeAnalysisVersion = null; public string $info; - public string $mcs; + public string $controlSoftwareVersion; /** @var array */ public array $reagents; @@ -49,8 +49,8 @@ private function parseMiSeq(array $params): void { $this->application = $params['Setup']['ApplicationName']; $this->info = $params['RunID']; - $this->mcs = $params['MCSVersion']; - $this->rta = $params['RTAVersion']; + $this->controlSoftwareVersion = $params['MCSVersion']; + $this->realTimeAnalysisVersion = $params['RTAVersion']; $runStartDate = (string) $params['RunStartDate']; $date = Carbon::createFromFormat('!ymd', $runStartDate); @@ -82,8 +82,8 @@ private function parseMiSeqI100(array $params): void { $this->application = $params['Application']; $this->info = $params['RunId']; - $this->mcs = $params['SystemSuiteVersion']; - $this->rta = null; + $this->controlSoftwareVersion = $params['SystemSuiteVersion']; + $this->realTimeAnalysisVersion = null; $dateString = substr($this->info, 0, 8); $date = Carbon::createFromFormat('!Ymd', $dateString); diff --git a/tests/InterOp/MetaInfoTest.php b/tests/InterOp/MetaInfoTest.php index 6f9c17f..db9a3ab 100644 --- a/tests/InterOp/MetaInfoTest.php +++ b/tests/InterOp/MetaInfoTest.php @@ -18,8 +18,8 @@ public function testParseMiSeq(): void self::assertSame('2023-04-21', $metaInfo->runParameters->runDate->format('Y-m-d')); self::assertSame('KT6CY', $metaInfo->runParameters->flowcell); self::assertSame('2023-09-27', $metaInfo->runParameters->flowcellExpirationDate); - self::assertSame('1.18.54', $metaInfo->runParameters->rta); - self::assertSame('2.6.2.2', $metaInfo->runParameters->mcs); + self::assertSame('1.18.54', $metaInfo->runParameters->realTimeAnalysisVersion); + self::assertSame('2.6.2.2', $metaInfo->runParameters->controlSoftwareVersion); self::assertSame('230421_M02074_0859_000000000-KT6CY', $metaInfo->runParameters->info); self::assertCount(2, $metaInfo->runParameters->reagents); @@ -30,9 +30,9 @@ public function testParseMiSeq(): void self::assertSame(851.0, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->density->value); self::assertSame(32.0, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->density->deviation); - self::assertSame(96.54, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterPF->value); + self::assertSame(96.54, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterPassingFilter->value); self::assertSame(21.22, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterCount); - self::assertSame(20.48, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterCountPF); + self::assertSame(20.48, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterCountPassingFilter); self::assertSame(88.2, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->q30); self::assertSame(0.085, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->phasing); @@ -63,8 +63,8 @@ public function testParseMiSeqI100(): void self::assertSame('2026-02-05', $metaInfo->runParameters->runDate->format('Y-m-d')); self::assertSame('SC2139476-SC3', $metaInfo->runParameters->flowcell); self::assertSame('2026-07-29', $metaInfo->runParameters->flowcellExpirationDate); - self::assertNull($metaInfo->runParameters->rta); - self::assertSame('1.1.0.26158', $metaInfo->runParameters->mcs); + self::assertNull($metaInfo->runParameters->realTimeAnalysisVersion); + self::assertSame('1.1.0.26158', $metaInfo->runParameters->controlSoftwareVersion); self::assertSame('20260205_SH01038_0007_ASC2139476-SC3', $metaInfo->runParameters->info); self::assertCount(2, $metaInfo->runParameters->reagents); @@ -74,7 +74,7 @@ public function testParseMiSeqI100(): void self::assertSame('2026-10-17', $metaInfo->runParameters->reagents[1]['expire_date']); self::assertSame(1000.0, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->density->value); - self::assertSame(62.27, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterPF->value); + self::assertSame(62.27, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterPassingFilter->value); self::assertSame(95.35, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->q30); self::assertSame(0.075, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->phasing); self::assertSame(0.042, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->prephasing); From 1f8ecfa21e22a741524d98d61684baf1d30f8050 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Fri, 24 Apr 2026 17:24:32 +0200 Subject: [PATCH 08/25] Multiline fix --- src/InterOp/RunParameters.php | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/InterOp/RunParameters.php b/src/InterOp/RunParameters.php index 196c20a..61b8090 100644 --- a/src/InterOp/RunParameters.php +++ b/src/InterOp/RunParameters.php @@ -5,10 +5,36 @@ use Carbon\Carbon; /** - * @phpstan-type RFIDTag array{SerialNumber: string, PartNumber: int, ExpirationDate: string} - * @phpstan-type MiSeqParams array{Setup: array{ApplicationName: string}, RunID: string, MCSVersion: string, RTAVersion: string, RunStartDate: int, FlowcellRFIDTag: RFIDTag, PR2BottleRFIDTag?: RFIDTag, ReagentKitRFIDTag?: RFIDTag} - * @phpstan-type Consumable array{SerialNumber: string, Type: string, ExpirationDate?: string, LotNumber?: int, PartNumber?: int, Mode?: string|int, Version?: int} - * @phpstan-type I100Params array{Application: string, RunId: string, SystemSuiteVersion: string, ConsumableInfo: array{ConsumableInfo: array}} + * @phpstan-type RFIDTag array{ + * SerialNumber: string, + * PartNumber: int, + * ExpirationDate: string, + * } + * @phpstan-type MiSeqParams array{ + * Setup: array{ApplicationName: string}, + * RunID: string, + * MCSVersion: string, + * RTAVersion: string, + * RunStartDate: int, + * FlowcellRFIDTag: RFIDTag, + * PR2BottleRFIDTag?: RFIDTag, + * ReagentKitRFIDTag?: RFIDTag, + * } + * @phpstan-type Consumable array{ + * SerialNumber: string, + * Type: string, + * ExpirationDate?: string, + * LotNumber?: int, + * PartNumber?: int, + * Mode?: string|int, + * Version?: int, + * } + * @phpstan-type I100Params array{ + * Application: string, + * RunId: string, + * SystemSuiteVersion: string, + * ConsumableInfo: array{ConsumableInfo: array}, + * } */ class RunParameters { From 0c1e59ce45c2296e168ceac5e7a8ab2b96a3e639 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Fri, 24 Apr 2026 17:33:33 +0200 Subject: [PATCH 09/25] Fix named arguments --- src/InterOp/LaneResult.php | 52 +++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/InterOp/LaneResult.php b/src/InterOp/LaneResult.php index 013b2b7..8c2debf 100644 --- a/src/InterOp/LaneResult.php +++ b/src/InterOp/LaneResult.php @@ -51,50 +51,50 @@ public static function fromInterOpRow(array $row): self assert($phasingParts[1] !== 'nan', 'Unexpected nan prephasing rate for data read.'); $clusterStatistic = new ClusterStatistic( - density: $density, - clusterPassingFilter: $clusterPassingFilter, - clusterCount: SafeCast::toFloat($row['Reads']), - clusterCountPassingFilter: SafeCast::toFloat($row['Reads PF']) + $density, + $clusterPassingFilter, + SafeCast::toFloat($row['Reads']), + SafeCast::toFloat($row['Reads PF']) ); $sequencingQualityControl = new SequencingQualityControl( - q30: SafeCast::toFloat($row['%>=Q30']), - phasing: SafeCast::toFloat($phasingParts[0]), - prephasing: SafeCast::toFloat($phasingParts[1]), - aligned: $aligned, - error: $error + SafeCast::toFloat($row['%>=Q30']), + SafeCast::toFloat($phasingParts[0]), + SafeCast::toFloat($phasingParts[1]), + $aligned, + $error ); return new self( - clusterStatistic: $clusterStatistic, - sequencingQualityControl: $sequencingQualityControl, - intensityCycle: SafeCast::toInt($intensityCycle->value), - yield: SafeCast::toInt(SafeCast::toFloat($row['Yield']) * 1000000) + $clusterStatistic, + $sequencingQualityControl, + SafeCast::toInt($intensityCycle->value), + SafeCast::toInt(SafeCast::toFloat($row['Yield']) * 1000000) ); } public static function aggregate(self $a, self $b): self { $clusterStatistic = new ClusterStatistic( - density: DeviationValue::average($a->clusterStatistic->density, $b->clusterStatistic->density), - clusterPassingFilter: DeviationValue::average($a->clusterStatistic->clusterPassingFilter, $b->clusterStatistic->clusterPassingFilter), - clusterCount: $a->clusterStatistic->clusterCount + $b->clusterStatistic->clusterCount, - clusterCountPassingFilter: $a->clusterStatistic->clusterCountPassingFilter + $b->clusterStatistic->clusterCountPassingFilter + DeviationValue::average($a->clusterStatistic->density, $b->clusterStatistic->density), + DeviationValue::average($a->clusterStatistic->clusterPassingFilter, $b->clusterStatistic->clusterPassingFilter), + $a->clusterStatistic->clusterCount + $b->clusterStatistic->clusterCount, + $a->clusterStatistic->clusterCountPassingFilter + $b->clusterStatistic->clusterCountPassingFilter ); $sequencingQualityControl = new SequencingQualityControl( - q30: ($a->sequencingQualityControl->q30 + $b->sequencingQualityControl->q30) / 2, - phasing: ($a->sequencingQualityControl->phasing + $b->sequencingQualityControl->phasing) / 2, - prephasing: ($a->sequencingQualityControl->prephasing + $b->sequencingQualityControl->prephasing) / 2, - aligned: DeviationValue::average($a->sequencingQualityControl->aligned, $b->sequencingQualityControl->aligned), - error: DeviationValue::average($a->sequencingQualityControl->error, $b->sequencingQualityControl->error) + ($a->sequencingQualityControl->q30 + $b->sequencingQualityControl->q30) / 2, + ($a->sequencingQualityControl->phasing + $b->sequencingQualityControl->phasing) / 2, + ($a->sequencingQualityControl->prephasing + $b->sequencingQualityControl->prephasing) / 2, + DeviationValue::average($a->sequencingQualityControl->aligned, $b->sequencingQualityControl->aligned), + DeviationValue::average($a->sequencingQualityControl->error, $b->sequencingQualityControl->error) ); return new self( - clusterStatistic: $clusterStatistic, - sequencingQualityControl: $sequencingQualityControl, - intensityCycle: intdiv($a->intensityCycle + $b->intensityCycle, 2), - yield: $a->yield + $b->yield + $clusterStatistic, + $sequencingQualityControl, + intdiv($a->intensityCycle + $b->intensityCycle, 2), + $a->yield + $b->yield ); } } From 64d4ce65bbfb08ab17242f7f61eb845c3cb56ff9 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Fri, 24 Apr 2026 17:43:47 +0200 Subject: [PATCH 10/25] Fix stan errors --- src/InterOp/InterOpResult.php | 2 +- src/InterOp/RunParameters.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/InterOp/InterOpResult.php b/src/InterOp/InterOpResult.php index ddaab8c..a2967fd 100644 --- a/src/InterOp/InterOpResult.php +++ b/src/InterOp/InterOpResult.php @@ -57,7 +57,7 @@ public static function findDataReads(array $summary): array continue; } - if (substr($level, -3) !== '(I)') { + if (substr($level, -3) !== '(I)') { // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) $dataReads[] = $level; } } diff --git a/src/InterOp/RunParameters.php b/src/InterOp/RunParameters.php index 61b8090..98e7803 100644 --- a/src/InterOp/RunParameters.php +++ b/src/InterOp/RunParameters.php @@ -111,7 +111,7 @@ private function parseMiSeqI100(array $params): void $this->controlSoftwareVersion = $params['SystemSuiteVersion']; $this->realTimeAnalysisVersion = null; - $dateString = substr($this->info, 0, 8); + $dateString = substr($this->info, 0, 8); // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) $date = Carbon::createFromFormat('!Ymd', $dateString); assert($date instanceof Carbon, "Failed to parse i100 run date from RunId: {$this->info}."); $this->runDate = $date; @@ -149,9 +149,9 @@ private function stripZeroPrefix(string $serial): string return $serial; } - $prefix = substr($serial, 0, $pos); + $prefix = substr($serial, 0, $pos); // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) if ($prefix !== '' && trim($prefix, '0') === '') { - return substr($serial, $pos + 1); + return substr($serial, $pos + 1); // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) } return $serial; From 3c004573ac1694e71e2f1c807f191d86aeb832ce Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Fri, 24 Apr 2026 19:39:38 +0200 Subject: [PATCH 11/25] Fix stan errors --- src/InterOp/DeviationValue.php | 2 ++ src/InterOp/InterOpResult.php | 5 +---- src/InterOp/RunParameters.php | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/InterOp/DeviationValue.php b/src/InterOp/DeviationValue.php index 91131d2..3ee2f1d 100644 --- a/src/InterOp/DeviationValue.php +++ b/src/InterOp/DeviationValue.php @@ -29,6 +29,8 @@ public static function parse(string $raw): ?self return null; } + assert(isset($matches[1], $matches[2]), "Regex matched but captures missing in: {$raw}."); + return new self( SafeCast::toFloat($matches[1]), SafeCast::toFloat($matches[2]) diff --git a/src/InterOp/InterOpResult.php b/src/InterOp/InterOpResult.php index a2967fd..f9ec646 100644 --- a/src/InterOp/InterOpResult.php +++ b/src/InterOp/InterOpResult.php @@ -67,9 +67,6 @@ public static function findDataReads(array $summary): array throw new InterOpException("Expected at least 2 data reads, found {$count}."); } - $lastKey = array_key_last($dataReads); - assert($lastKey !== null, 'array_key_last() returned null despite count >= 2.'); - - return [$dataReads[0], $dataReads[$lastKey]]; + return [$dataReads[0], $dataReads[count($dataReads) - 1]]; } } diff --git a/src/InterOp/RunParameters.php b/src/InterOp/RunParameters.php index 98e7803..ae488da 100644 --- a/src/InterOp/RunParameters.php +++ b/src/InterOp/RunParameters.php @@ -63,7 +63,7 @@ public function __construct(array $params) { if (isset($params['Setup']['ApplicationName'])) { $this->parseMiSeq($params); - } elseif (isset($params['Application'])) { + } elseif (isset($params['Application'])) { // @phpstan-ignore isset.offset (runtime guard for unexpected input) $this->parseMiSeqI100($params); } else { throw new InterOpException('Unable to determine device type from RunParameters.'); From 032d8ba3914d081ca97cc00ae76b6ac767b99d8e Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 27 Apr 2026 12:16:17 +0200 Subject: [PATCH 12/25] fix: address review comments (no-brainer fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace @phpstan-ignore-line with structured @phpstan-ignore form - Change private methods to protected in library package - Replace Carbon::parse() with Carbon::createFromFormat() for known formats - Replace (string) cast with SafeCast::toString() + str_pad for pre-2010 dates - Remove unreachable assert in DeviationValue::parse() - Use existing $count variable instead of redundant count() call - Extract duplicate formatExpirationDate() call in DryCartridge block 🤖 Generated with Claude Code --- src/InterOp/DeviationValue.php | 2 -- src/InterOp/InterOpResult.php | 4 ++-- src/InterOp/RunParameters.php | 44 ++++++++++++++++++++++------------ 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/InterOp/DeviationValue.php b/src/InterOp/DeviationValue.php index 3ee2f1d..91131d2 100644 --- a/src/InterOp/DeviationValue.php +++ b/src/InterOp/DeviationValue.php @@ -29,8 +29,6 @@ public static function parse(string $raw): ?self return null; } - assert(isset($matches[1], $matches[2]), "Regex matched but captures missing in: {$raw}."); - return new self( SafeCast::toFloat($matches[1]), SafeCast::toFloat($matches[2]) diff --git a/src/InterOp/InterOpResult.php b/src/InterOp/InterOpResult.php index f9ec646..cf12515 100644 --- a/src/InterOp/InterOpResult.php +++ b/src/InterOp/InterOpResult.php @@ -57,7 +57,7 @@ public static function findDataReads(array $summary): array continue; } - if (substr($level, -3) !== '(I)') { // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) + if (substr($level, -3) !== '(I)') { // @phpstan-ignore theCodingMachineSafe.function (safe from PHP 8.0) $dataReads[] = $level; } } @@ -67,6 +67,6 @@ public static function findDataReads(array $summary): array throw new InterOpException("Expected at least 2 data reads, found {$count}."); } - return [$dataReads[0], $dataReads[count($dataReads) - 1]]; + return [$dataReads[0], $dataReads[$count - 1]]; } } diff --git a/src/InterOp/RunParameters.php b/src/InterOp/RunParameters.php index ae488da..abc7694 100644 --- a/src/InterOp/RunParameters.php +++ b/src/InterOp/RunParameters.php @@ -3,6 +3,7 @@ namespace MLL\Utils\InterOp; use Carbon\Carbon; +use MLL\Utils\SafeCast; /** * @phpstan-type RFIDTag array{ @@ -71,14 +72,14 @@ public function __construct(array $params) } /** @param MiSeqParams $params */ - private function parseMiSeq(array $params): void + protected function parseMiSeq(array $params): void { $this->application = $params['Setup']['ApplicationName']; $this->info = $params['RunID']; $this->controlSoftwareVersion = $params['MCSVersion']; $this->realTimeAnalysisVersion = $params['RTAVersion']; - $runStartDate = (string) $params['RunStartDate']; + $runStartDate = str_pad(SafeCast::toString($params['RunStartDate']), 6, '0', STR_PAD_LEFT); $date = Carbon::createFromFormat('!ymd', $runStartDate); assert($date instanceof Carbon, "Failed to parse MiSeq RunStartDate: {$runStartDate}."); $this->runDate = $date; @@ -104,14 +105,14 @@ private function parseMiSeq(array $params): void } /** @param I100Params $params */ - private function parseMiSeqI100(array $params): void + protected function parseMiSeqI100(array $params): void { $this->application = $params['Application']; $this->info = $params['RunId']; $this->controlSoftwareVersion = $params['SystemSuiteVersion']; $this->realTimeAnalysisVersion = null; - $dateString = substr($this->info, 0, 8); // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) + $dateString = substr($this->info, 0, 8); // @phpstan-ignore theCodingMachineSafe.function (safe from PHP 8.0) $date = Carbon::createFromFormat('!Ymd', $dateString); assert($date instanceof Carbon, "Failed to parse i100 run date from RunId: {$this->info}."); $this->runDate = $date; @@ -125,42 +126,55 @@ private function parseMiSeqI100(array $params): void $type = $consumable['Type']; if ($type === 'DryCartridge' || $type === 'WetCartridge') { + $expireDate = isset($consumable['ExpirationDate']) + ? $this->formatExpirationDate($consumable['ExpirationDate']) + : ''; + $this->reagents[] = [ 'name' => $consumable['SerialNumber'], - 'expire_date' => isset($consumable['ExpirationDate']) - ? $this->formatExpirationDate($consumable['ExpirationDate']) - : '', + 'expire_date' => $expireDate, ]; if ($type === 'DryCartridge') { $this->flowcell = $consumable['SerialNumber']; - if (isset($consumable['ExpirationDate'])) { - $this->flowcellExpirationDate = $this->formatExpirationDate($consumable['ExpirationDate']); + if ($expireDate !== '') { + $this->flowcellExpirationDate = $expireDate; } } } } } - private function stripZeroPrefix(string $serial): string + protected function stripZeroPrefix(string $serial): string { $pos = strpos($serial, '-'); if ($pos === false) { return $serial; } - $prefix = substr($serial, 0, $pos); // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) + $prefix = substr($serial, 0, $pos); // @phpstan-ignore theCodingMachineSafe.function (safe from PHP 8.0) if ($prefix !== '' && trim($prefix, '0') === '') { - return substr($serial, $pos + 1); // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) + return substr($serial, $pos + 1); // @phpstan-ignore theCodingMachineSafe.function (safe from PHP 8.0) } return $serial; } - private function formatExpirationDate(string $dateTime): string + protected function formatExpirationDate(string $dateTime): string { - $date = Carbon::parse($dateTime); + $formats = [DATE_ATOM, 'Y-m-d\TH:i:s']; + + foreach ($formats as $format) { + try { + $date = Carbon::createFromFormat($format, $dateTime); + if ($date instanceof Carbon) { + return $date->format('Y-m-d'); + } + } catch (\Carbon\Exceptions\InvalidFormatException $e) { + continue; + } + } - return $date->format('Y-m-d'); + throw new InterOpException("Failed to parse expiration date: {$dateTime}."); } } From c6aef618cdabc9e67f573d455c926215564c89d9 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 27 Apr 2026 12:35:03 +0200 Subject: [PATCH 13/25] fix(phpstan): use @phpstan-ignore-line for cross-version compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @phpstan-ignore only works in PHPStan 2.x, while @phpstan-ignore-line is recognized by both PHPStan 1.x and 2.x. 🤖 Generated with Claude Code --- src/InterOp/InterOpResult.php | 2 +- src/InterOp/RunParameters.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/InterOp/InterOpResult.php b/src/InterOp/InterOpResult.php index cf12515..17ad131 100644 --- a/src/InterOp/InterOpResult.php +++ b/src/InterOp/InterOpResult.php @@ -57,7 +57,7 @@ public static function findDataReads(array $summary): array continue; } - if (substr($level, -3) !== '(I)') { // @phpstan-ignore theCodingMachineSafe.function (safe from PHP 8.0) + if (substr($level, -3) !== '(I)') { // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) $dataReads[] = $level; } } diff --git a/src/InterOp/RunParameters.php b/src/InterOp/RunParameters.php index abc7694..e6f4b31 100644 --- a/src/InterOp/RunParameters.php +++ b/src/InterOp/RunParameters.php @@ -112,7 +112,7 @@ protected function parseMiSeqI100(array $params): void $this->controlSoftwareVersion = $params['SystemSuiteVersion']; $this->realTimeAnalysisVersion = null; - $dateString = substr($this->info, 0, 8); // @phpstan-ignore theCodingMachineSafe.function (safe from PHP 8.0) + $dateString = substr($this->info, 0, 8); // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) $date = Carbon::createFromFormat('!Ymd', $dateString); assert($date instanceof Carbon, "Failed to parse i100 run date from RunId: {$this->info}."); $this->runDate = $date; @@ -152,9 +152,9 @@ protected function stripZeroPrefix(string $serial): string return $serial; } - $prefix = substr($serial, 0, $pos); // @phpstan-ignore theCodingMachineSafe.function (safe from PHP 8.0) + $prefix = substr($serial, 0, $pos); // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) if ($prefix !== '' && trim($prefix, '0') === '') { - return substr($serial, $pos + 1); // @phpstan-ignore theCodingMachineSafe.function (safe from PHP 8.0) + return substr($serial, $pos + 1); // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) } return $serial; From 2606058d954460542336c2323bb94574c4c109f2 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 27 Apr 2026 12:51:52 +0200 Subject: [PATCH 14/25] fix: replace assert() with exceptions for runtime validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assert() is compiled out in production (zend.assertions=-1), so invalid external data would silently propagate as null/TypeError. Also adds missing test for DeviationValue::average(). 🤖 Generated with Claude Code --- src/InterOp/LaneResult.php | 29 ++++++++++++++++++++-------- src/InterOp/RunParameters.php | 8 ++++++-- tests/InterOp/DeviationValueTest.php | 11 +++++++++++ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/InterOp/LaneResult.php b/src/InterOp/LaneResult.php index 8c2debf..5caec53 100644 --- a/src/InterOp/LaneResult.php +++ b/src/InterOp/LaneResult.php @@ -31,24 +31,37 @@ public function __construct(ClusterStatistic $clusterStatistic, SequencingQualit public static function fromInterOpRow(array $row): self { $density = DeviationValue::parse($row['Density']); - assert($density instanceof DeviationValue, "Expected parseable Density, got: {$row['Density']}."); + if (! $density instanceof DeviationValue) { + throw new InterOpException("Expected parseable Density, got: {$row['Density']}."); + } $clusterPassingFilter = DeviationValue::parse($row['Cluster PF']); - assert($clusterPassingFilter instanceof DeviationValue, "Expected parseable Cluster PF, got: {$row['Cluster PF']}."); + if (! $clusterPassingFilter instanceof DeviationValue) { + throw new InterOpException("Expected parseable Cluster PF, got: {$row['Cluster PF']}."); + } $aligned = DeviationValue::parse($row['Aligned']); - assert($aligned instanceof DeviationValue, "Expected parseable Aligned, got: {$row['Aligned']}."); + if (! $aligned instanceof DeviationValue) { + throw new InterOpException("Expected parseable Aligned, got: {$row['Aligned']}."); + } $error = DeviationValue::parse($row['Error']); - assert($error instanceof DeviationValue, "Expected parseable Error, got: {$row['Error']}."); + if (! $error instanceof DeviationValue) { + throw new InterOpException("Expected parseable Error, got: {$row['Error']}."); + } $intensityCycle = DeviationValue::parse($row['Intensity C1']); - assert($intensityCycle instanceof DeviationValue, "Expected parseable Intensity C1, got: {$row['Intensity C1']}."); + if (! $intensityCycle instanceof DeviationValue) { + throw new InterOpException("Expected parseable Intensity C1, got: {$row['Intensity C1']}."); + } $phasingParts = explode(' / ', $row['Legacy Phasing/Prephasing Rate']); - assert(count($phasingParts) === 2, "Expected 'phasing / prephasing' format, got: {$row['Legacy Phasing/Prephasing Rate']}."); - assert($phasingParts[0] !== 'nan', 'Unexpected nan phasing rate for data read.'); - assert($phasingParts[1] !== 'nan', 'Unexpected nan prephasing rate for data read.'); + if (count($phasingParts) !== 2) { + throw new InterOpException("Expected 'phasing / prephasing' format, got: {$row['Legacy Phasing/Prephasing Rate']}."); + } + if ($phasingParts[0] === 'nan' || $phasingParts[1] === 'nan') { + throw new InterOpException('Unexpected nan phasing rate for data read.'); + } $clusterStatistic = new ClusterStatistic( $density, diff --git a/src/InterOp/RunParameters.php b/src/InterOp/RunParameters.php index e6f4b31..25b4b45 100644 --- a/src/InterOp/RunParameters.php +++ b/src/InterOp/RunParameters.php @@ -81,7 +81,9 @@ protected function parseMiSeq(array $params): void $runStartDate = str_pad(SafeCast::toString($params['RunStartDate']), 6, '0', STR_PAD_LEFT); $date = Carbon::createFromFormat('!ymd', $runStartDate); - assert($date instanceof Carbon, "Failed to parse MiSeq RunStartDate: {$runStartDate}."); + if (! $date instanceof Carbon) { + throw new InterOpException("Failed to parse MiSeq RunStartDate: {$runStartDate}."); + } $this->runDate = $date; $this->flowcell = $this->stripZeroPrefix($params['FlowcellRFIDTag']['SerialNumber']); @@ -114,7 +116,9 @@ protected function parseMiSeqI100(array $params): void $dateString = substr($this->info, 0, 8); // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) $date = Carbon::createFromFormat('!Ymd', $dateString); - assert($date instanceof Carbon, "Failed to parse i100 run date from RunId: {$this->info}."); + if (! $date instanceof Carbon) { + throw new InterOpException("Failed to parse i100 run date from RunId: {$this->info}."); + } $this->runDate = $date; $consumables = $params['ConsumableInfo']['ConsumableInfo']; diff --git a/tests/InterOp/DeviationValueTest.php b/tests/InterOp/DeviationValueTest.php index 84da30f..5380788 100644 --- a/tests/InterOp/DeviationValueTest.php +++ b/tests/InterOp/DeviationValueTest.php @@ -25,6 +25,17 @@ public function testParse(string $input, ?float $expectedValue, ?float $expected self::assertSame($expectedDeviation, $result->deviation); } + public function testAverage(): void + { + $a = new DeviationValue(100.0, 10.0); + $b = new DeviationValue(200.0, 30.0); + + $avg = DeviationValue::average($a, $b); + + self::assertSame(150.0, $avg->value); + self::assertSame(20.0, $avg->deviation); + } + /** @return iterable */ public static function parseProvider(): iterable { From cdb249d38bad7dcef77757682105bf1240ef1178 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 27 Apr 2026 13:25:11 +0200 Subject: [PATCH 15/25] fix(phpstan): restore assert for preg_match offset access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHPStan 2.x needs assert(isset()) to narrow preg_match capture types, even when the match return value guarantees their existence. 🤖 Generated with Claude Code --- src/InterOp/DeviationValue.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/InterOp/DeviationValue.php b/src/InterOp/DeviationValue.php index 91131d2..92630aa 100644 --- a/src/InterOp/DeviationValue.php +++ b/src/InterOp/DeviationValue.php @@ -28,6 +28,7 @@ public static function parse(string $raw): ?self if (preg_match('/^([\d.]+)\s*\+\/-\s*([\d.]+)$/', $raw, $matches) !== 1) { return null; } + assert(isset($matches[1], $matches[2])); return new self( SafeCast::toFloat($matches[1]), From 5e7981db2e4ce970ea36da42ac3e0e0b2364aad9 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 27 Apr 2026 13:27:42 +0200 Subject: [PATCH 16/25] fix: validate required row keys in LaneResult::fromInterOpRow() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missing or renamed keys would silently produce null and propagate through SafeCast/DeviationValue. Validate all expected keys upfront. 🤖 Generated with Claude Code --- src/InterOp/LaneResult.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/InterOp/LaneResult.php b/src/InterOp/LaneResult.php index 5caec53..a436f93 100644 --- a/src/InterOp/LaneResult.php +++ b/src/InterOp/LaneResult.php @@ -30,6 +30,12 @@ public function __construct(ClusterStatistic $clusterStatistic, SequencingQualit */ public static function fromInterOpRow(array $row): self { + $requiredKeys = ['Density', 'Cluster PF', 'Aligned', 'Error', 'Intensity C1', 'Legacy Phasing/Prephasing Rate', 'Reads', 'Reads PF', '%>=Q30', 'Yield']; + $missingKeys = array_diff($requiredKeys, array_keys($row)); + if ($missingKeys !== []) { + throw new InterOpException('Missing InterOp row keys: ' . implode(', ', $missingKeys) . '.'); + } + $density = DeviationValue::parse($row['Density']); if (! $density instanceof DeviationValue) { throw new InterOpException("Expected parseable Density, got: {$row['Density']}."); From 8237496f3635f17ad55d10d5f432eb7f1afba46f Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 27 Apr 2026 13:35:36 +0200 Subject: [PATCH 17/25] refactor: extract MetaInfo payload type to class-level PHPDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the inline @var type annotation from the constructor to a @phpstan-type on the class, improving readability. 🤖 Generated with Claude Code --- src/InterOp/MetaInfo.php | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/InterOp/MetaInfo.php b/src/InterOp/MetaInfo.php index d866bf0..665c441 100644 --- a/src/InterOp/MetaInfo.php +++ b/src/InterOp/MetaInfo.php @@ -7,6 +7,14 @@ /** * @phpstan-import-type MiSeqParams from RunParameters * @phpstan-import-type I100Params from RunParameters + * @phpstan-type MetaInfoPayload array{ + * runParameters: array{RunParameters: MiSeqParams|I100Params}, + * interop: array{ + * summary: array>, + * reads: array>>, + * }, + * uncPath: string, + * } */ class MetaInfo { @@ -18,16 +26,7 @@ class MetaInfo public function __construct(string $json) { - /** - * @var array{ - * runParameters: array{RunParameters: MiSeqParams|I100Params}, - * interop: array{ - * summary: array>, - * reads: array>>, - * }, - * uncPath: string, - * } $data - */ + /** @var MetaInfoPayload $data */ $data = json_decode($json, true); $this->runParameters = new RunParameters($data['runParameters']['RunParameters']); From 20e8e391dac3fc58741ea58415f76f2f7c09ec4c Mon Sep 17 00:00:00 2001 From: simbig <26680884+simbig@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:36:09 +0000 Subject: [PATCH 18/25] Apply php-cs-fixer changes --- src/InterOp/MetaInfo.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/InterOp/MetaInfo.php b/src/InterOp/MetaInfo.php index 665c441..c99452d 100644 --- a/src/InterOp/MetaInfo.php +++ b/src/InterOp/MetaInfo.php @@ -7,6 +7,7 @@ /** * @phpstan-import-type MiSeqParams from RunParameters * @phpstan-import-type I100Params from RunParameters + * * @phpstan-type MetaInfoPayload array{ * runParameters: array{RunParameters: MiSeqParams|I100Params}, * interop: array{ From 0e640b7c6a67eb8e524af6a4ef108ae254d3e2ad Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 27 Apr 2026 13:37:47 +0200 Subject: [PATCH 19/25] fix(phpstan): use native preg_match for capture type narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Safe\preg_match prevents PHPStan 2.x from narrowing capture group types, causing offsetAccess.notFound errors. Use native preg_match with @phpstan-ignore-line for thecodingmachine rule compatibility. 🤖 Generated with Claude Code --- src/InterOp/DeviationValue.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/InterOp/DeviationValue.php b/src/InterOp/DeviationValue.php index 92630aa..bf6ba73 100644 --- a/src/InterOp/DeviationValue.php +++ b/src/InterOp/DeviationValue.php @@ -4,8 +4,6 @@ use MLL\Utils\SafeCast; -use function Safe\preg_match; - class DeviationValue { public float $value; @@ -25,10 +23,9 @@ public function __construct(float $value, float $deviation) */ public static function parse(string $raw): ?self { - if (preg_match('/^([\d.]+)\s*\+\/-\s*([\d.]+)$/', $raw, $matches) !== 1) { + if (preg_match('/^([\d.]+)\s*\+\/-\s*([\d.]+)$/', $raw, $matches) !== 1) { // @phpstan-ignore-line theCodingMachineSafe.function (native preg_match needed for PHPStan 2.x capture type narrowing) return null; } - assert(isset($matches[1], $matches[2])); return new self( SafeCast::toFloat($matches[1]), From b7a1f2afa4a52a48df3a98ac62058ace7bc7fc93 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Wed, 29 Apr 2026 17:19:06 +0200 Subject: [PATCH 20/25] Prevent Single End Sequencing Results --- src/InterOp/InterOpResult.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/InterOp/InterOpResult.php b/src/InterOp/InterOpResult.php index 17ad131..0d48654 100644 --- a/src/InterOp/InterOpResult.php +++ b/src/InterOp/InterOpResult.php @@ -50,6 +50,7 @@ public function __construct(array $summary, array $reads) */ public static function findDataReads(array $summary): array { + // Summary count depends on indexing type (Sinlge or Dual) and sequencing type (Single-End or Paired-End). Possible reads are: Read 1, Read 2, Read 3, Non-indexed or Total $dataReads = []; foreach ($summary as $entry) { $level = $entry['Level']; @@ -57,16 +58,20 @@ public static function findDataReads(array $summary): array continue; } + // Identify index reads if (substr($level, -3) !== '(I)') { // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) $dataReads[] = $level; } } $count = count($dataReads); - if ($count < 2) { - throw new InterOpException("Expected at least 2 data reads, found {$count}."); + if ($count === 0 || $count > 2){ + throw new InterOpException("Unlogic behaviour. Expect 2 data reads, found {$count}."); + } + if($count === 1){ + throw new InterOpException("Single-End Sequencing results are not implemented."); } - return [$dataReads[0], $dataReads[$count - 1]]; + return [$dataReads[0], $dataReads[1]]; } } From 2f59c68d3bc31fb733ea07660150f034b11a31ba Mon Sep 17 00:00:00 2001 From: dhaupt88 <133017448+dhaupt88@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:19:55 +0000 Subject: [PATCH 21/25] Apply php-cs-fixer changes --- src/InterOp/InterOpResult.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/InterOp/InterOpResult.php b/src/InterOp/InterOpResult.php index 0d48654..7f9a6f4 100644 --- a/src/InterOp/InterOpResult.php +++ b/src/InterOp/InterOpResult.php @@ -65,11 +65,11 @@ public static function findDataReads(array $summary): array } $count = count($dataReads); - if ($count === 0 || $count > 2){ + if ($count === 0 || $count > 2) { throw new InterOpException("Unlogic behaviour. Expect 2 data reads, found {$count}."); } - if($count === 1){ - throw new InterOpException("Single-End Sequencing results are not implemented."); + if ($count === 1) { + throw new InterOpException('Single-End Sequencing results are not implemented.'); } return [$dataReads[0], $dataReads[1]]; From 09be5e7a8ce39dfc706304841d1bb19406f0fe37 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Wed, 29 Apr 2026 17:39:23 +0200 Subject: [PATCH 22/25] Add assertions --- src/InterOp/ClusterStatistic.php | 10 +++--- src/InterOp/LaneResult.php | 4 +-- tests/InterOp/InterOpResultTest.php | 55 +++++++++++++++++++++++++++++ tests/InterOp/MetaInfoTest.php | 7 ++-- tests/InterOp/RunParametersTest.php | 18 ++++++++++ 5 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 tests/InterOp/RunParametersTest.php diff --git a/src/InterOp/ClusterStatistic.php b/src/InterOp/ClusterStatistic.php index 06b196e..a48b434 100644 --- a/src/InterOp/ClusterStatistic.php +++ b/src/InterOp/ClusterStatistic.php @@ -8,15 +8,15 @@ class ClusterStatistic public DeviationValue $clusterPassingFilter; - public float $clusterCount; + public float $clusterCountMillions; - public float $clusterCountPassingFilter; + public float $clusterCountPassingFilterMillions; - public function __construct(DeviationValue $density, DeviationValue $clusterPassingFilter, float $clusterCount, float $clusterCountPassingFilter) + public function __construct(DeviationValue $density, DeviationValue $clusterPassingFilter, float $clusterCountMillions, float $clusterCountPassingFilterMillions) { $this->density = $density; $this->clusterPassingFilter = $clusterPassingFilter; - $this->clusterCount = $clusterCount; - $this->clusterCountPassingFilter = $clusterCountPassingFilter; + $this->clusterCountMillions = $clusterCountMillions; + $this->clusterCountPassingFilterMillions = $clusterCountPassingFilterMillions; } } diff --git a/src/InterOp/LaneResult.php b/src/InterOp/LaneResult.php index a436f93..e37f16e 100644 --- a/src/InterOp/LaneResult.php +++ b/src/InterOp/LaneResult.php @@ -97,8 +97,8 @@ public static function aggregate(self $a, self $b): self $clusterStatistic = new ClusterStatistic( DeviationValue::average($a->clusterStatistic->density, $b->clusterStatistic->density), DeviationValue::average($a->clusterStatistic->clusterPassingFilter, $b->clusterStatistic->clusterPassingFilter), - $a->clusterStatistic->clusterCount + $b->clusterStatistic->clusterCount, - $a->clusterStatistic->clusterCountPassingFilter + $b->clusterStatistic->clusterCountPassingFilter + $a->clusterStatistic->clusterCountMillions + $b->clusterStatistic->clusterCountMillions, + $a->clusterStatistic->clusterCountPassingFilterMillions + $b->clusterStatistic->clusterCountPassingFilterMillions ); $sequencingQualityControl = new SequencingQualityControl( diff --git a/tests/InterOp/InterOpResultTest.php b/tests/InterOp/InterOpResultTest.php index f917557..55fb700 100644 --- a/tests/InterOp/InterOpResultTest.php +++ b/tests/InterOp/InterOpResultTest.php @@ -2,6 +2,7 @@ namespace MLL\Utils\Tests\InterOp; +use MLL\Utils\InterOp\InterOpException; use MLL\Utils\InterOp\InterOpResult; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -22,6 +23,60 @@ public function testFindDataReads(string $description, array $summary, string $e self::assertSame($expectedLast, $last, "{$description}: last data read"); } + public function testThrowsOnNoDataReads(): void + { + $this->expectException(InterOpException::class); + + InterOpResult::findDataReads([ + ['Level' => 'Read 1 (I)'], + ['Level' => 'Read 2 (I)'], + ['Level' => 'Non-indexed'], + ['Level' => 'Total'], + ]); + } + + public function testThrowsOnSingleDataRead(): void + { + $this->expectException(InterOpException::class); + + InterOpResult::findDataReads([ + ['Level' => 'Read 1'], + ['Level' => 'Read 2 (I)'], + ['Level' => 'Non-indexed'], + ['Level' => 'Total'], + ]); + } + + public function testThrowsOnMissingReadData(): void + { + $summary = [ + ['Level' => 'Read 1'], + ['Level' => 'Read 2 (I)'], + ['Level' => 'Read 3'], + ['Level' => 'Non-indexed'], + ['Level' => 'Total'], + ]; + + $this->expectException(InterOpException::class); + + new InterOpResult($summary, []); + } + + public function testThrowsOnEmptyReadRows(): void + { + $summary = [ + ['Level' => 'Read 1'], + ['Level' => 'Read 2 (I)'], + ['Level' => 'Read 3'], + ['Level' => 'Non-indexed'], + ['Level' => 'Total'], + ]; + + $this->expectException(InterOpException::class); + + new InterOpResult($summary, ['Read 1' => [], 'Read 3' => []]); + } + /** @return iterable>, expectedFirst: string, expectedLast: string}> */ public static function dataReadDetectionProvider(): iterable { diff --git a/tests/InterOp/MetaInfoTest.php b/tests/InterOp/MetaInfoTest.php index db9a3ab..5aa192e 100644 --- a/tests/InterOp/MetaInfoTest.php +++ b/tests/InterOp/MetaInfoTest.php @@ -31,8 +31,8 @@ public function testParseMiSeq(): void self::assertSame(851.0, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->density->value); self::assertSame(32.0, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->density->deviation); self::assertSame(96.54, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterPassingFilter->value); - self::assertSame(21.22, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterCount); - self::assertSame(20.48, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterCountPassingFilter); + self::assertSame(21.22, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterCountMillions); + self::assertSame(20.48, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterCountPassingFilterMillions); self::assertSame(88.2, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->q30); self::assertSame(0.085, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->phasing); @@ -74,7 +74,10 @@ public function testParseMiSeqI100(): void self::assertSame('2026-10-17', $metaInfo->runParameters->reagents[1]['expire_date']); self::assertSame(1000.0, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->density->value); + self::assertSame(0.0, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->density->deviation); self::assertSame(62.27, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterPassingFilter->value); + self::assertSame(7.91, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterCountMillions); + self::assertSame(4.92, $metaInfo->interOpResult->resultsForRead1->clusterStatistic->clusterCountPassingFilterMillions); self::assertSame(95.35, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->q30); self::assertSame(0.075, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->phasing); self::assertSame(0.042, $metaInfo->interOpResult->resultsForRead1->sequencingQualityControl->prephasing); diff --git a/tests/InterOp/RunParametersTest.php b/tests/InterOp/RunParametersTest.php new file mode 100644 index 0000000..139e906 --- /dev/null +++ b/tests/InterOp/RunParametersTest.php @@ -0,0 +1,18 @@ +expectException(InterOpException::class); + + // @phpstan-ignore argument.type (intentionally invalid input for error path test) + new RunParameters(['UnknownKey' => 'value']); + } +} From b52b71a06b3dbf4c11e860169f2c850378fff4e3 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Thu, 30 Apr 2026 09:07:54 +0200 Subject: [PATCH 23/25] Use non-index result as well --- src/InterOp/InterOpResult.php | 20 +++++++++++++++++++- src/InterOp/RunResult.php | 21 +++++++++++++++++++-- tests/InterOp/MetaInfoTest.php | 3 ++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/InterOp/InterOpResult.php b/src/InterOp/InterOpResult.php index 7f9a6f4..7426258 100644 --- a/src/InterOp/InterOpResult.php +++ b/src/InterOp/InterOpResult.php @@ -28,10 +28,12 @@ public function __construct(array $summary, array $reads) throw new InterOpException("Reads data missing or empty for: {$lastDataRead}."); } + $nonIndexedRow = self::findNonIndexedRow($summary); + // First row per read key is the Surface "-" aggregate across all tiles $this->resultsForRead1 = LaneResult::fromInterOpRow($read1Rows[0]); $this->resultsForRead2 = LaneResult::fromInterOpRow($read2Rows[0]); - $this->resultsForRun = RunResult::fromLaneResults($this->resultsForRead1, $this->resultsForRead2); + $this->resultsForRun = RunResult::fromLaneResults($this->resultsForRead1, $this->resultsForRead2, $nonIndexedRow); } /** @@ -74,4 +76,20 @@ public static function findDataReads(array $summary): array return [$dataReads[0], $dataReads[1]]; } + + /** + * @param array> $summary + * + * @return array + */ + public static function findNonIndexedRow(array $summary): array + { + foreach ($summary as $entry) { + if ($entry['Level'] === 'Non-indexed') { + return $entry; + } + } + + throw new InterOpException('No "Non-indexed" summary row found.'); + } } diff --git a/src/InterOp/RunResult.php b/src/InterOp/RunResult.php index f8e4d69..840f224 100644 --- a/src/InterOp/RunResult.php +++ b/src/InterOp/RunResult.php @@ -2,6 +2,8 @@ namespace MLL\Utils\InterOp; +use MLL\Utils\SafeCast; + class RunResult { public ClusterStatistic $clusterStatistic; @@ -14,10 +16,25 @@ public function __construct(ClusterStatistic $clusterStatistic, SequencingQualit $this->sequencingQualityControl = $sequencingQualityControl; } - public static function fromLaneResults(LaneResult $read1, LaneResult $read2): self + /** @param array $nonIndexedRow */ + public static function fromLaneResults(LaneResult $read1, LaneResult $read2, array $nonIndexedRow): self { $aggregated = LaneResult::aggregate($read1, $read2); - return new self($aggregated->clusterStatistic, $aggregated->sequencingQualityControl); + $q30 = SafeCast::toFloat($nonIndexedRow['%>=Q30']); + + $alignedValue = SafeCast::toFloat($nonIndexedRow['Aligned']); + $alignedDeviation = ($read1->sequencingQualityControl->aligned->deviation + $read2->sequencingQualityControl->aligned->deviation) / 2; + $aligned = new DeviationValue($alignedValue, $alignedDeviation); + + $sequencingQualityControl = new SequencingQualityControl( + $q30, + $aggregated->sequencingQualityControl->phasing, + $aggregated->sequencingQualityControl->prephasing, + $aligned, + $aggregated->sequencingQualityControl->error + ); + + return new self($aggregated->clusterStatistic, $sequencingQualityControl); } } diff --git a/tests/InterOp/MetaInfoTest.php b/tests/InterOp/MetaInfoTest.php index 5aa192e..4b0074d 100644 --- a/tests/InterOp/MetaInfoTest.php +++ b/tests/InterOp/MetaInfoTest.php @@ -48,7 +48,8 @@ public function testParseMiSeq(): void self::assertSame(6140000, $metaInfo->interOpResult->resultsForRead2->yield); $runQC = $metaInfo->interOpResult->resultsForRun->sequencingQualityControl; - self::assertEqualsWithDelta(83.195, $runQC->q30, 0.001); + self::assertSame(83.2, $runQC->q30); + self::assertSame(6.11, $runQC->aligned->value); self::assertEqualsWithDelta(0.065, $runQC->phasing, 0.001); self::assertSame('example-server/miseq_active\\230421_M02074_0859_000000000-KT6CY\\meta-info.json', $metaInfo->uncPath); From bf6416a1aaeb172e74c99ffb0829338afbd1485a Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Thu, 30 Apr 2026 15:00:25 +0200 Subject: [PATCH 24/25] Use non-index result as well --- src/InterOp/InterOpResult.php | 20 ++++++++++---------- tests/InterOp/InterOpResultTest.php | 6 +++--- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/InterOp/InterOpResult.php b/src/InterOp/InterOpResult.php index 7426258..32109ea 100644 --- a/src/InterOp/InterOpResult.php +++ b/src/InterOp/InterOpResult.php @@ -16,16 +16,16 @@ class InterOpResult */ public function __construct(array $summary, array $reads) { - [$firstDataRead, $lastDataRead] = self::findDataReads($summary); + [$dataRead1Tag, $dataRead2Tag] = self::findDataReadTags($summary); - $read1Rows = $reads[$firstDataRead] ?? null; + $read1Rows = $reads[$dataRead1Tag] ?? null; if ($read1Rows === null || $read1Rows === []) { - throw new InterOpException("Reads data missing or empty for: {$firstDataRead}."); + throw new InterOpException("Reads data missing or empty for: {$dataRead1Tag}."); } - $read2Rows = $reads[$lastDataRead] ?? null; + $read2Rows = $reads[$dataRead2Tag] ?? null; if ($read2Rows === null || $read2Rows === []) { - throw new InterOpException("Reads data missing or empty for: {$lastDataRead}."); + throw new InterOpException("Reads data missing or empty for: {$dataRead2Tag}."); } $nonIndexedRow = self::findNonIndexedRow($summary); @@ -50,10 +50,10 @@ public function __construct(array $summary, array $reads) * * @return array{0: string, 1: string} */ - public static function findDataReads(array $summary): array + public static function findDataReadTags(array $summary): array { // Summary count depends on indexing type (Sinlge or Dual) and sequencing type (Single-End or Paired-End). Possible reads are: Read 1, Read 2, Read 3, Non-indexed or Total - $dataReads = []; + $dataReadTags = []; foreach ($summary as $entry) { $level = $entry['Level']; if ($level === 'Non-indexed' || $level === 'Total') { @@ -62,11 +62,11 @@ public static function findDataReads(array $summary): array // Identify index reads if (substr($level, -3) !== '(I)') { // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) - $dataReads[] = $level; + $dataReadTags[] = $level; } } - $count = count($dataReads); + $count = count($dataReadTags); if ($count === 0 || $count > 2) { throw new InterOpException("Unlogic behaviour. Expect 2 data reads, found {$count}."); } @@ -74,7 +74,7 @@ public static function findDataReads(array $summary): array throw new InterOpException('Single-End Sequencing results are not implemented.'); } - return [$dataReads[0], $dataReads[1]]; + return [$dataReadTags[0], $dataReadTags[1]]; } /** diff --git a/tests/InterOp/InterOpResultTest.php b/tests/InterOp/InterOpResultTest.php index 55fb700..5a58229 100644 --- a/tests/InterOp/InterOpResultTest.php +++ b/tests/InterOp/InterOpResultTest.php @@ -17,7 +17,7 @@ final class InterOpResultTest extends TestCase #[DataProvider('dataReadDetectionProvider')] public function testFindDataReads(string $description, array $summary, string $expectedFirst, string $expectedLast): void { - [$first, $last] = InterOpResult::findDataReads($summary); + [$first, $last] = InterOpResult::findDataReadTags($summary); self::assertSame($expectedFirst, $first, "{$description}: first data read"); self::assertSame($expectedLast, $last, "{$description}: last data read"); @@ -27,7 +27,7 @@ public function testThrowsOnNoDataReads(): void { $this->expectException(InterOpException::class); - InterOpResult::findDataReads([ + InterOpResult::findDataReadTags([ ['Level' => 'Read 1 (I)'], ['Level' => 'Read 2 (I)'], ['Level' => 'Non-indexed'], @@ -39,7 +39,7 @@ public function testThrowsOnSingleDataRead(): void { $this->expectException(InterOpException::class); - InterOpResult::findDataReads([ + InterOpResult::findDataReadTags([ ['Level' => 'Read 1'], ['Level' => 'Read 2 (I)'], ['Level' => 'Non-indexed'], From 2b977b76e4d34bd1a7cd9bd058af31a5c34745d2 Mon Sep 17 00:00:00 2001 From: Dennis Haupt Date: Thu, 30 Apr 2026 15:11:04 +0200 Subject: [PATCH 25/25] Fix stan --- phpstan/php-below-8.1.neon | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpstan/php-below-8.1.neon b/phpstan/php-below-8.1.neon index a2f3659..9a45de4 100644 --- a/phpstan/php-below-8.1.neon +++ b/phpstan/php-below-8.1.neon @@ -34,3 +34,5 @@ parameters: # Existing code with @phpstan-ignore that older versions don't understand - message: '#Cannot access property \$name on SimpleXMLElement\|null\.#' path: ../src/LightcyclerExportSheet/LightcyclerXmlParser.php + - message: '#array\{UnknownKey: .* given\.#' + path: ../tests/InterOp/RunParametersTest.php