Skip to content

Commit 7bc575b

Browse files
Merge pull request #19 from jeromekelleher/document-bgen-lims
Document bgen lims
2 parents d114cb5 + f0a0ba9 commit 7bc575b

8 files changed

Lines changed: 344 additions & 20 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,27 @@ bgenix -g /tmp/bgen-mnt/sample.bgen -list
9898
fusermount3 -u /tmp/bgen-mnt
9999
```
100100

101+
#### Limitations: ploidy
102+
103+
- **Mixed ploidy is not supported by `mount-bgen`.** The fixed-size BGEN
104+
encoder used for random-access serving requires uniform ploidy across
105+
every sample and variant in the view. Mounts whose region includes
106+
mixed-ploidy chromosomes (typically X, Y, MT) open successfully and
107+
serve `.sample` and `.bgen.bgi`, but the first `.bgen` read will fail
108+
with `EIO`. Workaround: restrict the view to autosomes at mount time
109+
(e.g. via the inherited `-r` / `-R` / `-t` / `-T` region filters), or
110+
use the one-shot `vcztools view-bgen` CLI for full-file conversions
111+
that include X / Y / MT — `view-bgen` uses the streaming
112+
variable-size encoder which handles mixed ploidy correctly.
113+
- **Pure haploid VCZ is supported by `mount-bgen`** (the encoder emits a
114+
uniform-haploid BGEN payload).
115+
- **`mount-plink` is diploid-only.** Pure haploid VCZ inputs (e.g.
116+
mitochondrial-only stores) are rejected by the underlying encoder
117+
with `EIO` on the first `.bed` read. Mixed-ploidy VCZ inputs serve
118+
successfully, but haploid samples are encoded as homozygous for the
119+
called allele — this matches the PLINK 1 BED format, which has no
120+
haploid representation.
121+
101122
## Development
102123

103124
```bash

fs_tests/harness/fixtures.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
@dataclasses.dataclass(frozen=True)
2222
class FixtureSpec:
2323
name: str
24-
num_diploid_samples: int
24+
num_samples: int
2525
sequence_length: int
2626
mutation_rate: float
2727
variants_chunk_size: int
@@ -37,7 +37,7 @@ class FixtureSpec:
3737

