diff --git a/phpstan/php-below-8.1.neon b/phpstan/php-below-8.1.neon index a2f36593..9a45de45 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 diff --git a/src/InterOp/ClusterStatistic.php b/src/InterOp/ClusterStatistic.php new file mode 100644 index 00000000..a48b4345 --- /dev/null +++ b/src/InterOp/ClusterStatistic.php @@ -0,0 +1,22 @@ +density = $density; + $this->clusterPassingFilter = $clusterPassingFilter; + $this->clusterCountMillions = $clusterCountMillions; + $this->clusterCountPassingFilterMillions = $clusterCountPassingFilterMillions; + } +} diff --git a/src/InterOp/DeviationValue.php b/src/InterOp/DeviationValue.php new file mode 100644 index 00000000..bf6ba73f --- /dev/null +++ b/src/InterOp/DeviationValue.php @@ -0,0 +1,43 @@ +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) { // @phpstan-ignore-line theCodingMachineSafe.function (native preg_match needed for PHPStan 2.x capture type narrowing) + return null; + } + + 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/InterOpException.php b/src/InterOp/InterOpException.php new file mode 100644 index 00000000..d0a90bec --- /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) + { + [$dataRead1Tag, $dataRead2Tag] = self::findDataReadTags($summary); + + $read1Rows = $reads[$dataRead1Tag] ?? null; + if ($read1Rows === null || $read1Rows === []) { + throw new InterOpException("Reads data missing or empty for: {$dataRead1Tag}."); + } + + $read2Rows = $reads[$dataRead2Tag] ?? null; + if ($read2Rows === null || $read2Rows === []) { + throw new InterOpException("Reads data missing or empty for: {$dataRead2Tag}."); + } + + $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, $nonIndexedRow); + } + + /** + * 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 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 + $dataReadTags = []; + foreach ($summary as $entry) { + $level = $entry['Level']; + if ($level === 'Non-indexed' || $level === 'Total') { + continue; + } + + // Identify index reads + if (substr($level, -3) !== '(I)') { // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) + $dataReadTags[] = $level; + } + } + + $count = count($dataReadTags); + 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 [$dataReadTags[0], $dataReadTags[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/LaneResult.php b/src/InterOp/LaneResult.php new file mode 100644 index 00000000..e37f16e0 --- /dev/null +++ b/src/InterOp/LaneResult.php @@ -0,0 +1,119 @@ +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 + { + $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']}."); + } + + $clusterPassingFilter = DeviationValue::parse($row['Cluster PF']); + if (! $clusterPassingFilter instanceof DeviationValue) { + throw new InterOpException("Expected parseable Cluster PF, got: {$row['Cluster PF']}."); + } + + $aligned = DeviationValue::parse($row['Aligned']); + if (! $aligned instanceof DeviationValue) { + throw new InterOpException("Expected parseable Aligned, got: {$row['Aligned']}."); + } + + $error = DeviationValue::parse($row['Error']); + if (! $error instanceof DeviationValue) { + throw new InterOpException("Expected parseable Error, got: {$row['Error']}."); + } + + $intensityCycle = DeviationValue::parse($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']); + 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, + $clusterPassingFilter, + SafeCast::toFloat($row['Reads']), + SafeCast::toFloat($row['Reads PF']) + ); + + $sequencingQualityControl = new SequencingQualityControl( + SafeCast::toFloat($row['%>=Q30']), + SafeCast::toFloat($phasingParts[0]), + SafeCast::toFloat($phasingParts[1]), + $aligned, + $error + ); + + return new self( + $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( + DeviationValue::average($a->clusterStatistic->density, $b->clusterStatistic->density), + DeviationValue::average($a->clusterStatistic->clusterPassingFilter, $b->clusterStatistic->clusterPassingFilter), + $a->clusterStatistic->clusterCountMillions + $b->clusterStatistic->clusterCountMillions, + $a->clusterStatistic->clusterCountPassingFilterMillions + $b->clusterStatistic->clusterCountPassingFilterMillions + ); + + $sequencingQualityControl = new SequencingQualityControl( + ($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, + $sequencingQualityControl, + intdiv($a->intensityCycle + $b->intensityCycle, 2), + $a->yield + $b->yield + ); + } +} diff --git a/src/InterOp/MetaInfo.php b/src/InterOp/MetaInfo.php new file mode 100644 index 00000000..c99452df --- /dev/null +++ b/src/InterOp/MetaInfo.php @@ -0,0 +1,37 @@ +>, + * reads: array>>, + * }, + * uncPath: string, + * } + */ +class MetaInfo +{ + public RunParameters $runParameters; + + public InterOpResult $interOpResult; + + public string $uncPath; + + public function __construct(string $json) + { + /** @var MetaInfoPayload $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 00000000..25b4b45a --- /dev/null +++ b/src/InterOp/RunParameters.php @@ -0,0 +1,184 @@ +}, + * } + */ +class RunParameters +{ + public const APPLICATION_MISEQ = 'MiSeq Control Software'; + public const APPLICATION_MISEQ_I100 = 'MiSeqi100Series Control Software'; + + public string $application; + + public Carbon $runDate; + + public string $flowcell; + + public ?string $flowcellExpirationDate = null; + + public ?string $realTimeAnalysisVersion = null; + + public string $info; + + public string $controlSoftwareVersion; + + /** @var array */ + public array $reagents; + + /** @param MiSeqParams|I100Params $params */ + public function __construct(array $params) + { + if (isset($params['Setup']['ApplicationName'])) { + $this->parseMiSeq($params); + } 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.'); + } + } + + /** @param MiSeqParams $params */ + protected function parseMiSeq(array $params): void + { + $this->application = $params['Setup']['ApplicationName']; + $this->info = $params['RunID']; + $this->controlSoftwareVersion = $params['MCSVersion']; + $this->realTimeAnalysisVersion = $params['RTAVersion']; + + $runStartDate = str_pad(SafeCast::toString($params['RunStartDate']), 6, '0', STR_PAD_LEFT); + $date = Carbon::createFromFormat('!ymd', $runStartDate); + if (! $date instanceof Carbon) { + throw new InterOpException("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 */ + 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) + $date = Carbon::createFromFormat('!Ymd', $dateString); + if (! $date instanceof Carbon) { + throw new InterOpException("Failed to parse i100 run date from RunId: {$this->info}."); + } + $this->runDate = $date; + + $consumables = $params['ConsumableInfo']['ConsumableInfo']; + $this->flowcell = ''; + $this->flowcellExpirationDate = null; + $this->reagents = []; + + foreach ($consumables as $consumable) { + $type = $consumable['Type']; + + if ($type === 'DryCartridge' || $type === 'WetCartridge') { + $expireDate = isset($consumable['ExpirationDate']) + ? $this->formatExpirationDate($consumable['ExpirationDate']) + : ''; + + $this->reagents[] = [ + 'name' => $consumable['SerialNumber'], + 'expire_date' => $expireDate, + ]; + + if ($type === 'DryCartridge') { + $this->flowcell = $consumable['SerialNumber']; + if ($expireDate !== '') { + $this->flowcellExpirationDate = $expireDate; + } + } + } + } + } + + 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) + if ($prefix !== '' && trim($prefix, '0') === '') { + return substr($serial, $pos + 1); // @phpstan-ignore-line theCodingMachineSafe.function (safe from PHP 8.0) + } + + return $serial; + } + + protected function formatExpirationDate(string $dateTime): string + { + $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; + } + } + + throw new InterOpException("Failed to parse expiration date: {$dateTime}."); + } +} diff --git a/src/InterOp/RunResult.php b/src/InterOp/RunResult.php new file mode 100644 index 00000000..840f2249 --- /dev/null +++ b/src/InterOp/RunResult.php @@ -0,0 +1,40 @@ +clusterStatistic = $clusterStatistic; + $this->sequencingQualityControl = $sequencingQualityControl; + } + + /** @param array $nonIndexedRow */ + public static function fromLaneResults(LaneResult $read1, LaneResult $read2, array $nonIndexedRow): self + { + $aggregated = LaneResult::aggregate($read1, $read2); + + $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/src/InterOp/SequencingQualityControl.php b/src/InterOp/SequencingQualityControl.php new file mode 100644 index 00000000..056c3143 --- /dev/null +++ b/src/InterOp/SequencingQualityControl.php @@ -0,0 +1,25 @@ +q30 = $q30; + $this->phasing = $phasing; + $this->prephasing = $prephasing; + $this->aligned = $aligned; + $this->error = $error; + } +} diff --git a/tests/GenomicRegionTest.php b/tests/GenomicRegionTest.php index 7ad4e734..244b63a0 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 00000000..53807882 --- /dev/null +++ b/tests/InterOp/DeviationValueTest.php @@ -0,0 +1,47 @@ +value); + 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 + { + 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]; + } +} diff --git a/tests/InterOp/InterOpResultTest.php b/tests/InterOp/InterOpResultTest.php new file mode 100644 index 00000000..5a582292 --- /dev/null +++ b/tests/InterOp/InterOpResultTest.php @@ -0,0 +1,124 @@ +> $summary + */ + #[DataProvider('dataReadDetectionProvider')] + public function testFindDataReads(string $description, array $summary, string $expectedFirst, string $expectedLast): void + { + [$first, $last] = InterOpResult::findDataReadTags($summary); + + self::assertSame($expectedFirst, $first, "{$description}: first data read"); + self::assertSame($expectedLast, $last, "{$description}: last data read"); + } + + public function testThrowsOnNoDataReads(): void + { + $this->expectException(InterOpException::class); + + InterOpResult::findDataReadTags([ + ['Level' => 'Read 1 (I)'], + ['Level' => 'Read 2 (I)'], + ['Level' => 'Non-indexed'], + ['Level' => 'Total'], + ]); + } + + public function testThrowsOnSingleDataRead(): void + { + $this->expectException(InterOpException::class); + + InterOpResult::findDataReadTags([ + ['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 + { + yield 'MiSeq single-index' => [ + 'description' => 'MiSeq with one index read', + 'summary' => [ + ['Level' => 'Read 1'], + ['Level' => 'Read 2 (I)'], + ['Level' => 'Read 3'], + ['Level' => 'Non-indexed'], + ['Level' => 'Total'], + ], + 'expectedFirst' => 'Read 1', + 'expectedLast' => 'Read 3', + ]; + + yield 'MiSeq dual-index' => [ + 'description' => 'MiSeq with two index reads', + 'summary' => [ + ['Level' => 'Read 1'], + ['Level' => 'Read 2 (I)'], + ['Level' => 'Read 3 (I)'], + ['Level' => 'Read 4'], + ['Level' => 'Non-indexed'], + ['Level' => 'Total'], + ], + 'expectedFirst' => 'Read 1', + 'expectedLast' => 'Read 4', + ]; + + yield 'i100 dual-index' => [ + 'description' => 'i100 with index reads first', + 'summary' => [ + ['Level' => 'Read 1 (I)'], + ['Level' => 'Read 2 (I)'], + ['Level' => 'Read 3'], + ['Level' => 'Read 4'], + ['Level' => 'Non-indexed'], + ['Level' => 'Total'], + ], + 'expectedFirst' => 'Read 3', + 'expectedLast' => 'Read 4', + ]; + } +} diff --git a/tests/InterOp/MetaInfoTest.php b/tests/InterOp/MetaInfoTest.php new file mode 100644 index 00000000..4b0074da --- /dev/null +++ b/tests/InterOp/MetaInfoTest.php @@ -0,0 +1,96 @@ +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->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); + 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->clusterPassingFilter->value); + 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); + 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::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); + } + + public function testParseMiSeqI100(): void + { + $json = file_get_contents(__DIR__ . '/meta-info-i100.json'); + $metaInfo = new MetaInfo($json); + + 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); + 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); + 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(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); + 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('example-server/miseq_active\\miSeqi100\\20260205_SH01038_0007_ASC2139476-SC3\\meta-info.json', $metaInfo->uncPath); + } +} diff --git a/tests/InterOp/RunParametersTest.php b/tests/InterOp/RunParametersTest.php new file mode 100644 index 00000000..139e906e --- /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']); + } +} diff --git a/tests/InterOp/meta-info-i100.json b/tests/InterOp/meta-info-i100.json new file mode 100644 index 00000000..fc23d265 --- /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.0.2.1/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": "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 new file mode 100644 index 00000000..6d210d38 --- /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": "example-server/miseq_active\\230421_M02074_0859_000000000-KT6CY\\meta-info.json" +}