From 967740c0206344d2fe2c707eac3978e5d7651e4b Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Tue, 10 Mar 2026 15:31:57 +0100 Subject: [PATCH 01/15] feat: add MolarityConverter and TapeStation CompactRegionTable parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MolarityConverter converts between mass concentration (ng/µL) and molar concentration (nmol/L) for double-stranded DNA fragments. CompactRegionTableParser reads Agilent TapeStation Compact Region Table CSV exports, handling delimiter detection and encoding-corrupted µ characters. Co-Authored-By: Claude Opus 4.6 --- src/Concentration/MolarityConverter.php | 25 ++++ src/TapeStation/CompactRegionTableParser.php | 105 ++++++++++++++++ src/TapeStation/CompactRegionTableRecord.php | 19 +++ tests/Concentration/MolarityConverterTest.php | 36 ++++++ .../CompactRegionTableParserTest.php | 114 ++++++++++++++++++ 5 files changed, 299 insertions(+) create mode 100644 src/Concentration/MolarityConverter.php create mode 100644 src/TapeStation/CompactRegionTableParser.php create mode 100644 src/TapeStation/CompactRegionTableRecord.php create mode 100644 tests/Concentration/MolarityConverterTest.php create mode 100644 tests/TapeStation/CompactRegionTableParserTest.php diff --git a/src/Concentration/MolarityConverter.php b/src/Concentration/MolarityConverter.php new file mode 100644 index 0000000..7bec690 --- /dev/null +++ b/src/Concentration/MolarityConverter.php @@ -0,0 +1,25 @@ + 0); + + return ($concentrationNgPerUl / (self::AVERAGE_DALTONS_PER_BASE_PAIR * $averageFragmentSizeBp)) * 1_000_000; + } + + /** Convert molar concentration (nmol/L) to mass concentration (ng/µL). */ + public static function nmolPerLToNgPerUl(float $molarityNmolPerL, int $averageFragmentSizeBp): float + { + assert($averageFragmentSizeBp > 0); + + return ($molarityNmolPerL * self::AVERAGE_DALTONS_PER_BASE_PAIR * $averageFragmentSizeBp) / 1_000_000; + } +} diff --git a/src/TapeStation/CompactRegionTableParser.php b/src/TapeStation/CompactRegionTableParser.php new file mode 100644 index 0000000..aec3503 --- /dev/null +++ b/src/TapeStation/CompactRegionTableParser.php @@ -0,0 +1,105 @@ + */ + public static function parse(string $csvContent): Collection + { + $csvContent = StringUtil::toUTF8($csvContent); + $delimiter = self::detectDelimiter($csvContent); + + $rows = CSVArray::toArray($csvContent, $delimiter); + + $records = new Collection(); + foreach ($rows as $row) { + $records->push(self::recordFromRow($row)); + } + + return $records; + } + + /** @param array $row */ + private static function recordFromRow(array $row): CompactRegionTableRecord + { + return new CompactRegionTableRecord( + fileName: $row['FileName'] ?? '', + wellID: $row['WellId'] ?? '', + sampleDescription: $row['Sample Description'] ?? '', + fromBp: self::parseInt($row, 'From [bp]', 'From [nt]'), + toBp: self::parseInt($row, 'To [bp]', 'To [nt]'), + averageSizeBp: self::parseInt($row, 'Average Size [bp]', 'Average Size [nt]'), + concentrationNgPerUl: self::parseConcentration($row), + regionMolarityNmolPerL: self::parseFloat($row[self::MOLARITY_KEY] ?? '0'), + percentOfTotal: self::parseFloat($row['% of Total'] ?? '0'), + regionComment: $row['Region Comment'] ?? '', + ); + } + + /** + * The concentration column header contains µ which may be corrupted. + * Match by prefix instead of exact key. + * + * @param array $row + */ + private static function parseConcentration(array $row): float + { + foreach ($row as $key => $value) { + if (str_starts_with($key, self::CONCENTRATION_KEY_PREFIX)) { + return self::parseFloat($value); + } + } + + throw new \RuntimeException('Concentration column not found. Expected column starting with "' . self::CONCENTRATION_KEY_PREFIX . '"'); + } + + /** + * Try primary key first, fall back to alternative (bp vs nt). + * + * @param array $row + */ + private static function parseInt(array $row, string $primaryKey, string $fallbackKey): int + { + $value = $row[$primaryKey] ?? $row[$fallbackKey] ?? null; + if ($value === null || $value === '') { + return 0; + } + + return (int) round(self::parseFloat($value)); + } + + private static function parseFloat(string $value): float + { + $trimmed = trim($value); + if ($trimmed === '' || ! is_numeric($trimmed)) { + return 0.0; + } + + return (float) $trimmed; + } + + private static function detectDelimiter(string $csvContent): string + { + $firstLine = strtok($csvContent, "\n"); + if ($firstLine === false) { + $firstLine = ''; + } + + $semicolonCount = substr_count($firstLine, ';'); + $commaCount = substr_count($firstLine, ','); + + return $semicolonCount > $commaCount ? ';' : ','; + } +} diff --git a/src/TapeStation/CompactRegionTableRecord.php b/src/TapeStation/CompactRegionTableRecord.php new file mode 100644 index 0000000..b2a6615 --- /dev/null +++ b/src/TapeStation/CompactRegionTableRecord.php @@ -0,0 +1,19 @@ + */ + public static function conversionPairs(): iterable + { + // Values verified against TapeStation Excel pooling sheet + yield 'FLT3-ITD sample 11.4 ng/µl, 489 bp' => [35.3, 11.4, 489]; + yield 'FLT3-ITD sample 9.35 ng/µl, 491 bp' => [28.9, 9.35, 491]; + yield 'Immunoreceptor TRB 400 bp' => [37.9, 10.0, 400]; + yield 'Immunoreceptor TRG 300 bp' => [50.5, 10.0, 300]; + yield 'Low concentration' => [0.09, 0.03, 488]; + } +} diff --git a/tests/TapeStation/CompactRegionTableParserTest.php b/tests/TapeStation/CompactRegionTableParserTest.php new file mode 100644 index 0000000..579de0d --- /dev/null +++ b/tests/TapeStation/CompactRegionTableParserTest.php @@ -0,0 +1,114 @@ +first(); + self::assertInstanceOf(CompactRegionTableRecord::class, $first); + self::assertSame('A1', $first->wellID); + self::assertSame('Poko_FLT3-ITD_A1', $first->sampleDescription); + self::assertSame(200, $first->fromBp); + self::assertSame(1000, $first->toBp); + self::assertSame(505, $first->averageSizeBp); + self::assertSame(15.0, $first->concentrationNgPerUl); + self::assertSame(46.6, $first->regionMolarityNmolPerL); + self::assertEqualsWithDelta(87.94, $first->percentOfTotal, 0.01); + self::assertSame('FLT3-ITD MRD', $first->regionComment); + } + + public function testParseCommaDelimited(): void + { + $csv = <<<'CSV' + FileName,WellId,Sample Description,From [bp],To [bp],Average Size [bp],Conc. [ng/µl],Region Molarity [nmol/l],% of Total,Region Comment + 2026-02-25.D1000,A8,22-000001,200,700,320,7.61,36.1,92.5,IDT + CSV; + + $records = CompactRegionTableParser::parse($csv); + + self::assertCount(1, $records); + + $record = $records->first(); + self::assertInstanceOf(CompactRegionTableRecord::class, $record); + self::assertSame('A8', $record->wellID); + self::assertSame('22-000001', $record->sampleDescription); + self::assertSame(320, $record->averageSizeBp); + self::assertSame(7.61, $record->concentrationNgPerUl); + self::assertSame(36.1, $record->regionMolarityNmolPerL); + } + + public function testParseWithNtUnits(): void + { + $csv = <<<'CSV' + FileName,WellId,Sample Description,From [nt],To [nt],Average Size [nt],Conc. [ng/µl],Region Molarity [nmol/l],% of Total,Region Comment + export.D1000,B2,RNA_179_23-025829_F2,200,4000,1800,34.7,29.5,85.0,WTS + CSV; + + $records = CompactRegionTableParser::parse($csv); + + self::assertCount(1, $records); + + $record = $records->first(); + self::assertInstanceOf(CompactRegionTableRecord::class, $record); + self::assertSame(1800, $record->averageSizeBp); + self::assertSame(34.7, $record->concentrationNgPerUl); + } + + public function testParseWithCorruptedMuCharacter(): void + { + // The µ character (U+00B5) sometimes degrades to replacement character + $corruptedHeader = "FileName;WellId;Sample Description;From [bp];To [bp];Average Size [bp];Conc. [ng/\xC2\xB5l];Region Molarity [nmol/l];% of Total;Region Comment"; + $csv = $corruptedHeader . "\n" . '2026-02-25.D1000;A1;Sample1;200;1000;500;12.5;38.0;90.0;MRD'; + + $records = CompactRegionTableParser::parse($csv); + + self::assertCount(1, $records); + $record = $records->first(); + self::assertInstanceOf(CompactRegionTableRecord::class, $record); + self::assertSame(12.5, $record->concentrationNgPerUl); + } + + public function testSkipsEmptyLines(): void + { + $csv = <<<'CSV' + FileName;WellId;Sample Description;From [bp];To [bp];Average Size [bp];Conc. [ng/µl];Region Molarity [nmol/l];% of Total;Region Comment + 2026-02-25.D1000;A1;Sample1;200;1000;500;12.5;38.0;90.0;MRD + + 2026-02-25.D1000;B1;Sample2;200;1000;490;8.3;25.1;85.0;MRD + + CSV; + + $records = CompactRegionTableParser::parse($csv); + + self::assertCount(2, $records); + } + + public function testThrowsOnMissingConcentrationColumn(): void + { + $csv = <<<'CSV' + FileName;WellId;Sample Description + 2026-02-25.D1000;A1;Sample1 + CSV; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Concentration column not found'); + + CompactRegionTableParser::parse($csv); + } +} From 78a61783b08cf3ed9a62748fb8572a826bd2dbd7 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Tue, 10 Mar 2026 15:37:10 +0100 Subject: [PATCH 02/15] fix: make new classes compatible with PHP 7.4 - Remove `readonly class` (PHP 8.2+) from CompactRegionTableRecord - Remove named arguments (PHP 8.0+) from constructor call - Replace `str_starts_with()` (PHP 8.0+) with `strpos() === 0` - Add `@dataProvider` annotations alongside `#[DataProvider]` attributes Co-Authored-By: Claude Opus 4.6 --- src/TapeStation/CompactRegionTableParser.php | 22 +++---- src/TapeStation/CompactRegionTableRecord.php | 65 +++++++++++++++---- tests/Concentration/MolarityConverterTest.php | 2 + 3 files changed, 66 insertions(+), 23 deletions(-) diff --git a/src/TapeStation/CompactRegionTableParser.php b/src/TapeStation/CompactRegionTableParser.php index aec3503..80da737 100644 --- a/src/TapeStation/CompactRegionTableParser.php +++ b/src/TapeStation/CompactRegionTableParser.php @@ -35,16 +35,16 @@ public static function parse(string $csvContent): Collection private static function recordFromRow(array $row): CompactRegionTableRecord { return new CompactRegionTableRecord( - fileName: $row['FileName'] ?? '', - wellID: $row['WellId'] ?? '', - sampleDescription: $row['Sample Description'] ?? '', - fromBp: self::parseInt($row, 'From [bp]', 'From [nt]'), - toBp: self::parseInt($row, 'To [bp]', 'To [nt]'), - averageSizeBp: self::parseInt($row, 'Average Size [bp]', 'Average Size [nt]'), - concentrationNgPerUl: self::parseConcentration($row), - regionMolarityNmolPerL: self::parseFloat($row[self::MOLARITY_KEY] ?? '0'), - percentOfTotal: self::parseFloat($row['% of Total'] ?? '0'), - regionComment: $row['Region Comment'] ?? '', + $row['FileName'] ?? '', + $row['WellId'] ?? '', + $row['Sample Description'] ?? '', + self::parseInt($row, 'From [bp]', 'From [nt]'), + self::parseInt($row, 'To [bp]', 'To [nt]'), + self::parseInt($row, 'Average Size [bp]', 'Average Size [nt]'), + self::parseConcentration($row), + self::parseFloat($row[self::MOLARITY_KEY] ?? '0'), + self::parseFloat($row['% of Total'] ?? '0'), + $row['Region Comment'] ?? '' ); } @@ -57,7 +57,7 @@ private static function recordFromRow(array $row): CompactRegionTableRecord private static function parseConcentration(array $row): float { foreach ($row as $key => $value) { - if (str_starts_with($key, self::CONCENTRATION_KEY_PREFIX)) { + if (strpos($key, self::CONCENTRATION_KEY_PREFIX) === 0) { return self::parseFloat($value); } } diff --git a/src/TapeStation/CompactRegionTableRecord.php b/src/TapeStation/CompactRegionTableRecord.php index b2a6615..a0e96b2 100644 --- a/src/TapeStation/CompactRegionTableRecord.php +++ b/src/TapeStation/CompactRegionTableRecord.php @@ -2,18 +2,59 @@ namespace MLL\Utils\TapeStation; -readonly class CompactRegionTableRecord +class CompactRegionTableRecord { + /** @var string */ + public $fileName; + + /** @var string */ + public $wellID; + + /** @var string */ + public $sampleDescription; + + /** @var int */ + public $fromBp; + + /** @var int */ + public $toBp; + + /** @var int */ + public $averageSizeBp; + + /** @var float */ + public $concentrationNgPerUl; + + /** @var float */ + public $regionMolarityNmolPerL; + + /** @var float */ + public $percentOfTotal; + + /** @var string */ + public $regionComment; + public function __construct( - public string $fileName, - public string $wellID, - public string $sampleDescription, - public int $fromBp, - public int $toBp, - public int $averageSizeBp, - public float $concentrationNgPerUl, - public float $regionMolarityNmolPerL, - public float $percentOfTotal, - public string $regionComment, - ) {} + string $fileName, + string $wellID, + string $sampleDescription, + int $fromBp, + int $toBp, + int $averageSizeBp, + float $concentrationNgPerUl, + float $regionMolarityNmolPerL, + float $percentOfTotal, + string $regionComment + ) { + $this->fileName = $fileName; + $this->wellID = $wellID; + $this->sampleDescription = $sampleDescription; + $this->fromBp = $fromBp; + $this->toBp = $toBp; + $this->averageSizeBp = $averageSizeBp; + $this->concentrationNgPerUl = $concentrationNgPerUl; + $this->regionMolarityNmolPerL = $regionMolarityNmolPerL; + $this->percentOfTotal = $percentOfTotal; + $this->regionComment = $regionComment; + } } diff --git a/tests/Concentration/MolarityConverterTest.php b/tests/Concentration/MolarityConverterTest.php index 392a240..c438350 100644 --- a/tests/Concentration/MolarityConverterTest.php +++ b/tests/Concentration/MolarityConverterTest.php @@ -8,6 +8,7 @@ final class MolarityConverterTest extends TestCase { + /** @dataProvider conversionPairs */ #[DataProvider('conversionPairs')] public function testNgPerUlToNmolPerL(float $expectedNmolPerL, float $ngPerUl, int $fragmentSizeBp): void { @@ -15,6 +16,7 @@ public function testNgPerUlToNmolPerL(float $expectedNmolPerL, float $ngPerUl, i self::assertEqualsWithDelta($expectedNmolPerL, $result, 0.1); } + /** @dataProvider conversionPairs */ #[DataProvider('conversionPairs')] public function testRoundTrip(float $expectedNmolPerL, float $ngPerUl, int $fragmentSizeBp): void { From fbb90a108e0fc7ae895fd2f7a2b6a0639e0b58dc Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Tue, 10 Mar 2026 15:48:37 +0100 Subject: [PATCH 03/15] fix: improve domain accuracy and production safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MolarityConverter: - Replace assert() with \InvalidArgumentException for zero/negative fragment sizes — assert() is disabled in production (zend.assertions=-1), which would silently produce INF/NAN on division by zero - Accept float for fragment size to support weighted averages from pooling CompactRegionTableRecord: - Rename fromBp/toBp/averageSizeBp to from/to/averageSize — these fields hold nucleotides (nt) for RNA TapeStation data, not base pairs (bp) Tests: - Fix corrupted µ test: use actual Latin-1 byte (0xB5) instead of valid UTF-8 sequence (0xC2 0xB5) which is not corrupted - Add edge case tests: zero/negative fragment size, headers-only CSV Co-Authored-By: Claude Opus 4.6 --- src/Concentration/MolarityConverter.php | 19 ++++++++---- src/TapeStation/CompactRegionTableRecord.php | 24 +++++++-------- tests/Concentration/MolarityConverterTest.php | 16 ++++++++++ .../CompactRegionTableParserTest.php | 29 +++++++++++++------ 4 files changed, 61 insertions(+), 27 deletions(-) diff --git a/src/Concentration/MolarityConverter.php b/src/Concentration/MolarityConverter.php index 7bec690..1f2bcf9 100644 --- a/src/Concentration/MolarityConverter.php +++ b/src/Concentration/MolarityConverter.php @@ -8,18 +8,25 @@ class MolarityConverter public const AVERAGE_DALTONS_PER_BASE_PAIR = 660.0; /** Convert mass concentration (ng/µL) to molar concentration (nmol/L). */ - public static function ngPerUlToNmolPerL(float $concentrationNgPerUl, int $averageFragmentSizeBp): float + public static function ngPerUlToNmolPerL(float $concentrationNgPerUl, float $averageFragmentSize): float { - assert($averageFragmentSizeBp > 0); + self::assertPositiveFragmentSize($averageFragmentSize); - return ($concentrationNgPerUl / (self::AVERAGE_DALTONS_PER_BASE_PAIR * $averageFragmentSizeBp)) * 1_000_000; + return ($concentrationNgPerUl / (self::AVERAGE_DALTONS_PER_BASE_PAIR * $averageFragmentSize)) * 1_000_000; } /** Convert molar concentration (nmol/L) to mass concentration (ng/µL). */ - public static function nmolPerLToNgPerUl(float $molarityNmolPerL, int $averageFragmentSizeBp): float + public static function nmolPerLToNgPerUl(float $molarityNmolPerL, float $averageFragmentSize): float { - assert($averageFragmentSizeBp > 0); + self::assertPositiveFragmentSize($averageFragmentSize); - return ($molarityNmolPerL * self::AVERAGE_DALTONS_PER_BASE_PAIR * $averageFragmentSizeBp) / 1_000_000; + return ($molarityNmolPerL * self::AVERAGE_DALTONS_PER_BASE_PAIR * $averageFragmentSize) / 1_000_000; + } + + private static function assertPositiveFragmentSize(float $averageFragmentSize): void + { + if ($averageFragmentSize <= 0.0) { + throw new \InvalidArgumentException("Fragment size must be positive, got {$averageFragmentSize}"); + } } } diff --git a/src/TapeStation/CompactRegionTableRecord.php b/src/TapeStation/CompactRegionTableRecord.php index a0e96b2..731e73a 100644 --- a/src/TapeStation/CompactRegionTableRecord.php +++ b/src/TapeStation/CompactRegionTableRecord.php @@ -13,14 +13,14 @@ class CompactRegionTableRecord /** @var string */ public $sampleDescription; - /** @var int */ - public $fromBp; + /** @var int Region start in bp (DNA) or nt (RNA). */ + public $from; - /** @var int */ - public $toBp; + /** @var int Region end in bp (DNA) or nt (RNA). */ + public $to; - /** @var int */ - public $averageSizeBp; + /** @var int Average fragment size in bp (DNA) or nt (RNA). */ + public $averageSize; /** @var float */ public $concentrationNgPerUl; @@ -38,9 +38,9 @@ public function __construct( string $fileName, string $wellID, string $sampleDescription, - int $fromBp, - int $toBp, - int $averageSizeBp, + int $from, + int $to, + int $averageSize, float $concentrationNgPerUl, float $regionMolarityNmolPerL, float $percentOfTotal, @@ -49,9 +49,9 @@ public function __construct( $this->fileName = $fileName; $this->wellID = $wellID; $this->sampleDescription = $sampleDescription; - $this->fromBp = $fromBp; - $this->toBp = $toBp; - $this->averageSizeBp = $averageSizeBp; + $this->from = $from; + $this->to = $to; + $this->averageSize = $averageSize; $this->concentrationNgPerUl = $concentrationNgPerUl; $this->regionMolarityNmolPerL = $regionMolarityNmolPerL; $this->percentOfTotal = $percentOfTotal; diff --git a/tests/Concentration/MolarityConverterTest.php b/tests/Concentration/MolarityConverterTest.php index c438350..3e2d5d0 100644 --- a/tests/Concentration/MolarityConverterTest.php +++ b/tests/Concentration/MolarityConverterTest.php @@ -25,6 +25,22 @@ public function testRoundTrip(float $expectedNmolPerL, float $ngPerUl, int $frag self::assertEqualsWithDelta($ngPerUl, $backToNgPerUl, 0.001); } + public function testThrowsOnZeroFragmentSize(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Fragment size must be positive'); + + MolarityConverter::ngPerUlToNmolPerL(10.0, 0); + } + + public function testThrowsOnNegativeFragmentSize(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Fragment size must be positive'); + + MolarityConverter::nmolPerLToNgPerUl(10.0, -100); + } + /** @return iterable */ public static function conversionPairs(): iterable { diff --git a/tests/TapeStation/CompactRegionTableParserTest.php b/tests/TapeStation/CompactRegionTableParserTest.php index 579de0d..d8fab99 100644 --- a/tests/TapeStation/CompactRegionTableParserTest.php +++ b/tests/TapeStation/CompactRegionTableParserTest.php @@ -24,9 +24,9 @@ public function testParseSemicolonDelimited(): void self::assertInstanceOf(CompactRegionTableRecord::class, $first); self::assertSame('A1', $first->wellID); self::assertSame('Poko_FLT3-ITD_A1', $first->sampleDescription); - self::assertSame(200, $first->fromBp); - self::assertSame(1000, $first->toBp); - self::assertSame(505, $first->averageSizeBp); + self::assertSame(200, $first->from); + self::assertSame(1000, $first->to); + self::assertSame(505, $first->averageSize); self::assertSame(15.0, $first->concentrationNgPerUl); self::assertSame(46.6, $first->regionMolarityNmolPerL); self::assertEqualsWithDelta(87.94, $first->percentOfTotal, 0.01); @@ -48,7 +48,7 @@ public function testParseCommaDelimited(): void self::assertInstanceOf(CompactRegionTableRecord::class, $record); self::assertSame('A8', $record->wellID); self::assertSame('22-000001', $record->sampleDescription); - self::assertSame(320, $record->averageSizeBp); + self::assertSame(320, $record->averageSize); self::assertSame(7.61, $record->concentrationNgPerUl); self::assertSame(36.1, $record->regionMolarityNmolPerL); } @@ -66,15 +66,15 @@ public function testParseWithNtUnits(): void $record = $records->first(); self::assertInstanceOf(CompactRegionTableRecord::class, $record); - self::assertSame(1800, $record->averageSizeBp); + self::assertSame(1800, $record->averageSize); self::assertSame(34.7, $record->concentrationNgPerUl); } - public function testParseWithCorruptedMuCharacter(): void + public function testParseWithMuAsLatin1Byte(): void { - // The µ character (U+00B5) sometimes degrades to replacement character - $corruptedHeader = "FileName;WellId;Sample Description;From [bp];To [bp];Average Size [bp];Conc. [ng/\xC2\xB5l];Region Molarity [nmol/l];% of Total;Region Comment"; - $csv = $corruptedHeader . "\n" . '2026-02-25.D1000;A1;Sample1;200;1000;500;12.5;38.0;90.0;MRD'; + // Latin-1 µ (0xB5) without UTF-8 prefix — occurs when files are saved as ISO-8859-1 + $latin1Header = "FileName;WellId;Sample Description;From [bp];To [bp];Average Size [bp];Conc. [ng/\xB5l];Region Molarity [nmol/l];% of Total;Region Comment"; + $csv = $latin1Header . "\n" . '2026-02-25.D1000;A1;Sample1;200;1000;500;12.5;38.0;90.0;MRD'; $records = CompactRegionTableParser::parse($csv); @@ -99,6 +99,17 @@ public function testSkipsEmptyLines(): void self::assertCount(2, $records); } + public function testHeadersOnlyReturnsEmptyCollection(): void + { + $csv = <<<'CSV' + FileName;WellId;Sample Description;From [bp];To [bp];Average Size [bp];Conc. [ng/µl];Region Molarity [nmol/l];% of Total;Region Comment + CSV; + + $records = CompactRegionTableParser::parse($csv); + + self::assertCount(0, $records); + } + public function testThrowsOnMissingConcentrationColumn(): void { $csv = <<<'CSV' From 2ee2fbb2993f699186ea157b89abea02861c7cb9 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Wed, 11 Mar 2026 09:46:25 +0100 Subject: [PATCH 04/15] fix: make CompactRegionTableRecord::$from nullable The From [bp/nt] column is absent in some TapeStation Compact Region Table exports. Return null instead of 0 to distinguish "missing" from "starts at position 0". Co-Authored-By: Claude Opus 4.6 --- src/TapeStation/CompactRegionTableParser.php | 19 ++++++++++++++++++- src/TapeStation/CompactRegionTableRecord.php | 4 ++-- .../CompactRegionTableParserTest.php | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/TapeStation/CompactRegionTableParser.php b/src/TapeStation/CompactRegionTableParser.php index 80da737..d6c707c 100644 --- a/src/TapeStation/CompactRegionTableParser.php +++ b/src/TapeStation/CompactRegionTableParser.php @@ -38,7 +38,7 @@ private static function recordFromRow(array $row): CompactRegionTableRecord $row['FileName'] ?? '', $row['WellId'] ?? '', $row['Sample Description'] ?? '', - self::parseInt($row, 'From [bp]', 'From [nt]'), + self::parseNullableInt($row, 'From [bp]', 'From [nt]'), self::parseInt($row, 'To [bp]', 'To [nt]'), self::parseInt($row, 'Average Size [bp]', 'Average Size [nt]'), self::parseConcentration($row), @@ -65,6 +65,23 @@ private static function parseConcentration(array $row): float throw new \RuntimeException('Concentration column not found. Expected column starting with "' . self::CONCENTRATION_KEY_PREFIX . '"'); } + /** + * Try primary key first, fall back to alternative (bp vs nt). + * Returns null when neither column exists in the header — e.g. From [bp] is absent in some exports. + * + * @param array $row + */ + private static function parseNullableInt(array $row, string $primaryKey, string $fallbackKey): ?int + { + if (! array_key_exists($primaryKey, $row) && ! array_key_exists($fallbackKey, $row)) { + return null; + } + + $value = $row[$primaryKey] ?? $row[$fallbackKey] ?? ''; + + return $value === '' ? null : (int) round(self::parseFloat($value)); + } + /** * Try primary key first, fall back to alternative (bp vs nt). * diff --git a/src/TapeStation/CompactRegionTableRecord.php b/src/TapeStation/CompactRegionTableRecord.php index 731e73a..8a4f7cb 100644 --- a/src/TapeStation/CompactRegionTableRecord.php +++ b/src/TapeStation/CompactRegionTableRecord.php @@ -13,7 +13,7 @@ class CompactRegionTableRecord /** @var string */ public $sampleDescription; - /** @var int Region start in bp (DNA) or nt (RNA). */ + /** @var int|null Region start in bp (DNA) or nt (RNA). Null when the column is absent from the export. */ public $from; /** @var int Region end in bp (DNA) or nt (RNA). */ @@ -38,7 +38,7 @@ public function __construct( string $fileName, string $wellID, string $sampleDescription, - int $from, + ?int $from, int $to, int $averageSize, float $concentrationNgPerUl, diff --git a/tests/TapeStation/CompactRegionTableParserTest.php b/tests/TapeStation/CompactRegionTableParserTest.php index d8fab99..a98c29e 100644 --- a/tests/TapeStation/CompactRegionTableParserTest.php +++ b/tests/TapeStation/CompactRegionTableParserTest.php @@ -110,6 +110,25 @@ public function testHeadersOnlyReturnsEmptyCollection(): void self::assertCount(0, $records); } + public function testParseWithMissingFromColumn(): void + { + $csv = <<<'CSV' + FileName,WellId,Sample Description,To [bp],Average Size [bp],Conc. [ng/µl],Region Molarity [nmol/l],% of Total,Region Comment + 2026-02-25.D1000,A8,22-000001,550,336,7.61,36.1,80.91,IDT + CSV; + + $records = CompactRegionTableParser::parse($csv); + + self::assertCount(1, $records); + + $record = $records->first(); + self::assertInstanceOf(CompactRegionTableRecord::class, $record); + self::assertNull($record->from); + self::assertSame(550, $record->to); + self::assertSame(336, $record->averageSize); + self::assertSame(7.61, $record->concentrationNgPerUl); + } + public function testThrowsOnMissingConcentrationColumn(): void { $csv = <<<'CSV' From d7078588f1e2059d3a27da4b29d9e96165b43911 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Wed, 11 Mar 2026 10:16:46 +0100 Subject: [PATCH 05/15] fix: reject High Sensitivity TapeStation assays with clear error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HSD1000 exports use pg/µl and pmol/l (1000× smaller than standard D1000). Silently parsing these values would produce dangerously wrong pooling volumes. Throw early with a descriptive message instead. Co-Authored-By: Claude Opus 4.6 --- src/TapeStation/CompactRegionTableParser.php | 12 +++++++++ .../CompactRegionTableParserTest.php | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/TapeStation/CompactRegionTableParser.php b/src/TapeStation/CompactRegionTableParser.php index d6c707c..2bbc1f3 100644 --- a/src/TapeStation/CompactRegionTableParser.php +++ b/src/TapeStation/CompactRegionTableParser.php @@ -34,6 +34,10 @@ public static function parse(string $csvContent): Collection /** @param array $row */ private static function recordFromRow(array $row): CompactRegionTableRecord { + if (array_key_exists('Region Molarity [pmol/l]', $row)) { + throw new \RuntimeException('High Sensitivity assay detected (pmol/l). This parser only supports standard assays (nmol/l).'); + } + return new CompactRegionTableRecord( $row['FileName'] ?? '', $row['WellId'] ?? '', @@ -52,11 +56,19 @@ private static function recordFromRow(array $row): CompactRegionTableRecord * The concentration column header contains µ which may be corrupted. * Match by prefix instead of exact key. * + * Only standard assays (ng/µl) are supported. High Sensitivity assays + * export pg/µl which is 1000× smaller — using those values without + * conversion would produce dangerously wrong results. + * * @param array $row */ private static function parseConcentration(array $row): float { foreach ($row as $key => $value) { + if (strpos($key, 'Conc. [pg/') === 0) { + throw new \RuntimeException('High Sensitivity assay detected (pg/µl). This parser only supports standard assays (ng/µl).'); + } + if (strpos($key, self::CONCENTRATION_KEY_PREFIX) === 0) { return self::parseFloat($value); } diff --git a/tests/TapeStation/CompactRegionTableParserTest.php b/tests/TapeStation/CompactRegionTableParserTest.php index a98c29e..73fbc59 100644 --- a/tests/TapeStation/CompactRegionTableParserTest.php +++ b/tests/TapeStation/CompactRegionTableParserTest.php @@ -141,4 +141,30 @@ public function testThrowsOnMissingConcentrationColumn(): void CompactRegionTableParser::parse($csv); } + + public function testThrowsOnHighSensitivityConcentration(): void + { + $csv = <<<'CSV' + FileName,WellId,Sample Description,From [bp],To [bp],Average Size [bp],Conc. [pg/µl],Region Molarity [nmol/l],% of Total,Region Comment + 2026-02-25.HSD1000,A1,Sample1,200,1000,500,125.0,38.0,90.0,MRD + CSV; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('High Sensitivity assay detected (pg/µl)'); + + CompactRegionTableParser::parse($csv); + } + + public function testThrowsOnHighSensitivityMolarity(): void + { + $csv = <<<'CSV' + FileName,WellId,Sample Description,From [bp],To [bp],Average Size [bp],Conc. [ng/µl],Region Molarity [pmol/l],% of Total,Region Comment + 2026-02-25.HSD1000,A1,Sample1,200,1000,500,12.5,38000.0,90.0,MRD + CSV; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('High Sensitivity assay detected (pmol/l)'); + + CompactRegionTableParser::parse($csv); + } } From 715cbd69e4f9eba5129b824ae03fec68174b0601 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Wed, 11 Mar 2026 10:28:58 +0100 Subject: [PATCH 06/15] feat!: make CompactRegionTableRecord unit-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename concentrationNgPerUl → concentration and regionMolarityNmolPerL → regionMolarity. The Compact Region Table format is shared across assay types (D1000, HSD1000, RNA) — only the units in column headers differ. Embedding ng/µl in the property name would force a breaking change when HS support is added later. Document the design decision on both parser and record: the caller knows which assay it is parsing and interprets units accordingly. BREAKING CHANGE: Property names on CompactRegionTableRecord changed. Co-Authored-By: Claude Opus 4.6 --- src/TapeStation/CompactRegionTableParser.php | 13 ++++++++ src/TapeStation/CompactRegionTableRecord.php | 32 ++++++++++++++----- .../CompactRegionTableParserTest.php | 14 ++++---- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/TapeStation/CompactRegionTableParser.php b/src/TapeStation/CompactRegionTableParser.php index 2bbc1f3..8b5db91 100644 --- a/src/TapeStation/CompactRegionTableParser.php +++ b/src/TapeStation/CompactRegionTableParser.php @@ -6,6 +6,19 @@ use MLL\Utils\CSVArray; use MLL\Utils\StringUtil; +/** + * Parses Agilent TapeStation "Compact Region Table" CSV exports. + * + * Handles known format variations: + * - Delimiter: comma or semicolon (auto-detected) + * - Size columns: bp (DNA) or nt (RNA) + * - µ character encoding: UTF-8, Latin-1, or replacement character + * - From column: optional (absent in some exports) + * + * Currently only supports standard assays where concentration is in ng/µl + * and molarity in nmol/l. High Sensitivity assays (pg/µl, pmol/l) are + * rejected with a descriptive error — support can be added when needed. + */ class CompactRegionTableParser { /** diff --git a/src/TapeStation/CompactRegionTableRecord.php b/src/TapeStation/CompactRegionTableRecord.php index 8a4f7cb..eace3c9 100644 --- a/src/TapeStation/CompactRegionTableRecord.php +++ b/src/TapeStation/CompactRegionTableRecord.php @@ -2,6 +2,22 @@ namespace MLL\Utils\TapeStation; +/** + * A single row from an Agilent TapeStation "Compact Region Table" CSV export. + * + * This format is shared across assay types (D1000, HSD1000, RNA, etc.) — the + * CSV structure is identical, only the units in column headers differ: + * - D1000 (standard DNA): ng/µl, nmol/l, bp + * - HSD1000 (high sensitivity DNA): pg/µl, pmol/l, bp + * - RNA: ng/µl, nmol/l, nt + * + * The parser currently only accepts standard assays (ng/µl + nmol/l) and rejects + * High Sensitivity assays with a clear error. The properties below are therefore + * unit-agnostic — the caller knows which assay type it is parsing and is + * responsible for interpreting units correctly. + * + * @see CompactRegionTableParser for format detection and validation + */ class CompactRegionTableRecord { /** @var string */ @@ -22,11 +38,11 @@ class CompactRegionTableRecord /** @var int Average fragment size in bp (DNA) or nt (RNA). */ public $averageSize; - /** @var float */ - public $concentrationNgPerUl; + /** @var float Concentration from the "Conc." column. Unit depends on assay: ng/µl (standard) or pg/µl (HS). */ + public $concentration; - /** @var float */ - public $regionMolarityNmolPerL; + /** @var float Region molarity from the "Region Molarity" column. Unit depends on assay: nmol/l (standard) or pmol/l (HS). */ + public $regionMolarity; /** @var float */ public $percentOfTotal; @@ -41,8 +57,8 @@ public function __construct( ?int $from, int $to, int $averageSize, - float $concentrationNgPerUl, - float $regionMolarityNmolPerL, + float $concentration, + float $regionMolarity, float $percentOfTotal, string $regionComment ) { @@ -52,8 +68,8 @@ public function __construct( $this->from = $from; $this->to = $to; $this->averageSize = $averageSize; - $this->concentrationNgPerUl = $concentrationNgPerUl; - $this->regionMolarityNmolPerL = $regionMolarityNmolPerL; + $this->concentration = $concentration; + $this->regionMolarity = $regionMolarity; $this->percentOfTotal = $percentOfTotal; $this->regionComment = $regionComment; } diff --git a/tests/TapeStation/CompactRegionTableParserTest.php b/tests/TapeStation/CompactRegionTableParserTest.php index 73fbc59..d0abb6b 100644 --- a/tests/TapeStation/CompactRegionTableParserTest.php +++ b/tests/TapeStation/CompactRegionTableParserTest.php @@ -27,8 +27,8 @@ public function testParseSemicolonDelimited(): void self::assertSame(200, $first->from); self::assertSame(1000, $first->to); self::assertSame(505, $first->averageSize); - self::assertSame(15.0, $first->concentrationNgPerUl); - self::assertSame(46.6, $first->regionMolarityNmolPerL); + self::assertSame(15.0, $first->concentration); + self::assertSame(46.6, $first->regionMolarity); self::assertEqualsWithDelta(87.94, $first->percentOfTotal, 0.01); self::assertSame('FLT3-ITD MRD', $first->regionComment); } @@ -49,8 +49,8 @@ public function testParseCommaDelimited(): void self::assertSame('A8', $record->wellID); self::assertSame('22-000001', $record->sampleDescription); self::assertSame(320, $record->averageSize); - self::assertSame(7.61, $record->concentrationNgPerUl); - self::assertSame(36.1, $record->regionMolarityNmolPerL); + self::assertSame(7.61, $record->concentration); + self::assertSame(36.1, $record->regionMolarity); } public function testParseWithNtUnits(): void @@ -67,7 +67,7 @@ public function testParseWithNtUnits(): void $record = $records->first(); self::assertInstanceOf(CompactRegionTableRecord::class, $record); self::assertSame(1800, $record->averageSize); - self::assertSame(34.7, $record->concentrationNgPerUl); + self::assertSame(34.7, $record->concentration); } public function testParseWithMuAsLatin1Byte(): void @@ -81,7 +81,7 @@ public function testParseWithMuAsLatin1Byte(): void self::assertCount(1, $records); $record = $records->first(); self::assertInstanceOf(CompactRegionTableRecord::class, $record); - self::assertSame(12.5, $record->concentrationNgPerUl); + self::assertSame(12.5, $record->concentration); } public function testSkipsEmptyLines(): void @@ -126,7 +126,7 @@ public function testParseWithMissingFromColumn(): void self::assertNull($record->from); self::assertSame(550, $record->to); self::assertSame(336, $record->averageSize); - self::assertSame(7.61, $record->concentrationNgPerUl); + self::assertSame(7.61, $record->concentration); } public function testThrowsOnMissingConcentrationColumn(): void From 742b6b07ef75866370e256d6c544c39ae998990c Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Wed, 11 Mar 2026 10:59:04 +0100 Subject: [PATCH 07/15] =?UTF-8?q?fix:=20revert=20unit-agnostic=20rename,?= =?UTF-8?q?=20keep=20explicit=20ng/=C2=B5l=20+=20nmol/l=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All supported assays (D1000, D5000, RNA) use ng/µl + nmol/l. The property names are truthful for every value the parser returns. High Sensitivity assays (pg/µl, pmol/l) are rejected by the parser, so a future HSD1000 integration would force a deliberate breaking change — ensuring all consumers revisit their unit assumptions. Size columns (from, to, averageSize) remain unit-agnostic because bp and nt are numerically equivalent within a workflow context. Co-Authored-By: Claude Opus 4.6 --- src/TapeStation/CompactRegionTableParser.php | 11 ++----- src/TapeStation/CompactRegionTableRecord.php | 32 +++++-------------- .../CompactRegionTableParserTest.php | 14 ++++---- 3 files changed, 17 insertions(+), 40 deletions(-) diff --git a/src/TapeStation/CompactRegionTableParser.php b/src/TapeStation/CompactRegionTableParser.php index 8b5db91..274141a 100644 --- a/src/TapeStation/CompactRegionTableParser.php +++ b/src/TapeStation/CompactRegionTableParser.php @@ -9,15 +9,8 @@ /** * Parses Agilent TapeStation "Compact Region Table" CSV exports. * - * Handles known format variations: - * - Delimiter: comma or semicolon (auto-detected) - * - Size columns: bp (DNA) or nt (RNA) - * - µ character encoding: UTF-8, Latin-1, or replacement character - * - From column: optional (absent in some exports) - * - * Currently only supports standard assays where concentration is in ng/µl - * and molarity in nmol/l. High Sensitivity assays (pg/µl, pmol/l) are - * rejected with a descriptive error — support can be added when needed. + * Supports D1000, D5000, and RNA assays (all ng/µl + nmol/l). + * Rejects High Sensitivity assays (pg/µl + pmol/l) — see parseConcentration(). */ class CompactRegionTableParser { diff --git a/src/TapeStation/CompactRegionTableRecord.php b/src/TapeStation/CompactRegionTableRecord.php index eace3c9..8a4f7cb 100644 --- a/src/TapeStation/CompactRegionTableRecord.php +++ b/src/TapeStation/CompactRegionTableRecord.php @@ -2,22 +2,6 @@ namespace MLL\Utils\TapeStation; -/** - * A single row from an Agilent TapeStation "Compact Region Table" CSV export. - * - * This format is shared across assay types (D1000, HSD1000, RNA, etc.) — the - * CSV structure is identical, only the units in column headers differ: - * - D1000 (standard DNA): ng/µl, nmol/l, bp - * - HSD1000 (high sensitivity DNA): pg/µl, pmol/l, bp - * - RNA: ng/µl, nmol/l, nt - * - * The parser currently only accepts standard assays (ng/µl + nmol/l) and rejects - * High Sensitivity assays with a clear error. The properties below are therefore - * unit-agnostic — the caller knows which assay type it is parsing and is - * responsible for interpreting units correctly. - * - * @see CompactRegionTableParser for format detection and validation - */ class CompactRegionTableRecord { /** @var string */ @@ -38,11 +22,11 @@ class CompactRegionTableRecord /** @var int Average fragment size in bp (DNA) or nt (RNA). */ public $averageSize; - /** @var float Concentration from the "Conc." column. Unit depends on assay: ng/µl (standard) or pg/µl (HS). */ - public $concentration; + /** @var float */ + public $concentrationNgPerUl; - /** @var float Region molarity from the "Region Molarity" column. Unit depends on assay: nmol/l (standard) or pmol/l (HS). */ - public $regionMolarity; + /** @var float */ + public $regionMolarityNmolPerL; /** @var float */ public $percentOfTotal; @@ -57,8 +41,8 @@ public function __construct( ?int $from, int $to, int $averageSize, - float $concentration, - float $regionMolarity, + float $concentrationNgPerUl, + float $regionMolarityNmolPerL, float $percentOfTotal, string $regionComment ) { @@ -68,8 +52,8 @@ public function __construct( $this->from = $from; $this->to = $to; $this->averageSize = $averageSize; - $this->concentration = $concentration; - $this->regionMolarity = $regionMolarity; + $this->concentrationNgPerUl = $concentrationNgPerUl; + $this->regionMolarityNmolPerL = $regionMolarityNmolPerL; $this->percentOfTotal = $percentOfTotal; $this->regionComment = $regionComment; } diff --git a/tests/TapeStation/CompactRegionTableParserTest.php b/tests/TapeStation/CompactRegionTableParserTest.php index d0abb6b..73fbc59 100644 --- a/tests/TapeStation/CompactRegionTableParserTest.php +++ b/tests/TapeStation/CompactRegionTableParserTest.php @@ -27,8 +27,8 @@ public function testParseSemicolonDelimited(): void self::assertSame(200, $first->from); self::assertSame(1000, $first->to); self::assertSame(505, $first->averageSize); - self::assertSame(15.0, $first->concentration); - self::assertSame(46.6, $first->regionMolarity); + self::assertSame(15.0, $first->concentrationNgPerUl); + self::assertSame(46.6, $first->regionMolarityNmolPerL); self::assertEqualsWithDelta(87.94, $first->percentOfTotal, 0.01); self::assertSame('FLT3-ITD MRD', $first->regionComment); } @@ -49,8 +49,8 @@ public function testParseCommaDelimited(): void self::assertSame('A8', $record->wellID); self::assertSame('22-000001', $record->sampleDescription); self::assertSame(320, $record->averageSize); - self::assertSame(7.61, $record->concentration); - self::assertSame(36.1, $record->regionMolarity); + self::assertSame(7.61, $record->concentrationNgPerUl); + self::assertSame(36.1, $record->regionMolarityNmolPerL); } public function testParseWithNtUnits(): void @@ -67,7 +67,7 @@ public function testParseWithNtUnits(): void $record = $records->first(); self::assertInstanceOf(CompactRegionTableRecord::class, $record); self::assertSame(1800, $record->averageSize); - self::assertSame(34.7, $record->concentration); + self::assertSame(34.7, $record->concentrationNgPerUl); } public function testParseWithMuAsLatin1Byte(): void @@ -81,7 +81,7 @@ public function testParseWithMuAsLatin1Byte(): void self::assertCount(1, $records); $record = $records->first(); self::assertInstanceOf(CompactRegionTableRecord::class, $record); - self::assertSame(12.5, $record->concentration); + self::assertSame(12.5, $record->concentrationNgPerUl); } public function testSkipsEmptyLines(): void @@ -126,7 +126,7 @@ public function testParseWithMissingFromColumn(): void self::assertNull($record->from); self::assertSame(550, $record->to); self::assertSame(336, $record->averageSize); - self::assertSame(7.61, $record->concentration); + self::assertSame(7.61, $record->concentrationNgPerUl); } public function testThrowsOnMissingConcentrationColumn(): void From 103b7f9b2766e8159bf466310746e2fd3e869be3 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Wed, 11 Mar 2026 11:18:22 +0100 Subject: [PATCH 08/15] docs: align PHPDoc with Agilent 4200 TapeStation System Manual - List all standard assays (D1000, D5000, RNA, Genomic DNA, Cell-free DNA) - Document averageSize as center of mass (Region view), not peak maximum Co-Authored-By: Claude Opus 4.6 --- src/TapeStation/CompactRegionTableParser.php | 2 +- src/TapeStation/CompactRegionTableRecord.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TapeStation/CompactRegionTableParser.php b/src/TapeStation/CompactRegionTableParser.php index 274141a..a31a5b0 100644 --- a/src/TapeStation/CompactRegionTableParser.php +++ b/src/TapeStation/CompactRegionTableParser.php @@ -9,7 +9,7 @@ /** * Parses Agilent TapeStation "Compact Region Table" CSV exports. * - * Supports D1000, D5000, and RNA assays (all ng/µl + nmol/l). + * Supports all standard assays (D1000, D5000, RNA, Genomic DNA, Cell-free DNA — all ng/µl + nmol/l). * Rejects High Sensitivity assays (pg/µl + pmol/l) — see parseConcentration(). */ class CompactRegionTableParser diff --git a/src/TapeStation/CompactRegionTableRecord.php b/src/TapeStation/CompactRegionTableRecord.php index 8a4f7cb..e4e43d6 100644 --- a/src/TapeStation/CompactRegionTableRecord.php +++ b/src/TapeStation/CompactRegionTableRecord.php @@ -19,7 +19,7 @@ class CompactRegionTableRecord /** @var int Region end in bp (DNA) or nt (RNA). */ public $to; - /** @var int Average fragment size in bp (DNA) or nt (RNA). */ + /** @var int Average fragment size in bp (DNA) or nt (RNA). Center of mass, not peak maximum. */ public $averageSize; /** @var float */ From 4585035a55b193c2c3a474801ca1fa41888a2907 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Wed, 11 Mar 2026 11:32:09 +0100 Subject: [PATCH 09/15] refactor: use typed properties, consolidate HS rejection, trim redundant comments - CompactRegionTableRecord: typed properties replace @var annotations - CompactRegionTableParser: extract rejectHighSensitivityAssay(), use Collection::map() - MolarityConverter: remove PHPDoc that restated method names Co-Authored-By: Claude Opus 4.6 --- src/Concentration/MolarityConverter.php | 2 - src/TapeStation/CompactRegionTableParser.php | 58 +++++++++----------- src/TapeStation/CompactRegionTableRecord.php | 32 ++++------- 3 files changed, 37 insertions(+), 55 deletions(-) diff --git a/src/Concentration/MolarityConverter.php b/src/Concentration/MolarityConverter.php index 1f2bcf9..7a6bf46 100644 --- a/src/Concentration/MolarityConverter.php +++ b/src/Concentration/MolarityConverter.php @@ -7,7 +7,6 @@ class MolarityConverter /** Average molar mass of a single base pair in double-stranded DNA (g/mol). */ public const AVERAGE_DALTONS_PER_BASE_PAIR = 660.0; - /** Convert mass concentration (ng/µL) to molar concentration (nmol/L). */ public static function ngPerUlToNmolPerL(float $concentrationNgPerUl, float $averageFragmentSize): float { self::assertPositiveFragmentSize($averageFragmentSize); @@ -15,7 +14,6 @@ public static function ngPerUlToNmolPerL(float $concentrationNgPerUl, float $ave return ($concentrationNgPerUl / (self::AVERAGE_DALTONS_PER_BASE_PAIR * $averageFragmentSize)) * 1_000_000; } - /** Convert molar concentration (nmol/L) to mass concentration (ng/µL). */ public static function nmolPerLToNgPerUl(float $molarityNmolPerL, float $averageFragmentSize): float { self::assertPositiveFragmentSize($averageFragmentSize); diff --git a/src/TapeStation/CompactRegionTableParser.php b/src/TapeStation/CompactRegionTableParser.php index a31a5b0..4e72a22 100644 --- a/src/TapeStation/CompactRegionTableParser.php +++ b/src/TapeStation/CompactRegionTableParser.php @@ -10,14 +10,11 @@ * Parses Agilent TapeStation "Compact Region Table" CSV exports. * * Supports all standard assays (D1000, D5000, RNA, Genomic DNA, Cell-free DNA — all ng/µl + nmol/l). - * Rejects High Sensitivity assays (pg/µl + pmol/l) — see parseConcentration(). + * Rejects High Sensitivity assays (pg/µl + pmol/l) — see rejectHighSensitivityAssay(). */ class CompactRegionTableParser { - /** - * Column name prefixes — used for fuzzy matching because the µ character - * in "Conc. [ng/µl]" is frequently corrupted during file save/load cycles. - */ + /** µ in "Conc. [ng/µl]" is frequently corrupted during file save/load cycles. */ private const CONCENTRATION_KEY_PREFIX = 'Conc. [ng/'; private const MOLARITY_KEY = 'Region Molarity [nmol/l]'; @@ -29,20 +26,16 @@ public static function parse(string $csvContent): Collection $rows = CSVArray::toArray($csvContent, $delimiter); - $records = new Collection(); - foreach ($rows as $row) { - $records->push(self::recordFromRow($row)); - } - - return $records; + return (new Collection($rows)) + ->map(static function (array $row): CompactRegionTableRecord { + return self::recordFromRow($row); + }); } /** @param array $row */ private static function recordFromRow(array $row): CompactRegionTableRecord { - if (array_key_exists('Region Molarity [pmol/l]', $row)) { - throw new \RuntimeException('High Sensitivity assay detected (pmol/l). This parser only supports standard assays (nmol/l).'); - } + self::rejectHighSensitivityAssay($row); return new CompactRegionTableRecord( $row['FileName'] ?? '', @@ -59,22 +52,30 @@ private static function recordFromRow(array $row): CompactRegionTableRecord } /** - * The concentration column header contains µ which may be corrupted. - * Match by prefix instead of exact key. - * - * Only standard assays (ng/µl) are supported. High Sensitivity assays - * export pg/µl which is 1000× smaller — using those values without - * conversion would produce dangerously wrong results. + * HS assays use pg/µl + pmol/l (1000× smaller than ng/µl + nmol/l). + * Silently parsing those would produce dangerously wrong results. * * @param array $row */ - private static function parseConcentration(array $row): float + private static function rejectHighSensitivityAssay(array $row): void { - foreach ($row as $key => $value) { + if (array_key_exists('Region Molarity [pmol/l]', $row)) { + throw new \RuntimeException('High Sensitivity assay detected (pmol/l). This parser only supports standard assays (nmol/l).'); + } + + foreach (array_keys($row) as $key) { if (strpos($key, 'Conc. [pg/') === 0) { throw new \RuntimeException('High Sensitivity assay detected (pg/µl). This parser only supports standard assays (ng/µl).'); } + } + } + /** + * @param array $row + */ + private static function parseConcentration(array $row): float + { + foreach ($row as $key => $value) { if (strpos($key, self::CONCENTRATION_KEY_PREFIX) === 0) { return self::parseFloat($value); } @@ -83,12 +84,7 @@ private static function parseConcentration(array $row): float throw new \RuntimeException('Concentration column not found. Expected column starting with "' . self::CONCENTRATION_KEY_PREFIX . '"'); } - /** - * Try primary key first, fall back to alternative (bp vs nt). - * Returns null when neither column exists in the header — e.g. From [bp] is absent in some exports. - * - * @param array $row - */ + /** @param array $row */ private static function parseNullableInt(array $row, string $primaryKey, string $fallbackKey): ?int { if (! array_key_exists($primaryKey, $row) && ! array_key_exists($fallbackKey, $row)) { @@ -100,11 +96,7 @@ private static function parseNullableInt(array $row, string $primaryKey, string return $value === '' ? null : (int) round(self::parseFloat($value)); } - /** - * Try primary key first, fall back to alternative (bp vs nt). - * - * @param array $row - */ + /** @param array $row */ private static function parseInt(array $row, string $primaryKey, string $fallbackKey): int { $value = $row[$primaryKey] ?? $row[$fallbackKey] ?? null; diff --git a/src/TapeStation/CompactRegionTableRecord.php b/src/TapeStation/CompactRegionTableRecord.php index e4e43d6..6384492 100644 --- a/src/TapeStation/CompactRegionTableRecord.php +++ b/src/TapeStation/CompactRegionTableRecord.php @@ -4,35 +4,27 @@ class CompactRegionTableRecord { - /** @var string */ - public $fileName; + public string $fileName; - /** @var string */ - public $wellID; + public string $wellID; - /** @var string */ - public $sampleDescription; + public string $sampleDescription; - /** @var int|null Region start in bp (DNA) or nt (RNA). Null when the column is absent from the export. */ - public $from; + /** Null when the column is absent from the export. */ + public ?int $from; - /** @var int Region end in bp (DNA) or nt (RNA). */ - public $to; + public int $to; - /** @var int Average fragment size in bp (DNA) or nt (RNA). Center of mass, not peak maximum. */ - public $averageSize; + /** Center of mass, not peak maximum. */ + public int $averageSize; - /** @var float */ - public $concentrationNgPerUl; + public float $concentrationNgPerUl; - /** @var float */ - public $regionMolarityNmolPerL; + public float $regionMolarityNmolPerL; - /** @var float */ - public $percentOfTotal; + public float $percentOfTotal; - /** @var string */ - public $regionComment; + public string $regionComment; public function __construct( string $fileName, From 4445553675bdd92568236d3eeb57fec9a837811d Mon Sep 17 00:00:00 2001 From: simbig <26680884+simbig@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:32:40 +0000 Subject: [PATCH 10/15] Apply php-cs-fixer changes --- src/TapeStation/CompactRegionTableParser.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/TapeStation/CompactRegionTableParser.php b/src/TapeStation/CompactRegionTableParser.php index 4e72a22..d83de50 100644 --- a/src/TapeStation/CompactRegionTableParser.php +++ b/src/TapeStation/CompactRegionTableParser.php @@ -27,9 +27,7 @@ public static function parse(string $csvContent): Collection $rows = CSVArray::toArray($csvContent, $delimiter); return (new Collection($rows)) - ->map(static function (array $row): CompactRegionTableRecord { - return self::recordFromRow($row); - }); + ->map(static fn (array $row): CompactRegionTableRecord => self::recordFromRow($row)); } /** @param array $row */ @@ -70,9 +68,7 @@ private static function rejectHighSensitivityAssay(array $row): void } } - /** - * @param array $row - */ + /** @param array $row */ private static function parseConcentration(array $row): float { foreach ($row as $key => $value) { From 70f7f73a52a737e4f665a86fd602ecaf4a9985f9 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 12 Mar 2026 10:04:49 +0100 Subject: [PATCH 11/15] feat(tapestation): type wellID as Coordinates TapeStations always use 96-well plates (12x8). Parsing the WellId from CSV into a validated Coordinates object catches invalid positions at import time instead of downstream. Co-Authored-By: Claude Opus 4.6 --- src/TapeStation/CompactRegionTableParser.php | 4 +++- src/TapeStation/CompactRegionTableRecord.php | 9 +++++++-- tests/TapeStation/CompactRegionTableParserTest.php | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/TapeStation/CompactRegionTableParser.php b/src/TapeStation/CompactRegionTableParser.php index d83de50..8815a67 100644 --- a/src/TapeStation/CompactRegionTableParser.php +++ b/src/TapeStation/CompactRegionTableParser.php @@ -4,6 +4,8 @@ use Illuminate\Support\Collection; use MLL\Utils\CSVArray; +use MLL\Utils\Microplate\Coordinates; +use MLL\Utils\Microplate\CoordinateSystem12x8; use MLL\Utils\StringUtil; /** @@ -37,7 +39,7 @@ private static function recordFromRow(array $row): CompactRegionTableRecord return new CompactRegionTableRecord( $row['FileName'] ?? '', - $row['WellId'] ?? '', + Coordinates::fromString($row['WellId'] ?? '', new CoordinateSystem12x8()), $row['Sample Description'] ?? '', self::parseNullableInt($row, 'From [bp]', 'From [nt]'), self::parseInt($row, 'To [bp]', 'To [nt]'), diff --git a/src/TapeStation/CompactRegionTableRecord.php b/src/TapeStation/CompactRegionTableRecord.php index 6384492..897c2b9 100644 --- a/src/TapeStation/CompactRegionTableRecord.php +++ b/src/TapeStation/CompactRegionTableRecord.php @@ -2,11 +2,15 @@ namespace MLL\Utils\TapeStation; +use MLL\Utils\Microplate\Coordinates; +use MLL\Utils\Microplate\CoordinateSystem12x8; + class CompactRegionTableRecord { public string $fileName; - public string $wellID; + /** @var Coordinates */ + public Coordinates $wellID; public string $sampleDescription; @@ -26,9 +30,10 @@ class CompactRegionTableRecord public string $regionComment; + /** @param Coordinates $wellID */ public function __construct( string $fileName, - string $wellID, + Coordinates $wellID, string $sampleDescription, ?int $from, int $to, diff --git a/tests/TapeStation/CompactRegionTableParserTest.php b/tests/TapeStation/CompactRegionTableParserTest.php index 73fbc59..33e944a 100644 --- a/tests/TapeStation/CompactRegionTableParserTest.php +++ b/tests/TapeStation/CompactRegionTableParserTest.php @@ -22,7 +22,7 @@ public function testParseSemicolonDelimited(): void $first = $records->first(); self::assertInstanceOf(CompactRegionTableRecord::class, $first); - self::assertSame('A1', $first->wellID); + self::assertSame('A1', $first->wellID->toString()); self::assertSame('Poko_FLT3-ITD_A1', $first->sampleDescription); self::assertSame(200, $first->from); self::assertSame(1000, $first->to); @@ -46,7 +46,7 @@ public function testParseCommaDelimited(): void $record = $records->first(); self::assertInstanceOf(CompactRegionTableRecord::class, $record); - self::assertSame('A8', $record->wellID); + self::assertSame('A8', $record->wellID->toString()); self::assertSame('22-000001', $record->sampleDescription); self::assertSame(320, $record->averageSize); self::assertSame(7.61, $record->concentrationNgPerUl); From 7c2d0d637cc1590957abda6fd20ce65ddd272b64 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 16 Mar 2026 10:05:57 +0100 Subject: [PATCH 12/15] refactor(tapestation): use SafeCast and rename wellID to coordinates Delegate string-to-float casting to SafeCast::tryFloat(), removing the private parseFloat() method that reimplemented the same logic. Rename wellID to coordinates to match the property type. Co-Authored-By: Claude Opus 4.6 --- src/TapeStation/CompactRegionTableParser.php | 21 ++++++------------- src/TapeStation/CompactRegionTableRecord.php | 8 +++---- .../CompactRegionTableParserTest.php | 4 ++-- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/TapeStation/CompactRegionTableParser.php b/src/TapeStation/CompactRegionTableParser.php index 8815a67..6f83910 100644 --- a/src/TapeStation/CompactRegionTableParser.php +++ b/src/TapeStation/CompactRegionTableParser.php @@ -6,6 +6,7 @@ use MLL\Utils\CSVArray; use MLL\Utils\Microplate\Coordinates; use MLL\Utils\Microplate\CoordinateSystem12x8; +use MLL\Utils\SafeCast; use MLL\Utils\StringUtil; /** @@ -45,8 +46,8 @@ private static function recordFromRow(array $row): CompactRegionTableRecord self::parseInt($row, 'To [bp]', 'To [nt]'), self::parseInt($row, 'Average Size [bp]', 'Average Size [nt]'), self::parseConcentration($row), - self::parseFloat($row[self::MOLARITY_KEY] ?? '0'), - self::parseFloat($row['% of Total'] ?? '0'), + SafeCast::tryFloat($row[self::MOLARITY_KEY] ?? '0') ?? 0.0, + SafeCast::tryFloat($row['% of Total'] ?? '0') ?? 0.0, $row['Region Comment'] ?? '' ); } @@ -75,7 +76,7 @@ private static function parseConcentration(array $row): float { foreach ($row as $key => $value) { if (strpos($key, self::CONCENTRATION_KEY_PREFIX) === 0) { - return self::parseFloat($value); + return SafeCast::tryFloat($value) ?? 0.0; } } @@ -91,7 +92,7 @@ private static function parseNullableInt(array $row, string $primaryKey, string $value = $row[$primaryKey] ?? $row[$fallbackKey] ?? ''; - return $value === '' ? null : (int) round(self::parseFloat($value)); + return $value === '' ? null : (int) round(SafeCast::tryFloat($value) ?? 0.0); } /** @param array $row */ @@ -102,17 +103,7 @@ private static function parseInt(array $row, string $primaryKey, string $fallbac return 0; } - return (int) round(self::parseFloat($value)); - } - - private static function parseFloat(string $value): float - { - $trimmed = trim($value); - if ($trimmed === '' || ! is_numeric($trimmed)) { - return 0.0; - } - - return (float) $trimmed; + return (int) round(SafeCast::tryFloat($value) ?? 0.0); } private static function detectDelimiter(string $csvContent): string diff --git a/src/TapeStation/CompactRegionTableRecord.php b/src/TapeStation/CompactRegionTableRecord.php index 897c2b9..a6abdb0 100644 --- a/src/TapeStation/CompactRegionTableRecord.php +++ b/src/TapeStation/CompactRegionTableRecord.php @@ -10,7 +10,7 @@ class CompactRegionTableRecord public string $fileName; /** @var Coordinates */ - public Coordinates $wellID; + public Coordinates $coordinates; public string $sampleDescription; @@ -30,10 +30,10 @@ class CompactRegionTableRecord public string $regionComment; - /** @param Coordinates $wellID */ + /** @param Coordinates $coordinates */ public function __construct( string $fileName, - Coordinates $wellID, + Coordinates $coordinates, string $sampleDescription, ?int $from, int $to, @@ -44,7 +44,7 @@ public function __construct( string $regionComment ) { $this->fileName = $fileName; - $this->wellID = $wellID; + $this->coordinates = $coordinates; $this->sampleDescription = $sampleDescription; $this->from = $from; $this->to = $to; diff --git a/tests/TapeStation/CompactRegionTableParserTest.php b/tests/TapeStation/CompactRegionTableParserTest.php index 33e944a..e33f95a 100644 --- a/tests/TapeStation/CompactRegionTableParserTest.php +++ b/tests/TapeStation/CompactRegionTableParserTest.php @@ -22,7 +22,7 @@ public function testParseSemicolonDelimited(): void $first = $records->first(); self::assertInstanceOf(CompactRegionTableRecord::class, $first); - self::assertSame('A1', $first->wellID->toString()); + self::assertSame('A1', $first->coordinates->toString()); self::assertSame('Poko_FLT3-ITD_A1', $first->sampleDescription); self::assertSame(200, $first->from); self::assertSame(1000, $first->to); @@ -46,7 +46,7 @@ public function testParseCommaDelimited(): void $record = $records->first(); self::assertInstanceOf(CompactRegionTableRecord::class, $record); - self::assertSame('A8', $record->wellID->toString()); + self::assertSame('A8', $record->coordinates->toString()); self::assertSame('22-000001', $record->sampleDescription); self::assertSame(320, $record->averageSize); self::assertSame(7.61, $record->concentrationNgPerUl); From 5d9ce11a9b3e674d8ccfaa5319a1c1afb100df93 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 16 Mar 2026 10:26:05 +0100 Subject: [PATCH 13/15] refactor(concentration): make MolarityConverter formula generic Extract the dsDNA-specific 660 Da constant as a parameter so the converter becomes pure chemistry math. Add constants for dsDNA, ssDNA, and RNA on the class for convenient co-location. Rename methods to concentrationToMolarity / molarityToConcentration. Co-Authored-By: Claude Opus 4.6 --- src/Concentration/MolarityConverter.php | 35 ++++++++++++------- tests/Concentration/MolarityConverterTest.php | 20 +++++++---- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/Concentration/MolarityConverter.php b/src/Concentration/MolarityConverter.php index 7a6bf46..f76ac8e 100644 --- a/src/Concentration/MolarityConverter.php +++ b/src/Concentration/MolarityConverter.php @@ -4,27 +4,36 @@ class MolarityConverter { - /** Average molar mass of a single base pair in double-stranded DNA (g/mol). */ - public const AVERAGE_DALTONS_PER_BASE_PAIR = 660.0; + public const DALTONS_PER_BASE_PAIR_DSDNA = 660.0; + public const DALTONS_PER_NUCLEOTIDE_SSDNA = 330.0; + public const DALTONS_PER_NUCLEOTIDE_RNA = 340.0; - public static function ngPerUlToNmolPerL(float $concentrationNgPerUl, float $averageFragmentSize): float - { - self::assertPositiveFragmentSize($averageFragmentSize); + public static function concentrationToMolarity( + float $concentrationNgPerUl, + float $averageFragmentSize, + float $averageDaltonsPerUnit + ): float { + self::assertPositive($averageFragmentSize, 'Fragment size'); + self::assertPositive($averageDaltonsPerUnit, 'Daltons per unit'); - return ($concentrationNgPerUl / (self::AVERAGE_DALTONS_PER_BASE_PAIR * $averageFragmentSize)) * 1_000_000; + return ($concentrationNgPerUl / ($averageDaltonsPerUnit * $averageFragmentSize)) * 1_000_000; } - public static function nmolPerLToNgPerUl(float $molarityNmolPerL, float $averageFragmentSize): float - { - self::assertPositiveFragmentSize($averageFragmentSize); + public static function molarityToConcentration( + float $molarityNmolPerL, + float $averageFragmentSize, + float $averageDaltonsPerUnit + ): float { + self::assertPositive($averageFragmentSize, 'Fragment size'); + self::assertPositive($averageDaltonsPerUnit, 'Daltons per unit'); - return ($molarityNmolPerL * self::AVERAGE_DALTONS_PER_BASE_PAIR * $averageFragmentSize) / 1_000_000; + return ($molarityNmolPerL * $averageDaltonsPerUnit * $averageFragmentSize) / 1_000_000; } - private static function assertPositiveFragmentSize(float $averageFragmentSize): void + private static function assertPositive(float $value, string $name): void { - if ($averageFragmentSize <= 0.0) { - throw new \InvalidArgumentException("Fragment size must be positive, got {$averageFragmentSize}"); + if ($value <= 0.0) { + throw new \InvalidArgumentException("{$name} must be positive, got {$value}"); } } } diff --git a/tests/Concentration/MolarityConverterTest.php b/tests/Concentration/MolarityConverterTest.php index 3e2d5d0..768925c 100644 --- a/tests/Concentration/MolarityConverterTest.php +++ b/tests/Concentration/MolarityConverterTest.php @@ -10,9 +10,9 @@ final class MolarityConverterTest extends TestCase { /** @dataProvider conversionPairs */ #[DataProvider('conversionPairs')] - public function testNgPerUlToNmolPerL(float $expectedNmolPerL, float $ngPerUl, int $fragmentSizeBp): void + public function testConcentrationToMolarity(float $expectedNmolPerL, float $ngPerUl, int $fragmentSizeBp): void { - $result = MolarityConverter::ngPerUlToNmolPerL($ngPerUl, $fragmentSizeBp); + $result = MolarityConverter::concentrationToMolarity($ngPerUl, $fragmentSizeBp, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); self::assertEqualsWithDelta($expectedNmolPerL, $result, 0.1); } @@ -20,8 +20,8 @@ public function testNgPerUlToNmolPerL(float $expectedNmolPerL, float $ngPerUl, i #[DataProvider('conversionPairs')] public function testRoundTrip(float $expectedNmolPerL, float $ngPerUl, int $fragmentSizeBp): void { - $nmolPerL = MolarityConverter::ngPerUlToNmolPerL($ngPerUl, $fragmentSizeBp); - $backToNgPerUl = MolarityConverter::nmolPerLToNgPerUl($nmolPerL, $fragmentSizeBp); + $nmolPerL = MolarityConverter::concentrationToMolarity($ngPerUl, $fragmentSizeBp, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); + $backToNgPerUl = MolarityConverter::molarityToConcentration($nmolPerL, $fragmentSizeBp, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); self::assertEqualsWithDelta($ngPerUl, $backToNgPerUl, 0.001); } @@ -30,7 +30,7 @@ public function testThrowsOnZeroFragmentSize(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Fragment size must be positive'); - MolarityConverter::ngPerUlToNmolPerL(10.0, 0); + MolarityConverter::concentrationToMolarity(10.0, 0, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); } public function testThrowsOnNegativeFragmentSize(): void @@ -38,7 +38,15 @@ public function testThrowsOnNegativeFragmentSize(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Fragment size must be positive'); - MolarityConverter::nmolPerLToNgPerUl(10.0, -100); + MolarityConverter::molarityToConcentration(10.0, -100, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); + } + + public function testThrowsOnZeroDaltonsPerUnit(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Daltons per unit must be positive'); + + MolarityConverter::concentrationToMolarity(10.0, 400, 0.0); } /** @return iterable */ From 0696d1452fb83a81a2b3138204f9be1c9918a61b Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 16 Mar 2026 10:45:42 +0100 Subject: [PATCH 14/15] refactor(tapestation): use SafeCast::toInt/toFloat, remove silent defaults Replace silent 0/0.0 defaults with strict SafeCast::toInt/toFloat that throw on invalid input. Remove parseInt helper (inlined). Replace strtok with stateless explode for delimiter detection. Co-Authored-By: Claude Opus 4.6 --- src/TapeStation/CompactRegionTableParser.php | 28 +++++-------------- .../CompactRegionTableParserTest.php | 4 +-- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/TapeStation/CompactRegionTableParser.php b/src/TapeStation/CompactRegionTableParser.php index 6f83910..59ddad1 100644 --- a/src/TapeStation/CompactRegionTableParser.php +++ b/src/TapeStation/CompactRegionTableParser.php @@ -43,11 +43,11 @@ private static function recordFromRow(array $row): CompactRegionTableRecord Coordinates::fromString($row['WellId'] ?? '', new CoordinateSystem12x8()), $row['Sample Description'] ?? '', self::parseNullableInt($row, 'From [bp]', 'From [nt]'), - self::parseInt($row, 'To [bp]', 'To [nt]'), - self::parseInt($row, 'Average Size [bp]', 'Average Size [nt]'), + SafeCast::toInt($row['To [bp]'] ?? $row['To [nt]'] ?? ''), + SafeCast::toInt($row['Average Size [bp]'] ?? $row['Average Size [nt]'] ?? ''), self::parseConcentration($row), - SafeCast::tryFloat($row[self::MOLARITY_KEY] ?? '0') ?? 0.0, - SafeCast::tryFloat($row['% of Total'] ?? '0') ?? 0.0, + SafeCast::toFloat($row[self::MOLARITY_KEY] ?? ''), + SafeCast::toFloat($row['% of Total'] ?? ''), $row['Region Comment'] ?? '' ); } @@ -76,7 +76,7 @@ private static function parseConcentration(array $row): float { foreach ($row as $key => $value) { if (strpos($key, self::CONCENTRATION_KEY_PREFIX) === 0) { - return SafeCast::tryFloat($value) ?? 0.0; + return SafeCast::toFloat($value); } } @@ -92,26 +92,12 @@ private static function parseNullableInt(array $row, string $primaryKey, string $value = $row[$primaryKey] ?? $row[$fallbackKey] ?? ''; - return $value === '' ? null : (int) round(SafeCast::tryFloat($value) ?? 0.0); - } - - /** @param array $row */ - private static function parseInt(array $row, string $primaryKey, string $fallbackKey): int - { - $value = $row[$primaryKey] ?? $row[$fallbackKey] ?? null; - if ($value === null || $value === '') { - return 0; - } - - return (int) round(SafeCast::tryFloat($value) ?? 0.0); + return $value === '' ? null : SafeCast::toInt($value); } private static function detectDelimiter(string $csvContent): string { - $firstLine = strtok($csvContent, "\n"); - if ($firstLine === false) { - $firstLine = ''; - } + $firstLine = explode("\n", $csvContent, 2)[0]; $semicolonCount = substr_count($firstLine, ';'); $commaCount = substr_count($firstLine, ','); diff --git a/tests/TapeStation/CompactRegionTableParserTest.php b/tests/TapeStation/CompactRegionTableParserTest.php index e33f95a..093e7f3 100644 --- a/tests/TapeStation/CompactRegionTableParserTest.php +++ b/tests/TapeStation/CompactRegionTableParserTest.php @@ -132,8 +132,8 @@ public function testParseWithMissingFromColumn(): void public function testThrowsOnMissingConcentrationColumn(): void { $csv = <<<'CSV' - FileName;WellId;Sample Description - 2026-02-25.D1000;A1;Sample1 + FileName;WellId;Sample Description;From [bp];To [bp];Average Size [bp];Region Molarity [nmol/l];% of Total;Region Comment + 2026-02-25.D1000;A1;Sample1;200;1000;500;38.0;90.0;MRD CSV; $this->expectException(\RuntimeException::class); From 46527c272191520faa4b6efdde0264df0e25fca0 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Mon, 16 Mar 2026 11:01:24 +0100 Subject: [PATCH 15/15] refactor(concentration): rename to massConcentrationToMolarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Concentration" is ambiguous since molarity itself is a concentration. Use the precise chemistry term "mass concentration" (ng/µl) to eliminate ambiguity. Co-Authored-By: Claude Opus 4.6 --- src/Concentration/MolarityConverter.php | 4 ++-- tests/Concentration/MolarityConverterTest.php | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Concentration/MolarityConverter.php b/src/Concentration/MolarityConverter.php index f76ac8e..cecb89d 100644 --- a/src/Concentration/MolarityConverter.php +++ b/src/Concentration/MolarityConverter.php @@ -8,7 +8,7 @@ class MolarityConverter public const DALTONS_PER_NUCLEOTIDE_SSDNA = 330.0; public const DALTONS_PER_NUCLEOTIDE_RNA = 340.0; - public static function concentrationToMolarity( + public static function massConcentrationToMolarity( float $concentrationNgPerUl, float $averageFragmentSize, float $averageDaltonsPerUnit @@ -19,7 +19,7 @@ public static function concentrationToMolarity( return ($concentrationNgPerUl / ($averageDaltonsPerUnit * $averageFragmentSize)) * 1_000_000; } - public static function molarityToConcentration( + public static function molarityToMassConcentration( float $molarityNmolPerL, float $averageFragmentSize, float $averageDaltonsPerUnit diff --git a/tests/Concentration/MolarityConverterTest.php b/tests/Concentration/MolarityConverterTest.php index 768925c..3ee0c6e 100644 --- a/tests/Concentration/MolarityConverterTest.php +++ b/tests/Concentration/MolarityConverterTest.php @@ -10,9 +10,9 @@ final class MolarityConverterTest extends TestCase { /** @dataProvider conversionPairs */ #[DataProvider('conversionPairs')] - public function testConcentrationToMolarity(float $expectedNmolPerL, float $ngPerUl, int $fragmentSizeBp): void + public function testMassConcentrationToMolarity(float $expectedNmolPerL, float $ngPerUl, int $fragmentSizeBp): void { - $result = MolarityConverter::concentrationToMolarity($ngPerUl, $fragmentSizeBp, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); + $result = MolarityConverter::massConcentrationToMolarity($ngPerUl, $fragmentSizeBp, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); self::assertEqualsWithDelta($expectedNmolPerL, $result, 0.1); } @@ -20,8 +20,8 @@ public function testConcentrationToMolarity(float $expectedNmolPerL, float $ngPe #[DataProvider('conversionPairs')] public function testRoundTrip(float $expectedNmolPerL, float $ngPerUl, int $fragmentSizeBp): void { - $nmolPerL = MolarityConverter::concentrationToMolarity($ngPerUl, $fragmentSizeBp, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); - $backToNgPerUl = MolarityConverter::molarityToConcentration($nmolPerL, $fragmentSizeBp, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); + $nmolPerL = MolarityConverter::massConcentrationToMolarity($ngPerUl, $fragmentSizeBp, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); + $backToNgPerUl = MolarityConverter::molarityToMassConcentration($nmolPerL, $fragmentSizeBp, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); self::assertEqualsWithDelta($ngPerUl, $backToNgPerUl, 0.001); } @@ -30,7 +30,7 @@ public function testThrowsOnZeroFragmentSize(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Fragment size must be positive'); - MolarityConverter::concentrationToMolarity(10.0, 0, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); + MolarityConverter::massConcentrationToMolarity(10.0, 0, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); } public function testThrowsOnNegativeFragmentSize(): void @@ -38,7 +38,7 @@ public function testThrowsOnNegativeFragmentSize(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Fragment size must be positive'); - MolarityConverter::molarityToConcentration(10.0, -100, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); + MolarityConverter::molarityToMassConcentration(10.0, -100, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA); } public function testThrowsOnZeroDaltonsPerUnit(): void @@ -46,7 +46,7 @@ public function testThrowsOnZeroDaltonsPerUnit(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Daltons per unit must be positive'); - MolarityConverter::concentrationToMolarity(10.0, 400, 0.0); + MolarityConverter::massConcentrationToMolarity(10.0, 400, 0.0); } /** @return iterable */