Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
10 changes: 3 additions & 7 deletions src/GenomicPosition.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,10 @@

public int $position;

public function __construct(Chromosome $chromosome, int $position)
public function __construct(Chromosome $chromosome, NucleotidePosition $position)
{
if ($position < 1) {
throw new \InvalidArgumentException("Position must be positive, got: {$position}.");
}

$this->chromosome = $chromosome;
$this->position = $position;
$this->position = $position->value;
}

/** @example GenomicPosition::parse('chr1:123456') */
Expand All @@ -27,7 +23,7 @@
throw new \InvalidArgumentException("Invalid genomic position format: {$value}. Expected format: chr1:123456.");
}

return new self(new Chromosome($matches[1]), (int) $matches[3]);
return new self(new Chromosome($matches[1]), new NucleotidePosition($matches[3]));

Check failure on line 26 in src/GenomicPosition.php

View workflow job for this annotation

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

Offset 3 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string}.

Check failure on line 26 in src/GenomicPosition.php

View workflow job for this annotation

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

Offset 1 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string}.

Check failure on line 26 in src/GenomicPosition.php

View workflow job for this annotation

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

Offset 3 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string}.

Check failure on line 26 in src/GenomicPosition.php

View workflow job for this annotation

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

Offset 1 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string}.

Check failure on line 26 in src/GenomicPosition.php

View workflow job for this annotation

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

Offset 3 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string}.

Check failure on line 26 in src/GenomicPosition.php

View workflow job for this annotation

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

Offset 1 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string}.
}

public function equals(self $other): bool
Expand Down
44 changes: 26 additions & 18 deletions src/GenomicRegion.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,16 @@

public function __construct(
Chromosome $chromosome,
int $start,
int $end
NucleotidePosition $start,
NucleotidePosition $end
) {
if ($start < 1) {
throw new \InvalidArgumentException("Start must be positive, got: {$start}.");
}

if ($end < 1) {
throw new \InvalidArgumentException("End must be positive, got: {$end}.");
}

if ($start > $end) {
throw new \InvalidArgumentException("End ({$end}) must not be less than start ({$start}).");
if ($start->value > $end->value) {
throw new \InvalidArgumentException("End ({$end->value}) must not be less than start ({$start->value}).");
}

$this->chromosome = $chromosome;
$this->start = $start;
$this->end = $end;
$this->start = $start->value;
$this->end = $end->value;
}

public static function parse(string $value): self
Expand All @@ -41,9 +33,9 @@
}

return new self(
new Chromosome($matches[1]),

Check failure on line 36 in src/GenomicRegion.php

View workflow job for this annotation

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

Offset 1 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string, 4?: string, 5?: numeric-string}.

Check failure on line 36 in src/GenomicRegion.php

View workflow job for this annotation

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

Offset 1 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string, 4?: string, 5?: numeric-string}.

Check failure on line 36 in src/GenomicRegion.php

View workflow job for this annotation

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

Offset 1 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string, 4?: string, 5?: numeric-string}.
(int) $matches[3],
(int) ($matches[5] ?? $matches[3])
new NucleotidePosition($matches[3]),

Check failure on line 37 in src/GenomicRegion.php

View workflow job for this annotation

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

Offset 3 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string, 4?: string, 5?: numeric-string}.

Check failure on line 37 in src/GenomicRegion.php

View workflow job for this annotation

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

Offset 3 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string, 4?: string, 5?: numeric-string}.

Check failure on line 37 in src/GenomicRegion.php

View workflow job for this annotation

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

Offset 3 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string, 4?: string, 5?: numeric-string}.
new NucleotidePosition($matches[5] ?? $matches[3])

Check failure on line 38 in src/GenomicRegion.php

View workflow job for this annotation

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

Offset 3 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string, 4?: string, 5?: numeric-string}.

Check failure on line 38 in src/GenomicRegion.php

View workflow job for this annotation

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

Offset 3 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string, 4?: string, 5?: numeric-string}.

Check failure on line 38 in src/GenomicRegion.php

View workflow job for this annotation

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

Offset 3 might not exist on list{0?: string, 1?: non-empty-string, 2?: ''|'g.', 3?: numeric-string, 4?: string, 5?: numeric-string}.
);
}

Expand Down Expand Up @@ -98,11 +90,27 @@

