Skip to content

Commit 1eb7b1f

Browse files
authored
feat: Implement Lightcycler XML parsing with validation for unique sample coordinates
1 parent 8eb7bc7 commit 1eb7b1f

File tree

8 files changed

+475
-0
lines changed

8 files changed

+475
-0
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ See [GitHub releases](https://github.com/mll-lab/php-utils/releases).
99

1010
## Unreleased
1111

12+
## v5.18.0
13+
14+
### Added
15+
16+
- Support parsing Lightcycler Sample Sheets from XML-file https://github.com/mll-lab/php-utils/pull/56
17+
1218
## v5.17.0
1319

1420
### Added

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"require": {
1818
"php": "^7.4 || ^8",
1919
"ext-calendar": "*",
20+
"ext-simplexml": "*",
2021
"illuminate/support": "^8.73 || ^9 || ^10 || ^11 || ^12",
2122
"mll-lab/str_putcsv": "^1",
2223
"nesbot/carbon": "^2.62.1 || ^3",
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\Utils\LightcyclerExportSheet;
4+
5+
class DuplicateCoordinatesException extends \InvalidArgumentException
6+
{
7+
/** @param array<string> $duplicateCoordinates */
8+
public static function forCoordinates(array $duplicateCoordinates): self
9+
{
10+
$coordinates = implode(', ', $duplicateCoordinates);
11+
12+
return new self("Duplicate sample coordinates found: {$coordinates}.");
13+
}
14+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\Utils\LightcyclerExportSheet;
4+
5+
use Illuminate\Support\Collection;
6+
7+
trait LightcyclerDataParsingTrait
8+
{
9+
protected function parseFloatValue(?string $value): ?float
10+
{
11+
$cleanString = $this->cleanString($value);
12+
13+
if ($cleanString === null) {
14+
return null;
15+
}
16+
17+
if (! is_numeric($cleanString)) {
18+
throw new \InvalidArgumentException("Invalid float value: '{$cleanString}'");
19+
}
20+
21+
return (float) $cleanString;
22+
}
23+
24+
/** @return array{float, float} */
25+
protected function validateConcentrationAndCrossingPoint(?string $concentration, ?string $crossingPoint): array
26+
{
27+
$parsedConcentration = $this->parseFloatValue($concentration);
28+
$parsedCrossingPoint = $this->parseFloatValue($crossingPoint);
29+
30+
if (($parsedConcentration === null) !== ($parsedCrossingPoint === null)) {
31+
throw new \InvalidArgumentException('Concentration and crossing point must both be present or both be absent');
32+
}
33+
34+
return [
35+
$parsedConcentration ?? LightcyclerXmlParser::FLOAT_ZERO,
36+
$parsedCrossingPoint ?? LightcyclerXmlParser::FLOAT_ZERO,
37+
];
38+
}
39+
40+
protected function cleanString(?string $maybeString): ?string
41+
{
42+
if ($maybeString === null) {
43+
return null;
44+
}
45+
$trimmed = trim($maybeString);
46+
47+
return $trimmed !== ''
48+
? $trimmed
49+
: null;
50+
}
51+
52+
/** @param array<string, string> $properties */
53+
protected function requiredProperty(array $properties, string $propertyName): string
54+
{
55+
$cleaned = $this->cleanString($properties[$propertyName] ?? null);
56+
if ($cleaned === null) {
57+
throw MissingRequiredPropertyException::forProperty($propertyName);
58+
}
59+
60+
return $cleaned;
61+
}
62+
63+
/** @param array<string, string> $properties */
64+
protected function optionalProperty(array $properties, string $propertyName): ?string
65+
{
66+
return $this->cleanString($properties[$propertyName] ?? null);
67+
}
68+
69+
/**
70+
* @param Collection<array-key, LightcyclerSample> $samples
71+
*
72+
* @return Collection<array-key, LightcyclerSample>
73+
*/
74+
protected function validateUniqueCoordinates(Collection $samples): Collection
75+
{
76+
$coordinateCount = [];
77+
78+
foreach ($samples as $sample) {
79+
$coordinateString = $sample->coordinates->toString();
80+
$coordinateCount[$coordinateString] = ($coordinateCount[$coordinateString] ?? 0) + 1;
81+
}
82+
83+
$duplicates = array_keys(array_filter($coordinateCount, fn (int $count): bool => $count > 1));
84+
85+
if ($duplicates !== []) {
86+
throw DuplicateCoordinatesException::forCoordinates($duplicates);
87+
}
88+
89+
return $samples;
90+
}
91+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\Utils\LightcyclerExportSheet;
4+
5+
use MLL\Utils\Microplate\Coordinates;
6+
use MLL\Utils\Microplate\CoordinateSystem12x8;
7+
8+
class LightcyclerSample
9+
{
10+
public string $name;
11+
12+
/** @var Coordinates<CoordinateSystem12x8> */
13+
public Coordinates $coordinates;
14+
15+
public float $calculatedConcentration;
16+
17+
public float $crossingPoint;
18+
19+
public ?float $standardConcentration;
20+
21+
/** @param Coordinates<CoordinateSystem12x8> $coordinates */
22+
public function __construct(
23+
string $name,
24+
Coordinates $coordinates,
25+
float $calculatedConcentration,
26+
float $crossingPoint,
27+
?float $standardConcentration = null
28+
) {
29+
$this->standardConcentration = $standardConcentration;
30+
$this->crossingPoint = $crossingPoint;
31+
$this->calculatedConcentration = $calculatedConcentration;
32+
$this->coordinates = $coordinates;
33+
$this->name = $name;
34+
}
35+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\Utils\LightcyclerExportSheet;
4+
5+
use Illuminate\Support\Collection;
6+
use MLL\Utils\Microplate\Coordinates;
7+
use MLL\Utils\Microplate\CoordinateSystem12x8;
8+
9+
use function Safe\simplexml_load_string;
10+
11+
class LightcyclerXmlParser
12+
{
13+
use LightcyclerDataParsingTrait;
14+
15+
public const FLOAT_ZERO = 0.0;
16+
17+
/** @return Collection<array-key, LightcyclerSample> */
18+
public function parse(string $xmlContent): Collection
19+
{
20+
$xml = simplexml_load_string($xmlContent);
21+
22+
$analyses = $xml->analyses;
23+
if ($analyses === null || $analyses->analysis === null) {
24+
return new Collection();
25+
}
26+
27+
return $this->extractAnalysisSamples($analyses);
28+
}
29+
30+
/** @return Collection<array-key, LightcyclerSample> */
31+
private function extractAnalysisSamples(\SimpleXMLElement $analyses): Collection
32+
{
33+
$samples = [];
34+
35+
foreach ($analyses->analysis as $analysis) {
36+
if (property_exists($analysis, 'AnalysisSamples')
37+
&& $analysis->AnalysisSamples !== null
38+
) {
39+
foreach ($analysis->AnalysisSamples->AnalysisSample as $xmlSample) {
40+
$samples[] = $this->createSampleFromXml($xmlSample);
41+
}
42+
}
43+
}
44+
45+
return $this->validateUniqueCoordinates(new Collection($samples));
46+
}
47+
48+
private function createSampleFromXml(\SimpleXMLElement $xmlSample): LightcyclerSample
49+
{
50+
$sampleProperties = $this->extractPropertiesFromXml($xmlSample);
51+
52+
[$validatedConcentration, $validatedCrossingPoint] = $this->validateConcentrationAndCrossingPoint(
53+
$this->optionalProperty($sampleProperties, 'CalcConc'),
54+
$this->optionalProperty($sampleProperties, 'CrossingPoint'),
55+
);
56+
57+
$coordinates = Coordinates::fromString(
58+
$this->requiredProperty($sampleProperties, 'Position'),
59+
new CoordinateSystem12x8(),
60+
);
61+
62+
return new LightcyclerSample(
63+
$this->requiredProperty($sampleProperties, 'name'),
64+
$coordinates,
65+
$validatedConcentration,
66+
$validatedCrossingPoint,
67+
$this->parseFloatValue($this->optionalProperty(
68+
$sampleProperties,
69+
'StandardConc',
70+
)),
71+
);
72+
}
73+
74+
/** @return array<string, string> */
75+
private function extractPropertiesFromXml(\SimpleXMLElement $xmlElement): array
76+
{
77+
$properties = [];
78+
79+
foreach ($xmlElement->prop as $propertyNode) {
80+
$propertyName = (string) $propertyNode->attributes()->name;
81+
$propertyValue = $propertyNode->__toString();
82+
83+
if (! isset($properties[$propertyName])) {
84+
$properties[$propertyName] = $propertyValue;
85+
}
86+
}
87+
88+
return $properties;
89+
}
90+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace MLL\Utils\LightcyclerExportSheet;
4+
5+
class MissingRequiredPropertyException extends \InvalidArgumentException
6+
{
7+
public static function forProperty(string $propertyName): self
8+
{
9+
return new self("Required property '{$propertyName}' is missing or empty.");
10+
}
11+
}

0 commit comments

Comments
 (0)