Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/Concentration/MolarityConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php declare(strict_types=1);

namespace MLL\Utils\Concentration;

class MolarityConverter
{
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 massConcentrationToMolarity(
float $concentrationNgPerUl,
float $averageFragmentSize,
float $averageDaltonsPerUnit
): float {
self::assertPositive($averageFragmentSize, 'Fragment size');
self::assertPositive($averageDaltonsPerUnit, 'Daltons per unit');

return ($concentrationNgPerUl / ($averageDaltonsPerUnit * $averageFragmentSize)) * 1_000_000;
}

public static function molarityToMassConcentration(
float $molarityNmolPerL,
float $averageFragmentSize,
float $averageDaltonsPerUnit
): float {
self::assertPositive($averageFragmentSize, 'Fragment size');
self::assertPositive($averageDaltonsPerUnit, 'Daltons per unit');

return ($molarityNmolPerL * $averageDaltonsPerUnit * $averageFragmentSize) / 1_000_000;
}

private static function assertPositive(float $value, string $name): void
{
if ($value <= 0.0) {
throw new \InvalidArgumentException("{$name} must be positive, got {$value}");
}
}
}
107 changes: 107 additions & 0 deletions src/TapeStation/CompactRegionTableParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php declare(strict_types=1);

namespace MLL\Utils\TapeStation;

use Illuminate\Support\Collection;
use MLL\Utils\CSVArray;
use MLL\Utils\Microplate\Coordinates;
use MLL\Utils\Microplate\CoordinateSystem12x8;
use MLL\Utils\SafeCast;
use MLL\Utils\StringUtil;

/**
* 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 rejectHighSensitivityAssay().
*/
class CompactRegionTableParser
{
/** µ 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]';

/** @return Collection<int, CompactRegionTableRecord> */
public static function parse(string $csvContent): Collection
{
$csvContent = StringUtil::toUTF8($csvContent);
$delimiter = self::detectDelimiter($csvContent);

$rows = CSVArray::toArray($csvContent, $delimiter);

return (new Collection($rows))
->map(static fn (array $row): CompactRegionTableRecord => self::recordFromRow($row));
}

/** @param array<string, string> $row */
private static function recordFromRow(array $row): CompactRegionTableRecord
{
self::rejectHighSensitivityAssay($row);

return new CompactRegionTableRecord(
$row['FileName'] ?? '',
Coordinates::fromString($row['WellId'] ?? '', new CoordinateSystem12x8()),
$row['Sample Description'] ?? '',
self::parseNullableInt($row, 'From [bp]', 'From [nt]'),
SafeCast::toInt($row['To [bp]'] ?? $row['To [nt]'] ?? ''),
SafeCast::toInt($row['Average Size [bp]'] ?? $row['Average Size [nt]'] ?? ''),
self::parseConcentration($row),
SafeCast::toFloat($row[self::MOLARITY_KEY] ?? ''),
SafeCast::toFloat($row['% of Total'] ?? ''),
$row['Region Comment'] ?? ''
);
}

/**
* HS assays use pg/µl + pmol/l (1000× smaller than ng/µl + nmol/l).
* Silently parsing those would produce dangerously wrong results.
*
* @param array<string, string> $row
*/
private static function rejectHighSensitivityAssay(array $row): void
{
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<string, string> $row */
private static function parseConcentration(array $row): float
{
foreach ($row as $key => $value) {
if (strpos($key, self::CONCENTRATION_KEY_PREFIX) === 0) {
return SafeCast::toFloat($value);
}
}

throw new \RuntimeException('Concentration column not found. Expected column starting with "' . self::CONCENTRATION_KEY_PREFIX . '"');
}

/** @param array<string, string> $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 : SafeCast::toInt($value);
}

private static function detectDelimiter(string $csvContent): string
{
$firstLine = explode("\n", $csvContent, 2)[0];

$semicolonCount = substr_count($firstLine, ';');
$commaCount = substr_count($firstLine, ',');

return $semicolonCount > $commaCount ? ';' : ',';
}
}
57 changes: 57 additions & 0 deletions src/TapeStation/CompactRegionTableRecord.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php declare(strict_types=1);

namespace MLL\Utils\TapeStation;

use MLL\Utils\Microplate\Coordinates;
use MLL\Utils\Microplate\CoordinateSystem12x8;

class CompactRegionTableRecord
{
public string $fileName;

/** @var Coordinates<CoordinateSystem12x8> */
public Coordinates $coordinates;

public string $sampleDescription;

/** Null when the column is absent from the export. */
public ?int $from;

public int $to;

/** Center of mass, not peak maximum. */
public int $averageSize;

public float $concentrationNgPerUl;

public float $regionMolarityNmolPerL;

public float $percentOfTotal;

public string $regionComment;

/** @param Coordinates<CoordinateSystem12x8> $coordinates */
public function __construct(
string $fileName,
Coordinates $coordinates,
string $sampleDescription,
?int $from,
int $to,
int $averageSize,
float $concentrationNgPerUl,
float $regionMolarityNmolPerL,
float $percentOfTotal,
string $regionComment
) {
$this->fileName = $fileName;
$this->coordinates = $coordinates;
$this->sampleDescription = $sampleDescription;
$this->from = $from;
$this->to = $to;
$this->averageSize = $averageSize;
$this->concentrationNgPerUl = $concentrationNgPerUl;
$this->regionMolarityNmolPerL = $regionMolarityNmolPerL;
$this->percentOfTotal = $percentOfTotal;
$this->regionComment = $regionComment;
}
}
62 changes: 62 additions & 0 deletions tests/Concentration/MolarityConverterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php declare(strict_types=1);

namespace MLL\Utils\Tests\Concentration;

use MLL\Utils\Concentration\MolarityConverter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class MolarityConverterTest extends TestCase
{
/** @dataProvider conversionPairs */
#[DataProvider('conversionPairs')]
public function testMassConcentrationToMolarity(float $expectedNmolPerL, float $ngPerUl, int $fragmentSizeBp): void
{
$result = MolarityConverter::massConcentrationToMolarity($ngPerUl, $fragmentSizeBp, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA);
self::assertEqualsWithDelta($expectedNmolPerL, $result, 0.1);
}

/** @dataProvider conversionPairs */
#[DataProvider('conversionPairs')]
public function testRoundTrip(float $expectedNmolPerL, float $ngPerUl, int $fragmentSizeBp): void
{
$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);
}

public function testThrowsOnZeroFragmentSize(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Fragment size must be positive');

MolarityConverter::massConcentrationToMolarity(10.0, 0, MolarityConverter::DALTONS_PER_BASE_PAIR_DSDNA);
}

public function testThrowsOnNegativeFragmentSize(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Fragment size must be positive');

MolarityConverter::molarityToMassConcentration(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::massConcentrationToMolarity(10.0, 400, 0.0);
}

/** @return iterable<string, array{float, float, int}> */
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];
}
}
Loading
Loading