aegis_sim.utilities.vcf
VCF export/import for AEGIS genomes (composite architecture).
Each bit position is treated as a biallelic SNP (REF=A, ALT=T by convention). Each individual is one sample column with phased genotype CHROMATID0|CHROMATID1. Rows are ordered by logical (trait × age × bit-within-locus) position so that adjacent rows are biologically adjacent loci — useful for downstream tools that look at local linkage or sliding-window stats.
The VCF header records the locus_permutation and architecture metadata so the
file is self-contained: decode_vcf_to_genomes() can reconstruct the exact
multi-dimensional genome array without re-initializing AEGIS.
Only the composite architecture is supported here; modifying-architecture export would need a different structural mapping.
1"""VCF export/import for AEGIS genomes (composite architecture). 2 3Each bit position is treated as a biallelic SNP (REF=A, ALT=T by convention). 4Each individual is one sample column with phased genotype CHROMATID0|CHROMATID1. 5Rows are ordered by logical (trait × age × bit-within-locus) position so that 6adjacent rows are biologically adjacent loci — useful for downstream tools that 7look at local linkage or sliding-window stats. 8 9The VCF header records the `locus_permutation` and architecture metadata so the 10file is self-contained: `decode_vcf_to_genomes()` can reconstruct the exact 11multi-dimensional genome array without re-initializing AEGIS. 12 13Only the composite architecture is supported here; modifying-architecture export 14would need a different structural mapping. 15""" 16 17import pathlib 18from typing import Tuple, List, Dict 19 20import numpy as np 21 22REF_BASE = "A" 23ALT_BASE = "T" 24 25 26def encode_population_to_vcf( 27 population, 28 output_dir: pathlib.Path, 29 name: str = "dump", 30) -> pathlib.Path: 31 """Write a VCF with one row per genome bit and one sample column per individual. 32 33 Pulls architecture metadata (locus_permutation, trait layout) from 34 `aegis_sim.submodels.architect`, so the architect must be initialized. 35 36 Returns the path to the written VCF. 37 """ 38 from aegis_sim import submodels, parameterization 39 40 output_dir = pathlib.Path(output_dir) 41 output_dir.mkdir(parents=True, exist_ok=True) 42 43 architecture = submodels.architect.architecture 44 if not hasattr(architecture, "locus_permutation"): 45 raise RuntimeError("VCF export only supports the composite architecture") 46 47 genome_array = population.genomes.array 48 n_individuals, ploidy, n_loci, bits_per_locus = genome_array.shape 49 if ploidy != 2: 50 raise ValueError(f"VCF export expects diploid genomes (ploidy=2), got ploidy={ploidy}") 51 52 locus_permutation = np.asarray(architecture.locus_permutation, dtype=int) 53 traits = parameterization.traits 54 55 sample_names: List[str] = [] 56 birthdays = getattr(population, "birthdays", None) 57 ages = getattr(population, "ages", None) 58 for i in range(n_individuals): 59 b = int(birthdays[i]) if birthdays is not None else -1 60 a = int(ages[i]) if ages is not None else -1 61 sample_names.append(f"ind_{i}_b{b}_a{a}") 62 63 vcf_path = output_dir / f"{name}.vcf" 64 with open(vcf_path, "w") as fh: 65 fh.write("##fileformat=VCFv4.2\n") 66 fh.write("##source=AEGIS\n") 67 fh.write(f"##contig=<ID=aegis_genome,length={n_loci * bits_per_locus}>\n") 68 fh.write('##INFO=<ID=TRAIT,Number=1,Type=String,Description="Trait name">\n') 69 fh.write('##INFO=<ID=AGE,Number=1,Type=Integer,Description="Age (0-indexed) for age-specific traits, -1 otherwise">\n') 70 fh.write('##INFO=<ID=BIT,Number=1,Type=Integer,Description="Bit position within the BITS_PER_LOCUS locus (0-indexed)">\n') 71 fh.write('##INFO=<ID=PHYS_LOCUS,Number=1,Type=Integer,Description="Physical locus index in storage">\n') 72 fh.write('##FORMAT=<ID=GT,Number=1,Type=String,Description="Phased genotype: chromatid0|chromatid1">\n') 73 fh.write(f"##AEGIS_BITS_PER_LOCUS={bits_per_locus}\n") 74 fh.write(f"##AEGIS_N_LOCI={n_loci}\n") 75 fh.write(f"##AEGIS_PLOIDY={ploidy}\n") 76 fh.write(f"##AEGIS_N_INDIVIDUALS={n_individuals}\n") 77 fh.write(f"##AEGIS_LOCUS_PERMUTATION={','.join(str(x) for x in locus_permutation.tolist())}\n") 78 fh.write("#" + "\t".join(["CHROM", "POS", "ID", "REF", "ALT", "QUAL", "FILTER", "INFO", "FORMAT"] + sample_names) + "\n") 79 80 for logical_locus in range(n_loci): 81 physical_locus = int(locus_permutation[logical_locus]) 82 trait_name, age = _logical_to_trait_age(logical_locus, traits) 83 for bit_in_locus in range(bits_per_locus): 84 pos = logical_locus * bits_per_locus + bit_in_locus + 1 # 1-indexed 85 variant_id = f"{trait_name}_a{age}_b{bit_in_locus}" if age >= 0 else f"{trait_name}_b{bit_in_locus}" 86 info = f"TRAIT={trait_name};AGE={age};BIT={bit_in_locus};PHYS_LOCUS={physical_locus}" 87 88 # Pull bits for all individuals at this physical position 89 chr0 = genome_array[:, 0, physical_locus, bit_in_locus].astype(np.int8) 90 chr1 = genome_array[:, 1, physical_locus, bit_in_locus].astype(np.int8) 91 # Phased genotype strings 92 gts = [f"{int(chr0[i])}|{int(chr1[i])}" for i in range(n_individuals)] 93 94 row = ["aegis_genome", str(pos), variant_id, REF_BASE, ALT_BASE, ".", "PASS", info, "GT"] + gts 95 fh.write("\t".join(row) + "\n") 96 97 return vcf_path 98 99 100def _logical_to_trait_age(logical_locus: int, traits) -> Tuple[str, int]: 101 """Map a logical locus index to (trait_name, age). age=-1 for non-age-specific.""" 102 for trait in traits.values(): 103 if trait.length == 0: 104 continue 105 if trait.start <= logical_locus < trait.end: 106 age = logical_locus - trait.start if trait.agespecific is True else -1 107 return trait.name, age 108 raise IndexError(f"logical_locus {logical_locus} is outside any trait range") 109 110 111def decode_vcf_to_genomes(vcf_path: pathlib.Path) -> Tuple[np.ndarray, List[str]]: 112 """Parse an AEGIS-emitted VCF back to (genome_array, sample_names). 113 114 The VCF header contains the metadata needed to reconstruct the original 115 (n_individuals, ploidy, n_loci, bits_per_locus) array — no AEGIS init required. 116 """ 117 vcf_path = pathlib.Path(vcf_path) 118 119 meta: Dict[str, str] = {} 120 sample_names: List[str] = [] 121 data_rows: List[Tuple[int, int, int, List[Tuple[int, int]]]] = [] # phys_locus, bit, _, genotypes 122 123 with open(vcf_path) as fh: 124 for line in fh: 125 line = line.rstrip("\n") 126 if line.startswith("##"): 127 if "=" in line: 128 key, _, value = line[2:].partition("=") 129 meta[key] = value 130 continue 131 if line.startswith("#CHROM"): 132 fields = line.lstrip("#").split("\t") 133 sample_names = fields[9:] 134 continue 135 if not line: 136 continue 137 fields = line.split("\t") 138 info = dict(kv.split("=") for kv in fields[7].split(";")) 139 phys_locus = int(info["PHYS_LOCUS"]) 140 bit_in_locus = int(info["BIT"]) 141 gt_strings = fields[9:] 142 genotypes = [] 143 for gt in gt_strings: 144 a, b = gt.split("|") 145 genotypes.append((int(a), int(b))) 146 data_rows.append((phys_locus, bit_in_locus, 0, genotypes)) 147 148 n_individuals = int(meta["AEGIS_N_INDIVIDUALS"]) 149 ploidy = int(meta["AEGIS_PLOIDY"]) 150 n_loci = int(meta["AEGIS_N_LOCI"]) 151 bits_per_locus = int(meta["AEGIS_BITS_PER_LOCUS"]) 152 assert len(sample_names) == n_individuals 153 assert len(data_rows) == n_loci * bits_per_locus 154 155 genome_array = np.zeros((n_individuals, ploidy, n_loci, bits_per_locus), dtype=np.bool_) 156 for phys_locus, bit_in_locus, _, genotypes in data_rows: 157 for ind_idx, (a, b) in enumerate(genotypes): 158 genome_array[ind_idx, 0, phys_locus, bit_in_locus] = bool(a) 159 genome_array[ind_idx, 1, phys_locus, bit_in_locus] = bool(b) 160 161 return genome_array, sample_names
27def encode_population_to_vcf( 28 population, 29 output_dir: pathlib.Path, 30 name: str = "dump", 31) -> pathlib.Path: 32 """Write a VCF with one row per genome bit and one sample column per individual. 33 34 Pulls architecture metadata (locus_permutation, trait layout) from 35 `aegis_sim.submodels.architect`, so the architect must be initialized. 36 37 Returns the path to the written VCF. 38 """ 39 from aegis_sim import submodels, parameterization 40 41 output_dir = pathlib.Path(output_dir) 42 output_dir.mkdir(parents=True, exist_ok=True) 43 44 architecture = submodels.architect.architecture 45 if not hasattr(architecture, "locus_permutation"): 46 raise RuntimeError("VCF export only supports the composite architecture") 47 48 genome_array = population.genomes.array 49 n_individuals, ploidy, n_loci, bits_per_locus = genome_array.shape 50 if ploidy != 2: 51 raise ValueError(f"VCF export expects diploid genomes (ploidy=2), got ploidy={ploidy}") 52 53 locus_permutation = np.asarray(architecture.locus_permutation, dtype=int) 54 traits = parameterization.traits 55 56 sample_names: List[str] = [] 57 birthdays = getattr(population, "birthdays", None) 58 ages = getattr(population, "ages", None) 59 for i in range(n_individuals): 60 b = int(birthdays[i]) if birthdays is not None else -1 61 a = int(ages[i]) if ages is not None else -1 62 sample_names.append(f"ind_{i}_b{b}_a{a}") 63 64 vcf_path = output_dir / f"{name}.vcf" 65 with open(vcf_path, "w") as fh: 66 fh.write("##fileformat=VCFv4.2\n") 67 fh.write("##source=AEGIS\n") 68 fh.write(f"##contig=<ID=aegis_genome,length={n_loci * bits_per_locus}>\n") 69 fh.write('##INFO=<ID=TRAIT,Number=1,Type=String,Description="Trait name">\n') 70 fh.write('##INFO=<ID=AGE,Number=1,Type=Integer,Description="Age (0-indexed) for age-specific traits, -1 otherwise">\n') 71 fh.write('##INFO=<ID=BIT,Number=1,Type=Integer,Description="Bit position within the BITS_PER_LOCUS locus (0-indexed)">\n') 72 fh.write('##INFO=<ID=PHYS_LOCUS,Number=1,Type=Integer,Description="Physical locus index in storage">\n') 73 fh.write('##FORMAT=<ID=GT,Number=1,Type=String,Description="Phased genotype: chromatid0|chromatid1">\n') 74 fh.write(f"##AEGIS_BITS_PER_LOCUS={bits_per_locus}\n") 75 fh.write(f"##AEGIS_N_LOCI={n_loci}\n") 76 fh.write(f"##AEGIS_PLOIDY={ploidy}\n") 77 fh.write(f"##AEGIS_N_INDIVIDUALS={n_individuals}\n") 78 fh.write(f"##AEGIS_LOCUS_PERMUTATION={','.join(str(x) for x in locus_permutation.tolist())}\n") 79 fh.write("#" + "\t".join(["CHROM", "POS", "ID", "REF", "ALT", "QUAL", "FILTER", "INFO", "FORMAT"] + sample_names) + "\n") 80 81 for logical_locus in range(n_loci): 82 physical_locus = int(locus_permutation[logical_locus]) 83 trait_name, age = _logical_to_trait_age(logical_locus, traits) 84 for bit_in_locus in range(bits_per_locus): 85 pos = logical_locus * bits_per_locus + bit_in_locus + 1 # 1-indexed 86 variant_id = f"{trait_name}_a{age}_b{bit_in_locus}" if age >= 0 else f"{trait_name}_b{bit_in_locus}" 87 info = f"TRAIT={trait_name};AGE={age};BIT={bit_in_locus};PHYS_LOCUS={physical_locus}" 88 89 # Pull bits for all individuals at this physical position 90 chr0 = genome_array[:, 0, physical_locus, bit_in_locus].astype(np.int8) 91 chr1 = genome_array[:, 1, physical_locus, bit_in_locus].astype(np.int8) 92 # Phased genotype strings 93 gts = [f"{int(chr0[i])}|{int(chr1[i])}" for i in range(n_individuals)] 94 95 row = ["aegis_genome", str(pos), variant_id, REF_BASE, ALT_BASE, ".", "PASS", info, "GT"] + gts 96 fh.write("\t".join(row) + "\n") 97 98 return vcf_path
Write a VCF with one row per genome bit and one sample column per individual.
Pulls architecture metadata (locus_permutation, trait layout) from
aegis_sim.submodels.architect, so the architect must be initialized.
Returns the path to the written VCF.
112def decode_vcf_to_genomes(vcf_path: pathlib.Path) -> Tuple[np.ndarray, List[str]]: 113 """Parse an AEGIS-emitted VCF back to (genome_array, sample_names). 114 115 The VCF header contains the metadata needed to reconstruct the original 116 (n_individuals, ploidy, n_loci, bits_per_locus) array — no AEGIS init required. 117 """ 118 vcf_path = pathlib.Path(vcf_path) 119 120 meta: Dict[str, str] = {} 121 sample_names: List[str] = [] 122 data_rows: List[Tuple[int, int, int, List[Tuple[int, int]]]] = [] # phys_locus, bit, _, genotypes 123 124 with open(vcf_path) as fh: 125 for line in fh: 126 line = line.rstrip("\n") 127 if line.startswith("##"): 128 if "=" in line: 129 key, _, value = line[2:].partition("=") 130 meta[key] = value 131 continue 132 if line.startswith("#CHROM"): 133 fields = line.lstrip("#").split("\t") 134 sample_names = fields[9:] 135 continue 136 if not line: 137 continue 138 fields = line.split("\t") 139 info = dict(kv.split("=") for kv in fields[7].split(";")) 140 phys_locus = int(info["PHYS_LOCUS"]) 141 bit_in_locus = int(info["BIT"]) 142 gt_strings = fields[9:] 143 genotypes = [] 144 for gt in gt_strings: 145 a, b = gt.split("|") 146 genotypes.append((int(a), int(b))) 147 data_rows.append((phys_locus, bit_in_locus, 0, genotypes)) 148 149 n_individuals = int(meta["AEGIS_N_INDIVIDUALS"]) 150 ploidy = int(meta["AEGIS_PLOIDY"]) 151 n_loci = int(meta["AEGIS_N_LOCI"]) 152 bits_per_locus = int(meta["AEGIS_BITS_PER_LOCUS"]) 153 assert len(sample_names) == n_individuals 154 assert len(data_rows) == n_loci * bits_per_locus 155 156 genome_array = np.zeros((n_individuals, ploidy, n_loci, bits_per_locus), dtype=np.bool_) 157 for phys_locus, bit_in_locus, _, genotypes in data_rows: 158 for ind_idx, (a, b) in enumerate(genotypes): 159 genome_array[ind_idx, 0, phys_locus, bit_in_locus] = bool(a) 160 genome_array[ind_idx, 1, phys_locus, bit_in_locus] = bool(b) 161 162 return genome_array, sample_names
Parse an AEGIS-emitted VCF back to (genome_array, sample_names).
The VCF header contains the metadata needed to reconstruct the original (n_individuals, ploidy, n_loci, bits_per_locus) array — no AEGIS init required.