return new self(
$this->chromosome,
max($this->start, $other->start),
min($this->end, $other->end)
new NucleotidePosition(max($this->start, $other->start)),
new NucleotidePosition(min($this->end, $other->end))
);
}

/** Constructs a 1-based closed region from 0-based half-open coordinates (BED, BAM, bigWig). */
public static function fromZeroBasedHalfOpen(string $chromosome, int $start, int $end): self
{
return new self(
new Chromosome($chromosome),
new NucleotidePosition($start + 1),
new NucleotidePosition($end)
);
}

/** @return array{Chromosome, int, int} Chromosome, 0-based start, half-open end. */
public function toZeroBasedHalfOpen(): array
{
return [$this->chromosome, $this->start - 1, $this->end];
}

private function containsCoordinate(int $position): bool
{
return $position >= $this->start && $position <= $this->end;
Expand Down
18 changes: 18 additions & 0 deletions src/NucleotidePosition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php declare(strict_types=1);

namespace MLL\Utils;

class NucleotidePosition
{
public int $value;

/** @param int|string $positionAsMixed */
public function __construct($positionAsMixed)
{
$position = SafeCast::toInt($positionAsMixed);
if ($position < 1) {
throw new \InvalidArgumentException("Position must be positive, got: {$position}.");
}
$this->value = $position;
}
}
14 changes: 11 additions & 3 deletions tests/GenomicPositionTest.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?php declare(strict_types=1);

use MLL\Utils\Chromosome;
use MLL\Utils\GenomicPosition;
use MLL\Utils\NamingConvention;
use MLL\Utils\NucleotidePosition;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

Expand Down Expand Up @@ -46,18 +46,26 @@ public function testEquals(): void
);
}

public function testConstructorRejectsNonPositivePosition(): void
public function testConstructorRejectsZeroPosition(): void
{
self::expectException(\InvalidArgumentException::class);
self::expectExceptionMessage('Position must be positive, got: 0.');
new GenomicPosition(new Chromosome('chr1'), 0);
new NucleotidePosition(0);
}

public function testConstructorRejectsNegativePosition(): void
{
self::expectException(\InvalidArgumentException::class);
self::expectExceptionMessage('Position must be positive, got: -1.');
new NucleotidePosition(-1);
}

/** @return iterable<array{string}> */
public static function invalidFormats(): iterable
{
yield ['11:1test'];
yield ['chr1:0'];
yield ['chr1:-1'];
yield ['chr1:'];
yield ['chr1'];
}
Expand Down
43 changes: 43 additions & 0 deletions tests/GenomicRegionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,49 @@ public function testIsCoveredByDifferentChromosomes(): void
self::assertFalse($region->isCoveredBy(GenomicRegion::parse('chr12:1-100')));
}

public function testFromZeroBasedHalfOpenConvertsToOneBased(): void
{
// BED: chr7 55249070 55249171 (EGFR Exon 19, 0-based half-open)
$region = GenomicRegion::fromZeroBasedHalfOpen('chr7', 55249070, 55249171);

self::assertSame(55249071, $region->start);
self::assertSame(55249171, $region->end);
self::assertSame(101, $region->length());
}

public function testFromZeroBasedHalfOpenSingleBase(): void
{
// BED single base: chr1 99 100 → 1-based chr1:100-100
$region = GenomicRegion::fromZeroBasedHalfOpen('chr1', 99, 100);

self::assertSame(100, $region->start);
self::assertSame(100, $region->end);
self::assertSame(1, $region->length());
}

public function testToZeroBasedHalfOpenRoundTrips(): void
{
$region = GenomicRegion::fromZeroBasedHalfOpen('chr7', 55249070, 55249171);

[$chromosome, $start, $end] = $region->toZeroBasedHalfOpen();

self::assertSame('7', $chromosome->value());
self::assertSame(55249070, $start);
self::assertSame(55249171, $end);
}

public function testFromZeroBasedHalfOpenLengthMatchesBedFormula(): void
{
$bedStart = 1000;
$bedEnd = 2000;

$region = GenomicRegion::fromZeroBasedHalfOpen('chr1', $bedStart, $bedEnd);

// BED length = end - start; 1-based length = end - start + 1
// Both must agree on the actual number of bases
self::assertSame($bedEnd - $bedStart, $region->length());
}

public function testIntersectionIsCommutative(): void
{
$a = GenomicRegion::parse('chr11:10-20');
Expand Down
Loading