diff --git a/CHANGELOG.md b/CHANGELOG.md index fb5af1c..69e83cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ See [GitHub releases](https://github.com/mll-lab/php-utils/releases). ## Unreleased +## v5.18.0 + +### Added + +- Support parsing Lightcycler Sample Sheets from XML-file https://github.com/mll-lab/php-utils/pull/56 + ## v5.17.0 ### Added diff --git a/composer.json b/composer.json index 0f49d87..b0d15fb 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "require": { "php": "^7.4 || ^8", "ext-calendar": "*", + "ext-simplexml": "*", "illuminate/support": "^8.73 || ^9 || ^10 || ^11 || ^12", "mll-lab/str_putcsv": "^1", "nesbot/carbon": "^2.62.1 || ^3", diff --git a/src/LightcyclerExportSheet/DuplicateCoordinatesException.php b/src/LightcyclerExportSheet/DuplicateCoordinatesException.php new file mode 100644 index 0000000..6a4ea75 --- /dev/null +++ b/src/LightcyclerExportSheet/DuplicateCoordinatesException.php @@ -0,0 +1,14 @@ + $duplicateCoordinates */ + public static function forCoordinates(array $duplicateCoordinates): self + { + $coordinates = implode(', ', $duplicateCoordinates); + + return new self("Duplicate sample coordinates found: {$coordinates}."); + } +} diff --git a/src/LightcyclerExportSheet/LightcyclerDataParsingTrait.php b/src/LightcyclerExportSheet/LightcyclerDataParsingTrait.php new file mode 100644 index 0000000..d40dea0 --- /dev/null +++ b/src/LightcyclerExportSheet/LightcyclerDataParsingTrait.php @@ -0,0 +1,91 @@ +cleanString($value); + + if ($cleanString === null) { + return null; + } + + if (! is_numeric($cleanString)) { + throw new \InvalidArgumentException("Invalid float value: '{$cleanString}'"); + } + + return (float) $cleanString; + } + + /** @return array{float, float} */ + protected function validateConcentrationAndCrossingPoint(?string $concentration, ?string $crossingPoint): array + { + $parsedConcentration = $this->parseFloatValue($concentration); + $parsedCrossingPoint = $this->parseFloatValue($crossingPoint); + + if (($parsedConcentration === null) !== ($parsedCrossingPoint === null)) { + throw new \InvalidArgumentException('Concentration and crossing point must both be present or both be absent'); + } + + return [ + $parsedConcentration ?? LightcyclerXmlParser::FLOAT_ZERO, + $parsedCrossingPoint ?? LightcyclerXmlParser::FLOAT_ZERO, + ]; + } + + protected function cleanString(?string $maybeString): ?string + { + if ($maybeString === null) { + return null; + } + $trimmed = trim($maybeString); + + return $trimmed !== '' + ? $trimmed + : null; + } + + /** @param array $properties */ + protected function requiredProperty(array $properties, string $propertyName): string + { + $cleaned = $this->cleanString($properties[$propertyName] ?? null); + if ($cleaned === null) { + throw MissingRequiredPropertyException::forProperty($propertyName); + } + + return $cleaned; + } + + /** @param array $properties */ + protected function optionalProperty(array $properties, string $propertyName): ?string + { + return $this->cleanString($properties[$propertyName] ?? null); + } + + /** + * @param Collection $samples + * + * @return Collection + */ + protected function validateUniqueCoordinates(Collection $samples): Collection + { + $coordinateCount = []; + + foreach ($samples as $sample) { + $coordinateString = $sample->coordinates->toString(); + $coordinateCount[$coordinateString] = ($coordinateCount[$coordinateString] ?? 0) + 1; + } + + $duplicates = array_keys(array_filter($coordinateCount, fn (int $count): bool => $count > 1)); + + if ($duplicates !== []) { + throw DuplicateCoordinatesException::forCoordinates($duplicates); + } + + return $samples; + } +} diff --git a/src/LightcyclerExportSheet/LightcyclerSample.php b/src/LightcyclerExportSheet/LightcyclerSample.php new file mode 100644 index 0000000..2e5e3f0 --- /dev/null +++ b/src/LightcyclerExportSheet/LightcyclerSample.php @@ -0,0 +1,35 @@ + */ + public Coordinates $coordinates; + + public float $calculatedConcentration; + + public float $crossingPoint; + + public ?float $standardConcentration; + + /** @param Coordinates $coordinates */ + public function __construct( + string $name, + Coordinates $coordinates, + float $calculatedConcentration, + float $crossingPoint, + ?float $standardConcentration = null + ) { + $this->standardConcentration = $standardConcentration; + $this->crossingPoint = $crossingPoint; + $this->calculatedConcentration = $calculatedConcentration; + $this->coordinates = $coordinates; + $this->name = $name; + } +} diff --git a/src/LightcyclerExportSheet/LightcyclerXmlParser.php b/src/LightcyclerExportSheet/LightcyclerXmlParser.php new file mode 100644 index 0000000..3f3b0f0 --- /dev/null +++ b/src/LightcyclerExportSheet/LightcyclerXmlParser.php @@ -0,0 +1,90 @@ + */ + public function parse(string $xmlContent): Collection + { + $xml = simplexml_load_string($xmlContent); + + $analyses = $xml->analyses; + if ($analyses === null || $analyses->analysis === null) { + return new Collection(); + } + + return $this->extractAnalysisSamples($analyses); + } + + /** @return Collection */ + private function extractAnalysisSamples(\SimpleXMLElement $analyses): Collection + { + $samples = []; + + foreach ($analyses->analysis as $analysis) { + if (property_exists($analysis, 'AnalysisSamples') + && $analysis->AnalysisSamples !== null + ) { + foreach ($analysis->AnalysisSamples->AnalysisSample as $xmlSample) { + $samples[] = $this->createSampleFromXml($xmlSample); + } + } + } + + return $this->validateUniqueCoordinates(new Collection($samples)); + } + + private function createSampleFromXml(\SimpleXMLElement $xmlSample): LightcyclerSample + { + $sampleProperties = $this->extractPropertiesFromXml($xmlSample); + + [$validatedConcentration, $validatedCrossingPoint] = $this->validateConcentrationAndCrossingPoint( + $this->optionalProperty($sampleProperties, 'CalcConc'), + $this->optionalProperty($sampleProperties, 'CrossingPoint'), + ); + + $coordinates = Coordinates::fromString( + $this->requiredProperty($sampleProperties, 'Position'), + new CoordinateSystem12x8(), + ); + + return new LightcyclerSample( + $this->requiredProperty($sampleProperties, 'name'), + $coordinates, + $validatedConcentration, + $validatedCrossingPoint, + $this->parseFloatValue($this->optionalProperty( + $sampleProperties, + 'StandardConc', + )), + ); + } + + /** @return array */ + private function extractPropertiesFromXml(\SimpleXMLElement $xmlElement): array + { + $properties = []; + + foreach ($xmlElement->prop as $propertyNode) { + $propertyName = (string) $propertyNode->attributes()->name; + $propertyValue = $propertyNode->__toString(); + + if (! isset($properties[$propertyName])) { + $properties[$propertyName] = $propertyValue; + } + } + + return $properties; + } +} diff --git a/src/LightcyclerExportSheet/MissingRequiredPropertyException.php b/src/LightcyclerExportSheet/MissingRequiredPropertyException.php new file mode 100644 index 0000000..d72d1a6 --- /dev/null +++ b/src/LightcyclerExportSheet/MissingRequiredPropertyException.php @@ -0,0 +1,11 @@ + + + + + + + A1 + 100.0 + 25.0 + + + + + + XML; + + $this->expectExceptionObject(MissingRequiredPropertyException::forProperty('name')); + + $parser = new LightcyclerXmlParser(); + $parser->parse($xmlWithMissingName); + } + + public function testParseXmlReturnsEmptyCollectionForInvalidXml(): void + { + $invalidXml = /* @lang XML */ 'xml'; + + $parser = new LightcyclerXmlParser(); + $result = $parser->parse($invalidXml); + + self::assertTrue($result->isEmpty()); + } + + public function testSampleTypeDetection(): void + { + $parser = new LightcyclerXmlParser(); + + // Test Patient Sample + $patientXml = /* @lang XML */ << + + + + + + XX-XXXXXX + A1 + 100.0 + 25.0 + + + + + + XML; + + $result = $parser->parse($patientXml); + self::assertCount(1, $result); + $sample = $result->first(); + self::assertInstanceOf(LightcyclerSample::class, $sample); // Test Standard Sample + $standardConcentration = 400.0; + $standardXml = /* @lang XML */ << + + + + + + STANDARD-400 + B1 + {$standardConcentration} + {$standardConcentration} + 20.0 + + + + + + XML; + + $result = $parser->parse($standardXml); + self::assertCount(1, $result); + $sample = $result->first(); + self::assertInstanceOf(LightcyclerSample::class, $sample); + + // Test Control Sample + $controlXml = /* @lang XML */ << + + + + + + CONTROL + C1 + 0.0 + 35.0 + + + + + + XML; + + $result = $parser->parse($controlXml); + self::assertCount(1, $result); + $sample = $result->first(); + self::assertInstanceOf(LightcyclerSample::class, $sample); + } + + public function testParseXmlValidatesConcentrationAndCrossingPointConsistency(): void + { + $parser = new LightcyclerXmlParser(); + + // Test: CalcConc present but CrossingPoint missing + $xmlWithCalcConcButNoCrossingPoint = /* @lang XML */ << + + + + + + P123 + A1 + 100.0 + + + + + + XML; + + $this->expectExceptionObject(new \InvalidArgumentException('Concentration and crossing point must both be present or both be absent')); + $parser->parse($xmlWithCalcConcButNoCrossingPoint); + } + + public function testParseXmlHandlesEmptyAndMissingValues(): void + { + $parser = new LightcyclerXmlParser(); + + // Test empty string values are treated as absent + $xmlWithEmptyValues = /* @lang XML */ << + + + + + + Control-Empty + B1 + + + + + + + + XML; + + $result = $parser->parse($xmlWithEmptyValues); + self::assertCount(1, $result); + self::assertEqualsWithDelta(0.0, $result->firstOrFail()->calculatedConcentration, PHP_FLOAT_EPSILON); + self::assertEqualsWithDelta(0.0, $result->firstOrFail()->crossingPoint, PHP_FLOAT_EPSILON); + + // Test completely missing values + $xmlWithBothMissing = /* @lang XML */ << + + + + + + NTC-Control + H12 + + + + + + XML; + + $result = $parser->parse($xmlWithBothMissing); + self::assertCount(1, $result); + self::assertInstanceOf(LightcyclerSample::class, $result->first()); + self::assertEqualsWithDelta(0.0, $result->first()->calculatedConcentration, PHP_FLOAT_EPSILON); + self::assertEqualsWithDelta(0.0, $result->first()->crossingPoint, PHP_FLOAT_EPSILON); + } + + public function testParseXmlHandlesInvalidFloatValues(): void + { + $xmlWithInvalidFloat = /* @lang XML */ << + + + + + + Test + A1 + invalid + also-invalid + + + + + + XML; + + $this->expectExceptionObject(new \InvalidArgumentException("Invalid float value: 'invalid'")); + + $parser = new LightcyclerXmlParser(); + $parser->parse($xmlWithInvalidFloat); + } +}