Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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
5 changes: 4 additions & 1 deletion .php-cs-fixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@
->ignoreDotFiles(true)
->ignoreVCS(true);

return risky($finder);
$config = risky($finder);
$config->setCacheFile(__DIR__ . '/.build/php-cs-fixer/cache');

return $config;
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ See [GitHub releases](https://github.com/mll-lab/php-utils/releases).

## Unreleased

## v5.17.0

### Added

- Support creating Lightcycler Sample Sheets for Absolute Quantification https://github.com/mll-lab/php-utils/pull/55
- Accept `iterable $data` in `CSVArray::toCSV` https://github.com/mll-lab/php-utils/pull/55

## v5.16.0

### Added
Expand Down
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ it: fix stan test ## Run the commonly used targets

.PHONY: help
help: ## Displays this list of targets with descriptions
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[32m%-30s\033[0m %s\n", $$1, $$2}'
@grep --extended-regexp '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[32m%-30s\033[0m %s\n", $$1, $$2}'

setup: vendor ## Set up the repository

Expand All @@ -23,7 +23,7 @@ rector: vendor
.PHONY: php-cs-fixer
php-cs-fixer:
mkdir --parents .build/php-cs-fixer
vendor/bin/php-cs-fixer fix --cache-file=.build/php-cs-fixer/cache
vendor/bin/php-cs-fixer fix

.PHONY: stan
stan: vendor ## Runs a static analysis with phpstan
Expand All @@ -37,5 +37,5 @@ test: vendor ## Runs auto-review, unit, and integration tests with phpunit

vendor: composer.json
composer validate --strict
composer install
composer update
composer normalize
2 changes: 1 addition & 1 deletion rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
Rector\PHPUnit\CodeQuality\Rector\Class_\PreferPHPUnitSelfCallRector::class,
])
->withSkip([
Rector\PHPUnit\CodeQuality\Rector\MethodCall\AssertCountWithZeroToAssertEmptyRector::class, // sloppy
Rector\PHPUnit\CodeQuality\Rector\Class_\RemoveDataProviderParamKeysRector::class, // breaks tests
Rector\PHPUnit\CodeQuality\Rector\Class_\PreferPHPUnitThisCallRector::class, // breaks tests
Rector\CodeQuality\Rector\Concat\JoinStringConcatRector::class => [
__DIR__ . '/tests/CSVArrayTest.php', // keep `\r\n` for readability
Expand Down
12 changes: 4 additions & 8 deletions src/CSVArray.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,15 @@
return $result;
}

/** @param array<int, array<string, CSVPrimitive>> $data */
public static function toCSV(array $data, string $delimiter = ';', string $lineSeparator = "\r\n"): string
/** @param iterable<array<string, CSVPrimitive>> $data */
public static function toCSV(iterable $data, string $delimiter = ';', string $lineSeparator = StringUtil::WINDOWS_NEWLINE): string
{
if ($data === []) {
throw new \Exception('Array is empty');
}

// Use the keys of the array as the headers of the CSV
$headerItem = Arr::first($data);
if (! is_array($headerItem)) {
throw new \Exception('Missing column headers.');
if ($headerItem === null) {
throw new \Exception('Missing data.');
}
$headerKeys = array_keys($headerItem);

Check failure on line 57 in src/CSVArray.php

View workflow job for this annotation

GitHub Actions / static-code-analysis (8.0, lowest)

Parameter #1 $array of function array_keys expects array, mixed given.

Check failure on line 57 in src/CSVArray.php

View workflow job for this annotation

GitHub Actions / static-code-analysis (8.3, lowest)

Parameter #1 $array of function array_keys expects array, mixed given.

Check failure on line 57 in src/CSVArray.php

View workflow job for this annotation

GitHub Actions / static-code-analysis (8.0, highest)

Parameter #1 $array of function array_keys expects array, mixed given.

Check failure on line 57 in src/CSVArray.php

View workflow job for this annotation

GitHub Actions / static-code-analysis (8.1, lowest)

Parameter #1 $array of function array_keys expects array, mixed given.

Check failure on line 57 in src/CSVArray.php

View workflow job for this annotation

GitHub Actions / static-code-analysis (8.1, highest)

Parameter #1 $array of function array_keys expects array, mixed given.

Check failure on line 57 in src/CSVArray.php

View workflow job for this annotation

GitHub Actions / static-code-analysis (8.4, lowest)

Parameter #1 $array of function array_keys expects array, mixed given.

Check failure on line 57 in src/CSVArray.php

View workflow job for this annotation

GitHub Actions / static-code-analysis (8.2, lowest)

Parameter #1 $array of function array_keys expects array, mixed given.

$content = str_putcsv($headerKeys, $delimiter) . $lineSeparator;

Expand Down
64 changes: 64 additions & 0 deletions src/LightcyclerSampleSheet/AbsoluteQuantificationSample.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php declare(strict_types=1);

namespace MLL\Utils\LightcyclerSampleSheet;

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

class AbsoluteQuantificationSample
{
public string $sampleName;

public string $filterCombination;

public string $hexColor;

public string $sampleType;

/** Key used to determine replication grouping - samples with the same key will replicate to the first occurrence */
public string $replicationOfKey;

public ?int $concentration;

public function __construct(
string $sampleName,
string $filterCombination,
string $hexColor,
string $sampleType,
string $replicationOfKey,
?int $concentration
) {
$this->sampleName = $sampleName;
$this->filterCombination = $filterCombination;
$this->hexColor = $hexColor;
$this->sampleType = $sampleType;
$this->replicationOfKey = $replicationOfKey;
$this->concentration = $concentration;
}

public static function formatConcentration(?int $concentration): ?string
{
if ($concentration === null) {
return null;
}

$exponent = (int) floor(log10(abs($concentration)));
$mantissa = $concentration / (10 ** $exponent);

return number_format($mantissa, 2) . 'E' . $exponent;
}

/** @return array<string, string|null> */
public function toSerializableArray(string $coordinatesString, string $replicationOfCoordinate): array
{
return [
'General:Pos' => Coordinates::fromString($coordinatesString, new CoordinateSystem12x8())->toString(),
'General:Sample Name' => $this->sampleName,
'General:Repl. Of' => $replicationOfCoordinate,
'General:Filt. Comb.' => $this->filterCombination,
'Sample Preferences:Color' => RandomHexGenerator::LIGHTCYCLER_COLOR_PREFIX . $this->hexColor,
'Abs Quant:Sample Type' => $this->sampleType,
'Abs Quant:Concentration' => self::formatConcentration($this->concentration),
];
}
}
49 changes: 49 additions & 0 deletions src/LightcyclerSampleSheet/AbsoluteQuantificationSheet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php declare(strict_types=1);

namespace MLL\Utils\LightcyclerSampleSheet;

use Illuminate\Support\Collection;
use MLL\Utils\CSVArray;

class AbsoluteQuantificationSheet
{
/** @param Collection<string, AbsoluteQuantificationSample> $samples */
public function generate(Collection $samples): string
{
$replicationMapping = $this->calculateReplicationMapping($samples);

$data = $samples->map(fn (AbsoluteQuantificationSample $well, string $coordinateFromKey): array => $well->toSerializableArray(
$coordinateFromKey,
$replicationMapping[$coordinateFromKey]
));

return CSVArray::toCSV($data, "\t");
}

/**
* Calculate replication mapping based on replicationOfKey.
*
* @param Collection<string, AbsoluteQuantificationSample> $samples
*
* @return array<string, string> a map of coordinate -> replicationOfCoordinate
*/
private function calculateReplicationMapping(Collection $samples): array
{
$replicationKeyMap = [];
$mapping = [];

foreach ($samples as $coordinate => $sample) {
if (! isset($replicationKeyMap[$sample->replicationOfKey])) {
// The First occurrence replicates to itself
$replicationKeyMap[$sample->replicationOfKey] = $coordinate;
$mapping[$coordinate] = $coordinate;
} else {
// Later occurrences replicate to the first occurrence
$firstOccurrenceCoordinate = $replicationKeyMap[$sample->replicationOfKey];
$mapping[$coordinate] = $firstOccurrenceCoordinate;
}
}

return $mapping;
}
}
2 changes: 2 additions & 0 deletions src/LightcyclerSampleSheet/RandomHexGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

class RandomHexGenerator
{
public const LIGHTCYCLER_COLOR_PREFIX = '$00';

/** @var list<string> */
private array $generatedHexCodes = [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function toSerializableArray(string $coordinatesString): array
"\"{$this->sampleName}\"",
$replicationOf,
$this->filterCombination,
"$00{$this->hexColor}",
RandomHexGenerator::LIGHTCYCLER_COLOR_PREFIX . $this->hexColor,
];
}
}
10 changes: 5 additions & 5 deletions src/LightcyclerSampleSheet/RelativeQuantificationSheet.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
namespace MLL\Utils\LightcyclerSampleSheet;

use Illuminate\Support\Collection;
use MLL\Utils\StringUtil;

/** TODO use CSVArray, @see AbsoluteQuantificationSheet */
class RelativeQuantificationSheet
{
protected const WINDOWS_NEW_LINE = "\r\n";
protected const TAB_SEPARATOR = "\t";
public const HEADER_COLUMNS = [
'"General:Pos"',
'"General:Sample Name"',
Expand All @@ -22,8 +22,8 @@ public function generate(Collection $samples): string
return $samples
->map(fn (RelativeQuantificationSample $well, string $coordinateFromKey): array => $well->toSerializableArray($coordinateFromKey))
->prepend(self::HEADER_COLUMNS)
->map(fn (array $row): string => implode(self::TAB_SEPARATOR, $row))
->implode(self::WINDOWS_NEW_LINE)
. self::WINDOWS_NEW_LINE;
->map(fn (array $row): string => implode("\t", $row))
->implode(StringUtil::WINDOWS_NEWLINE)
. StringUtil::WINDOWS_NEWLINE;
}
}
2 changes: 1 addition & 1 deletion src/QxManager/FilledWell.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function __construct(FilledRow $famRow, FilledRow $hexRow)
/** @param Coordinates<CoordinateSystem12x8> $coordinates */
public function toString(Coordinates $coordinates): string
{
return $coordinates->toPaddedString() . QxManagerSampleSheet::DELIMITER . $this->famRow->toString() . QxManagerSampleSheet::EOL
return $coordinates->toPaddedString() . QxManagerSampleSheet::DELIMITER . $this->famRow->toString() . QxManagerSampleSheet::NEWLINE
. $coordinates->toPaddedString() . QxManagerSampleSheet::DELIMITER . $this->hexRow->toString();
}
}
8 changes: 5 additions & 3 deletions src/QxManager/QxManagerSampleSheet.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@

class QxManagerSampleSheet
{
public const EOL = "\r\n";
/** QX Manager runs on Windows. */
public const NEWLINE = StringUtil::WINDOWS_NEWLINE;

public const DELIMITER = ',';

/** @param Microplate<FilledWell, CoordinateSystem12x8> $microplate */
Expand All @@ -38,8 +40,8 @@ public function toCsvString(Microplate $microplate, CarbonInterface $createdDate
. QxManagerSampleSheet::DELIMITER
. (new EmptyRow())->toString();
})
->join(self::EOL);
->join(self::NEWLINE);

return $header . $body . self::EOL;
return $header . $body . self::NEWLINE;
}
}
4 changes: 3 additions & 1 deletion src/StringUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

class StringUtil
{
public const WINDOWS_NEWLINE = "\r\n";

/** https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8 */
public const UTF_8_BOM = "\xEF\xBB\xBF";

Expand Down Expand Up @@ -92,7 +94,7 @@ public static function splitLines(string $string): array
return \Safe\preg_split("/\r\n|\n|\r/", $string); // @phpstan-ignore return.type (preg_split from safe not known)
}

public static function normalizeLineEndings(string $input, string $to = "\r\n"): string
public static function normalizeLineEndings(string $input, string $to = self::WINDOWS_NEWLINE): string
{
return \Safe\preg_replace("/\r\n|\r|\n/", $to, $input);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Tecan/CustomCommands/TransferWithAutoWash.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public function __construct(float $volume, LiquidClass $liquidClass, Location $a

public function toString(): string
{
return implode(TecanProtocol::WINDOWS_NEW_LINE, [
return implode(TecanProtocol::NEWLINE, [
$this->aspirate->toString(),
$this->dispense->toString(),
(new Wash())->toString(),
Expand Down
7 changes: 4 additions & 3 deletions src/Tecan/TecanProtocol.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use MLL\Utils\Meta;
use MLL\Utils\StringUtil;
use MLL\Utils\Tecan\BasicCommands\BreakCommand;
use MLL\Utils\Tecan\BasicCommands\Command;
use MLL\Utils\Tecan\BasicCommands\Comment;
Expand All @@ -17,7 +18,7 @@
class TecanProtocol
{
/** Tecan software runs on Windows. */
public const WINDOWS_NEW_LINE = "\r\n";
public const NEWLINE = StringUtil::WINDOWS_NEWLINE;

public const GEMINI_WORKLIST_FILENAME_SUFFIX = '.gwl';

Expand Down Expand Up @@ -101,8 +102,8 @@ public function buildProtocol(): string
{
return $this->commands
->map(fn (Command $command): string => $command->toString())
->join(self::WINDOWS_NEW_LINE)
. self::WINDOWS_NEW_LINE;
->join(self::NEWLINE)
. self::NEWLINE;
}

public function fileName(): string
Expand Down
34 changes: 34 additions & 0 deletions tests/LightcyclerSampleSheet/AbsoluteQuantificationSampleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php declare(strict_types=1);

namespace MLL\Utils\Tests\LightcyclerSampleSheet;

use MLL\Utils\LightcyclerSampleSheet\AbsoluteQuantificationSample;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class AbsoluteQuantificationSampleTest extends TestCase
{
/** @dataProvider concentrationFormattingProvider */
#[DataProvider('concentrationFormattingProvider')]
public function testFormatConcentration(?int $input, ?string $expected): void
{
$result = AbsoluteQuantificationSample::formatConcentration($input);

self::assertSame($expected, $result);
}

/** @return iterable<array{?int, ?string}> */
public static function concentrationFormattingProvider(): iterable
{
yield 'null concentration returns null' => [null, null];
yield 'zero concentration' => [0, '0.00E0'];
yield 'small positive number' => [1, '1.00E0'];
yield 'ten' => [10, '1.00E1'];
yield 'hundred' => [100, '1.00E2'];
yield 'four hundred (common lab value)' => [400, '4.00E2'];
yield 'thousand' => [1000, '1.00E3'];
yield 'ten thousand' => [10000, '1.00E4'];
yield 'million' => [1000000, '1.00E6'];
yield 'large number' => [12345678, '1.23E7'];
}
}
Loading
Loading