Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions src/LightcyclerExportSheet/DuplicateCoordinatesException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php declare(strict_types=1);

namespace MLL\Utils\LightcyclerExportSheet;

final class DuplicateCoordinatesException extends \InvalidArgumentException
{
/** @param array<string> $duplicateCoordinates */
public static function forCoordinates(array $duplicateCoordinates): self
{
$coordinates = implode(', ', $duplicateCoordinates);

return new self("Duplicate sample coordinates found: {$coordinates}");
}
}
92 changes: 92 additions & 0 deletions src/LightcyclerExportSheet/LightcyclerDataParsingTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php declare(strict_types=1);

namespace MLL\Utils\LightcyclerExportSheet;

use Illuminate\Support\Collection;

trait LightcyclerDataParsingTrait
{
protected function parseFloatValue(?string $value): ?float
{
$cleanString = $this->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<string, string> $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<string, string> $properties */
protected function optionalProperty(array $properties, string $propertyName): ?string
{
return $this->cleanString($properties[$propertyName] ?? null);
}

/**
* @param Collection<array-key, LightcyclerSample> $samples
*
* @return Collection<array-key, LightcyclerSample>
*/
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;
}
}
35 changes: 35 additions & 0 deletions src/LightcyclerExportSheet/LightcyclerSample.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php declare(strict_types=1);

namespace MLL\Utils\LightcyclerExportSheet;

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

final class LightcyclerSample
{
public string $name;

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

public float $calculatedConcentration;

public float $crossingPoint;

public ?float $standardConcentration;

/** @param Coordinates<CoordinateSystem12x8> $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;
}
}
93 changes: 93 additions & 0 deletions src/LightcyclerExportSheet/LightcyclerXmlParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php declare(strict_types=1);

namespace MLL\Utils\LightcyclerExportSheet;

use Illuminate\Support\Collection;
use MLL\Utils\Microplate\Coordinates;
use MLL\Utils\Microplate\CoordinateSystem12x8;

use function Safe\simplexml_load_string;

final class LightcyclerXmlParser
{
use LightcyclerDataParsingTrait;

private const XML_PROPERTY_NAME = 'name';
private const XML_PROPERTY_POSITION = 'Position';
private const XML_PROPERTY_CALC_CONC = 'CalcConc';
private const XML_PROPERTY_STANDARD_CONC = 'StandardConc';
private const XML_PROPERTY_CROSSING_POINT = 'CrossingPoint';
public const FLOAT_ZERO = 0.0;

/** @return Collection<array-key, LightcyclerSample> */
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<array-key, LightcyclerSample> */
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, self::XML_PROPERTY_CALC_CONC),
$this->optionalProperty($sampleProperties, self::XML_PROPERTY_CROSSING_POINT),
);

$coordinates = Coordinates::fromString(
$this->requiredProperty($sampleProperties, self::XML_PROPERTY_POSITION),
new CoordinateSystem12x8(),
);

return new LightcyclerSample(
$this->requiredProperty($sampleProperties, self::XML_PROPERTY_NAME),
$coordinates,
$validatedConcentration,
$validatedCrossingPoint,
$this->parseFloatValue($this->optionalProperty(
$sampleProperties,
self::XML_PROPERTY_STANDARD_CONC,
)),
);
}

/** @return array<string, string> */
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;
}
}
11 changes: 11 additions & 0 deletions src/LightcyclerExportSheet/MissingRequiredPropertyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php declare(strict_types=1);

namespace MLL\Utils\LightcyclerExportSheet;

final class MissingRequiredPropertyException extends \InvalidArgumentException
{
public static function forProperty(string $propertyName): self
{
return new self("Required property '{$propertyName}' is missing or empty");
}
}
Loading