3838
SMALL = FixtureSpec(
3939
name="small",
40-
num_diploid_samples=10,
40+
num_samples=10,
4141
sequence_length=10_000,
4242
mutation_rate=1e-3,
4343
variants_chunk_size=7,
@@ -47,7 +47,7 @@ class FixtureSpec:
4747

4848
MEDIUM = FixtureSpec(
4949
name="medium",
50-
num_diploid_samples=200,
50+
num_samples=200,
5151
sequence_length=10_000_000,
5252
mutation_rate=1e-3,
5353
variants_chunk_size=1000,
@@ -57,7 +57,7 @@ class FixtureSpec:
5757

5858
LARGE = FixtureSpec(
5959
name="large",
60-
num_diploid_samples=500,
60+
num_samples=500,
6161
sequence_length=20_000_000,
6262
mutation_rate=2e-3,
6363
variants_chunk_size=2000,
@@ -81,7 +81,7 @@ def get_or_build(
8181
"building fixture %s under %s (samples=%d seq_len=%d mut_rate=%g)",
8282
spec.name,
8383
spec_dir,
84-
spec.num_diploid_samples,
84+
spec.num_samples,
8585
spec.sequence_length,
8686
spec.mutation_rate,
8787
)
@@ -90,7 +90,7 @@ def get_or_build(
9090
build_started = time.monotonic()
9191
fixture = helpers.simulate_vcz(
9292
out_dir=spec_dir,
93-
num_diploid_samples=spec.num_diploid_samples,
93+
num_samples=spec.num_samples,
9494
sequence_length=spec.sequence_length,
9595
mutation_rate=spec.mutation_rate,
9696
variants_chunk_size=spec.variants_chunk_size,

tests/conftest.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def fx_small_vcz(fx_session_dir) -> helpers.VczFixture:
2626
"""Tiny VCZ: ~10 diploid samples, multiple variant chunks for boundary testing."""
2727
return helpers.simulate_vcz(
2828
out_dir=fx_session_dir / "small",
29-
num_diploid_samples=10,
29+
num_samples=10,
3030
sequence_length=10_000,
3131
mutation_rate=1e-3,
3232
variants_chunk_size=7,
@@ -41,7 +41,7 @@ def fx_medium_vcz(fx_session_dir) -> helpers.VczFixture:
4141
"""Mid-sized VCZ: ~50 diploid samples × hundreds of variants for app tests."""
4242
return helpers.simulate_vcz(
4343
out_dir=fx_session_dir / "medium",
44-
num_diploid_samples=50,
44+
num_samples=50,
4545
sequence_length=200_000,
4646
mutation_rate=1e-4,
4747
variants_chunk_size=50,
@@ -56,7 +56,7 @@ def fx_singleton_vcz(fx_session_dir) -> helpers.VczFixture:
5656
"""Single-variant VCZ. Tiny edge case: chunk count 1, file size minimal."""
5757
return helpers.simulate_vcz(
5858
out_dir=fx_session_dir / "singleton",
59-
num_diploid_samples=4,
59+
num_samples=4,
6060
sequence_length=1000,
6161
mutation_rate=2e-3,
6262
variants_chunk_size=10,
@@ -76,7 +76,7 @@ def fx_multiallelic_vcz(fx_session_dir) -> helpers.VczFixture:
7676
"""
7777
return helpers.simulate_vcz(
7878
out_dir=fx_session_dir / "multiallelic",
79-
num_diploid_samples=10,
79+
num_samples=10,
8080
sequence_length=10_000,
8181
mutation_rate=1e-2,
8282
variants_chunk_size=10,
@@ -85,3 +85,37 @@ def fx_multiallelic_vcz(fx_session_dir) -> helpers.VczFixture:
8585
seed=53,
8686
biallelic=False,
8787
)
88+
89+
90+
@pytest.fixture(scope="session")
91+
def fx_haploid_vcz(fx_session_dir) -> helpers.VczFixture:
92+
"""Tiny haploid VCZ: ``call_genotype`` shape ``(V, S, 1)``."""
93+
return helpers.simulate_vcz(
94+
out_dir=fx_session_dir / "haploid",
95+
num_samples=10,
96+
sequence_length=10_000,
97+
mutation_rate=1e-3,
98+
variants_chunk_size=7,
99+
samples_chunk_size=10,
100+
name="haploid",
101+
seed=67,
102+
ploidy=1,
103+
)
104+
105+
106+
@pytest.fixture(scope="session")
107+
def fx_mixed_ploidy_vcz(fx_small_vcz, fx_session_dir) -> helpers.VczFixture:
108+
"""Diploid-shaped VCZ with half the samples flipped to effectively haploid.
109+
110+
Derived from :func:`fx_small_vcz` by writing ``-2`` into
111+
``call_genotype[:, :S//2, 1]``. The resulting ``call_genotype``
112+
still has shape ``(V, S, 2)`` but the BgenEncoder's chunk-level
113+
uniform-ploidy check raises ``NotImplementedError`` on the first
114+
read, mirroring real X / Y / MT data alongside diploid autosomes.
115+
"""
116+
return helpers.mutate_to_mixed_ploidy(
117+
fx_small_vcz.path,
118+
fx_session_dir / "mixed_ploidy",
119+
name="mixed_ploidy",
120+
haploid_sample_fraction=0.5,
121+
)

tests/helpers.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Test helpers shared across the biofuse test suite."""
22

33
import pathlib
4+
import shutil
45
from dataclasses import dataclass
56

67
import bio2zarr.tskit as bio2zarr_tskit
@@ -61,25 +62,29 @@ def _keep_first_mutation_per_site(ts):
6162
def simulate_vcz(
6263
out_dir: pathlib.Path,
6364
*,
64-
num_diploid_samples: int,
65+
num_samples: int,
6566
sequence_length: float,
6667
mutation_rate: float,
6768
variants_chunk_size: int,
6869
samples_chunk_size: int,
6970
name: str = "sim",
7071
seed: int = 1,
7172
biallelic: bool = True,
73+
ploidy: int = 2,
7274
) -> VczFixture:
7375
"""Simulate a tree sequence and convert it to VCZ on disk.
7476
7577
Returns a VczFixture pointing at a directory-format VCZ. The caller
7678
owns cleanup of out_dir. By default the fixture is biallelic: any
7779
site with multiple mutations keeps only its first mutation. Pass
7880
``biallelic=False`` to keep recurrent mutations and exercise the
79-
multi-allelic rejection path.
81+
multi-allelic rejection path. ``ploidy=1`` yields a VCZ with
82+
``call_genotype`` of shape ``(V, num_samples, 1)``; the default
83+
``ploidy=2`` yields ``(V, num_samples, 2)``.
8084
"""
8185
ts = msprime.sim_ancestry(
82-
samples=num_diploid_samples,
86+
samples=num_samples,
87+
ploidy=ploidy,
8388
sequence_length=sequence_length,
8489
recombination_rate=1e-8,
8590
random_seed=seed,
@@ -102,7 +107,6 @@ def simulate_vcz(
102107
)
103108

104109
num_variants = ts.num_sites
105-
num_samples = ts.num_samples // 2
106110
return VczFixture(
107111
path=vcz_path,
108112
num_samples=num_samples,
@@ -111,3 +115,41 @@ def simulate_vcz(
111115
variants_chunk_size=variants_chunk_size,
112116
samples_chunk_size=samples_chunk_size,
113117
)
118+
119+
120+
def mutate_to_mixed_ploidy(
121+
src_vcz: pathlib.Path,
122+
dst_dir: pathlib.Path,
123+
*,
124+
name: str = "mixed",
125+
haploid_sample_fraction: float = 0.5,
126+
) -> VczFixture:
127+
"""Copy a diploid VCZ and convert half its samples to effectively haploid.
128+
129+
Writes the haploid sentinel ``-2`` into slot 1 of ``call_genotype`` for
130+
the first ``int(S * haploid_sample_fraction)`` samples across all
131+
variants. This is the canonical VCZ representation for samples with
132+
lower ploidy within a fixed-width genotype array — it is what an X /
133+
Y / MT chromosome looks like alongside diploid autosomes in the same
134+
store.
135+
"""
136+
dst_dir.mkdir(parents=True, exist_ok=True)
137+
dst_vcz = dst_dir / f"{name}.vcz"
138+
shutil.copytree(src_vcz, dst_vcz)
139+
140+
store = zarr.open(dst_vcz, mode="r+")
141+
call_genotype = store["call_genotype"]
142+
num_variants, num_samples, _ = call_genotype.shape
143+
num_haploid = int(num_samples * haploid_sample_fraction)
144+
arr = call_genotype[:]
145+
arr[:, :num_haploid, 1] = -2
146+
call_genotype[:] = arr
147+
148+
return VczFixture(
149+
path=dst_vcz,
150+
num_samples=num_samples,
151+
num_variants=num_variants,
152+
num_biallelic_sites=_count_biallelic_sites(dst_vcz),
153+
variants_chunk_size=call_genotype.chunks[0],
154+
samples_chunk_size=call_genotype.chunks[1],
155+
)

tests/test_bgen_apps.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
encoder's bytes.
88
"""
99

10+
import contextlib
11+
import errno
1012
import os
1113
import pathlib
1214
import shutil
@@ -81,6 +83,22 @@ async def _arun(cmd) -> None:
8183
await trio.run_process(cmd, capture_stdout=True, capture_stderr=True, check=True)
8284

8385

86+
@contextlib.asynccontextmanager
87+
async def _mount_bgen(tmp_path, vcz):
88+
"""Mount ``vcz`` as a BGEN fileset; yield ``(mnt, basename)``."""
89+
mnt = tmp_path / "mnt"
90+
mnt.mkdir()
91+
basename = vcz.path.stem
92+
sock_path = tmp_path / "bgen.sock"
93+
async with await encoder_client.EncoderClient.start(
94+
str(vcz.path), sock_path, formats.BGEN_SPEC
95+
) as client:
96+
ops = encoder_ops.EncoderOps(client, basename, formats.BGEN_SPEC)
97+
async with fuse_adapter.mount(ops, str(mnt)):
98+
await _wait_for_mount(mnt)
99+
yield mnt, basename
100+
101+
84102
class TestBgenBytes:
85103
async def test_full_bgen_matches_encoder(self, fx_mounted_bgen):
86104
mnt, basename, expected, _ = fx_mounted_bgen
@@ -207,6 +225,56 @@ async def test_statfs_via_os_statvfs(self, fx_mounted_bgen):
207225
assert info.f_namemax >= 255
208226

209227

228+
class TestHaploidAndMixedPloidy:
229+
"""Pins the mount-level behaviour for non-diploid VCZ inputs.
230+
231+
See ``tests/test_formats.py::TestBgenSpecHaploid`` and
232+
``TestBgenSpecMixedPloidy`` for the encoder-layer contracts these
233+
end-to-end tests exercise.
234+
"""
235+
236+
async def test_haploid_bgen_matches_encoder(self, tmp_path, fx_haploid_vcz):
237+
"""Pure haploid is fully supported by BgenEncoder; the mounted
238+
``.bgen`` bytes must match the in-process encoder's output."""
239+
expected = _encoder_bytes(fx_haploid_vcz.path)
240+
async with _mount_bgen(tmp_path, fx_haploid_vcz) as (mnt, basename):
241+
data = await trio.to_thread.run_sync((mnt / f"{basename}.bgen").read_bytes)
242+
assert data == expected
243+
244+
@needs_plink2
245+
async def test_haploid_bgen_plink2_freq(self, tmp_path, fx_haploid_vcz):
246+
"""``plink2 --bgen`` reads the mounted haploid view end-to-end."""
247+
async with _mount_bgen(tmp_path, fx_haploid_vcz) as (mnt, basename):
248+
out = tmp_path / "p2_freq_hap"
249+
await _arun(
250+
[
251+
PLINK2,
252+
"--bgen",
253+
str(mnt / f"{basename}.bgen"),
254+
"ref-first",
255+
"--sample",
256+
str(mnt / f"{basename}.sample"),
257+
"--freq",
258+
"--out",
259+
str(out),
260+
]
261+
)
262+
assert out.with_suffix(".afreq").exists()
263+
264+
async def test_mixed_ploidy_bgen_read_raises_eio(
265+
self, tmp_path, fx_mixed_ploidy_vcz
266+
):
267+
"""The fixed-size BGEN encoder cannot represent mixed ploidy.
268+
The mount and the static sidecars (``.sample``, ``.bgen.bgi``)
269+
still serve, but the first ``.bgen`` read fails with EIO."""
270+
async with _mount_bgen(tmp_path, fx_mixed_ploidy_vcz) as (mnt, basename):
271+
await trio.to_thread.run_sync((mnt / f"{basename}.sample").read_bytes)
272+
await trio.to_thread.run_sync((mnt / f"{basename}.bgen.bgi").read_bytes)
273+
with pytest.raises(OSError, match="Input/output error") as excinfo:
274+
await trio.to_thread.run_sync((mnt / f"{basename}.bgen").read_bytes)
275+
assert excinfo.value.errno == errno.EIO
276+
277+
210278
class TestAccessTrace:
211279
"""Captures access patterns for inspection in phase-2 design."""
212280

0 commit comments

Comments
 (0)