statgen 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {statgen-0.2.0 → statgen-0.2.2}/PKG-INFO +1 -1
- {statgen-0.2.0 → statgen-0.2.2}/pyproject.toml +1 -1
- statgen-0.2.2/statgen/__init__.py +1 -0
- {statgen-0.2.0 → statgen-0.2.2}/statgen/_ld_npz.py +40 -19
- statgen-0.2.2/statgen/_ld_reference.py +57 -0
- {statgen-0.2.0 → statgen-0.2.2}/statgen/_ld_schema.py +38 -2
- {statgen-0.2.0 → statgen-0.2.2}/statgen/_ld_writer.py +76 -1
- statgen-0.2.2/statgen/_utils.py +77 -0
- {statgen-0.2.0 → statgen-0.2.2}/statgen/annotations.py +7 -27
- {statgen-0.2.0 → statgen-0.2.2}/statgen/ld.py +65 -27
- {statgen-0.2.0 → statgen-0.2.2}/statgen/reference.py +103 -30
- {statgen-0.2.0 → statgen-0.2.2}/statgen/sumstats.py +105 -41
- {statgen-0.2.0 → statgen-0.2.2}/statgen.egg-info/PKG-INFO +1 -1
- {statgen-0.2.0 → statgen-0.2.2}/statgen.egg-info/SOURCES.txt +1 -0
- statgen-0.2.0/statgen/__init__.py +0 -1
- statgen-0.2.0/statgen/_utils.py +0 -30
- {statgen-0.2.0 → statgen-0.2.2}/README.md +0 -0
- {statgen-0.2.0 → statgen-0.2.2}/setup.cfg +0 -0
- {statgen-0.2.0 → statgen-0.2.2}/statgen/genotype.py +0 -0
- {statgen-0.2.0 → statgen-0.2.2}/statgen.egg-info/dependency_links.txt +0 -0
- {statgen-0.2.0 → statgen-0.2.2}/statgen.egg-info/requires.txt +0 -0
- {statgen-0.2.0 → statgen-0.2.2}/statgen.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.2"
|
|
@@ -19,30 +19,51 @@ from ._ld_schema import (
|
|
|
19
19
|
|
|
20
20
|
def validate_npz_distribution(path, check_payload_structure=False) -> dict:
|
|
21
21
|
path = Path(path)
|
|
22
|
+
if not path.is_dir():
|
|
23
|
+
raise ValueError("validate_npz_distribution: path must identify a panel root directory")
|
|
24
|
+
|
|
25
|
+
manifest = read_manifest(path / "ld_manifest.json", expected_runtime=PY_RUNTIME_FORMAT)
|
|
26
|
+
runtime = manifest["runtime_format"]
|
|
27
|
+
seen_reference_bim = set()
|
|
28
|
+
for entry in manifest["shards"]:
|
|
29
|
+
shard_path = path / entry["file"]
|
|
30
|
+
require_file(shard_path)
|
|
31
|
+
file_md5 = md5_file(shard_path)
|
|
32
|
+
if file_md5 != entry.get("file_md5"):
|
|
33
|
+
raise ValueError(f"{shard_path}: file_md5 does not match manifest")
|
|
34
|
+
_ld_r, _a1freq, meta = read_npz_shard(
|
|
35
|
+
shard_path,
|
|
36
|
+
check_payload_structure=check_payload_structure,
|
|
37
|
+
)
|
|
38
|
+
validate_manifest_entry_agreement(entry, meta, shard_path)
|
|
39
|
+
reference_key = (
|
|
40
|
+
entry["reference_bim"],
|
|
41
|
+
entry["chr"],
|
|
42
|
+
int(entry["num_snp"]),
|
|
43
|
+
entry["reference_checksum"],
|
|
44
|
+
)
|
|
45
|
+
if reference_key not in seen_reference_bim:
|
|
46
|
+
_validate_bundled_reference(root=path, entry=entry)
|
|
47
|
+
seen_reference_bim.add(reference_key)
|
|
48
|
+
|
|
49
|
+
return {"ok": True}
|
|
22
50
|
|
|
23
|
-
if path.is_dir():
|
|
24
|
-
manifest = read_manifest(path / "ld_manifest.json", expected_runtime=PY_RUNTIME_FORMAT)
|
|
25
|
-
runtime = manifest["runtime_format"]
|
|
26
|
-
for entry in manifest["shards"]:
|
|
27
|
-
shard_path = path / entry["file"]
|
|
28
|
-
require_file(shard_path)
|
|
29
|
-
file_md5 = md5_file(shard_path)
|
|
30
|
-
if file_md5 != entry.get("file_md5"):
|
|
31
|
-
raise ValueError(f"{shard_path}: file_md5 does not match manifest")
|
|
32
|
-
_ld_r, _a1freq, meta = read_npz_shard(
|
|
33
|
-
shard_path,
|
|
34
|
-
check_payload_structure=check_payload_structure,
|
|
35
|
-
)
|
|
36
|
-
validate_manifest_entry_agreement(entry, meta, shard_path)
|
|
37
|
-
else:
|
|
38
|
-
runtime = runtime_from_suffix(path)
|
|
39
|
-
if runtime != PY_RUNTIME_FORMAT:
|
|
40
|
-
raise ValueError(f"{path}: unsupported LD runtime {runtime!r}")
|
|
41
|
-
read_npz_shard(path, check_payload_structure=check_payload_structure)
|
|
42
51
|
|
|
52
|
+
def validate_npz_shard_file(path, check_payload_structure=False) -> dict:
|
|
53
|
+
path = Path(path)
|
|
54
|
+
runtime = runtime_from_suffix(path)
|
|
55
|
+
if runtime != PY_RUNTIME_FORMAT:
|
|
56
|
+
raise ValueError(f"{path}: unsupported LD runtime {runtime!r}")
|
|
57
|
+
read_npz_shard(path, check_payload_structure=check_payload_structure)
|
|
43
58
|
return {"ok": True}
|
|
44
59
|
|
|
45
60
|
|
|
61
|
+
def _validate_bundled_reference(*, root: Path, entry: dict) -> None:
|
|
62
|
+
from ._ld_reference import validate_bundled_reference_bim
|
|
63
|
+
|
|
64
|
+
validate_bundled_reference_bim(root / entry["reference_bim"], entry, target="manifest")
|
|
65
|
+
|
|
66
|
+
|
|
46
67
|
def read_npz_shard(path: Path, check_payload_structure: bool):
|
|
47
68
|
require_file(path)
|
|
48
69
|
with np.load(path, allow_pickle=False) as data:
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from ._ld_schema import require_file
|
|
4
|
+
from ._utils import validate_requested_shards
|
|
5
|
+
from .reference import ReferencePanel, load_reference
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_bundled_reference_bim(path, entry: dict, *, target: str):
|
|
9
|
+
path = Path(path)
|
|
10
|
+
require_file(path)
|
|
11
|
+
panel = load_reference(path)
|
|
12
|
+
ref_shards = panel.shards
|
|
13
|
+
if len(ref_shards) != 1:
|
|
14
|
+
raise ValueError(f"{path}: bundled reference_bim must contain exactly one shard")
|
|
15
|
+
ref = ref_shards[0]
|
|
16
|
+
validate_reference_shard_for_ld_entry(ref, entry, path, target=target)
|
|
17
|
+
return ref
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_reference_shard_for_ld_entry(ref_shard, entry: dict, path, *, target: str) -> None:
|
|
21
|
+
path = Path(path)
|
|
22
|
+
if ref_shard.label != entry["chr"]:
|
|
23
|
+
raise ValueError(f"{path}: bundled reference_bim chr does not match {target}")
|
|
24
|
+
if ref_shard.num_snp != int(entry["num_snp"]):
|
|
25
|
+
raise ValueError(f"{path}: bundled reference_bim num_snp does not match {target}")
|
|
26
|
+
if ref_shard.checksum != entry["reference_checksum"]:
|
|
27
|
+
raise ValueError(f"{path}: bundled reference_bim reference_checksum does not match {target}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_bundled_reference_panel(root, manifest: dict, shards, *, where: str) -> ReferencePanel:
|
|
31
|
+
root = Path(root)
|
|
32
|
+
available = _manifest_chr_labels(manifest)
|
|
33
|
+
selected = validate_requested_shards(shards, available, where)
|
|
34
|
+
entries_by_chr = {}
|
|
35
|
+
for entry in manifest["shards"]:
|
|
36
|
+
entries_by_chr.setdefault(entry["chr"], []).append(entry)
|
|
37
|
+
|
|
38
|
+
ref_shards = []
|
|
39
|
+
for label in selected:
|
|
40
|
+
entries = entries_by_chr[label]
|
|
41
|
+
reference_path = root / entries[0]["reference_bim"]
|
|
42
|
+
ref_shard = validate_bundled_reference_bim(reference_path, entries[0], target="manifest")
|
|
43
|
+
for entry in entries[1:]:
|
|
44
|
+
validate_reference_shard_for_ld_entry(ref_shard, entry, reference_path, target="manifest")
|
|
45
|
+
ref_shards.append(ref_shard)
|
|
46
|
+
return ReferencePanel(ref_shards)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _manifest_chr_labels(manifest: dict) -> list[str]:
|
|
50
|
+
labels = []
|
|
51
|
+
seen = set()
|
|
52
|
+
for entry in manifest["shards"]:
|
|
53
|
+
label = entry["chr"]
|
|
54
|
+
if label not in seen:
|
|
55
|
+
labels.append(label)
|
|
56
|
+
seen.add(label)
|
|
57
|
+
return labels
|
|
@@ -33,17 +33,30 @@ def read_manifest(path: Path, expected_runtime: str | None) -> dict:
|
|
|
33
33
|
raise ValueError(f"{path}: shards must be a non-empty list")
|
|
34
34
|
|
|
35
35
|
seen = set()
|
|
36
|
+
reference_bim_by_chr = {}
|
|
36
37
|
for i, entry in enumerate(shards):
|
|
37
38
|
validate_manifest_entry(entry, f"{path}:shards[{i}]")
|
|
38
39
|
key = (entry["chr"], entry["sex"])
|
|
39
40
|
if key in seen:
|
|
40
41
|
raise ValueError(f"{path}: duplicate manifest entry for chr {key[0]} sex {key[1]!r}")
|
|
41
42
|
seen.add(key)
|
|
43
|
+
prior_reference_bim = reference_bim_by_chr.setdefault(entry["chr"], entry["reference_bim"])
|
|
44
|
+
if entry["reference_bim"] != prior_reference_bim:
|
|
45
|
+
raise ValueError(f"{path}: manifest entries for chr {entry['chr']} must share reference_bim")
|
|
42
46
|
return manifest
|
|
43
47
|
|
|
44
48
|
|
|
45
49
|
def validate_manifest_entry(entry: dict, where: str) -> None:
|
|
46
|
-
required = {
|
|
50
|
+
required = {
|
|
51
|
+
"chr",
|
|
52
|
+
"sex",
|
|
53
|
+
"file",
|
|
54
|
+
"file_md5",
|
|
55
|
+
"num_snp",
|
|
56
|
+
"nnz",
|
|
57
|
+
"reference_checksum",
|
|
58
|
+
"reference_bim",
|
|
59
|
+
}
|
|
47
60
|
missing = sorted(required.difference(entry))
|
|
48
61
|
if missing:
|
|
49
62
|
raise ValueError(f"{where}: missing required fields: {', '.join(missing)}")
|
|
@@ -58,6 +71,7 @@ def validate_manifest_entry(entry: dict, where: str) -> None:
|
|
|
58
71
|
raise ValueError(f"{where}: reference_checksum must be a non-empty string")
|
|
59
72
|
if not isinstance(entry["file_md5"], str) or len(entry["file_md5"]) != 32:
|
|
60
73
|
raise ValueError(f"{where}: file_md5 must be a lowercase MD5 hex string")
|
|
74
|
+
validate_reference_bim_filename(entry["reference_bim"], f"{where}: reference_bim")
|
|
61
75
|
|
|
62
76
|
|
|
63
77
|
def validate_shard_metadata(meta: dict, path: Path, expected_format: str) -> None:
|
|
@@ -73,6 +87,8 @@ def validate_shard_metadata(meta: dict, path: Path, expected_format: str) -> Non
|
|
|
73
87
|
"diagonal",
|
|
74
88
|
"value",
|
|
75
89
|
"reference_checksum",
|
|
90
|
+
"reference_bim",
|
|
91
|
+
"num_monomorphic_snps",
|
|
76
92
|
}
|
|
77
93
|
missing = sorted(required.difference(meta))
|
|
78
94
|
if missing:
|
|
@@ -86,6 +102,12 @@ def validate_shard_metadata(meta: dict, path: Path, expected_format: str) -> Non
|
|
|
86
102
|
validate_chr_sex(meta["chr"], meta["sex"], f"{path}: metadata")
|
|
87
103
|
validate_positive_int(meta["num_snp"], f"{path}: metadata num_snp")
|
|
88
104
|
validate_nonnegative_int(meta["nnz"], f"{path}: metadata nnz")
|
|
105
|
+
validate_nonnegative_int(
|
|
106
|
+
meta["num_monomorphic_snps"],
|
|
107
|
+
f"{path}: metadata num_monomorphic_snps",
|
|
108
|
+
)
|
|
109
|
+
if int(meta["num_monomorphic_snps"]) > int(meta["num_snp"]):
|
|
110
|
+
raise ValueError(f"{path}: metadata num_monomorphic_snps must not exceed num_snp")
|
|
89
111
|
if int(meta["nnz"]) >= 2**31:
|
|
90
112
|
raise ValueError(f"{path}: CSC32 metadata nnz must be < 2^31")
|
|
91
113
|
if meta["matrix"] != "symmetric":
|
|
@@ -96,6 +118,7 @@ def validate_shard_metadata(meta: dict, path: Path, expected_format: str) -> Non
|
|
|
96
118
|
raise ValueError(f"{path}: metadata value must be 'r'")
|
|
97
119
|
if not isinstance(meta["reference_checksum"], str) or meta["reference_checksum"] == "":
|
|
98
120
|
raise ValueError(f"{path}: metadata reference_checksum must be a non-empty string")
|
|
121
|
+
validate_reference_bim_filename(meta["reference_bim"], f"{path}: metadata reference_bim")
|
|
99
122
|
if expected_format == NPZ_FORMAT:
|
|
100
123
|
if meta.get("sparse_layout") != "csc":
|
|
101
124
|
raise ValueError(f"{path}: metadata sparse_layout must be 'csc'")
|
|
@@ -119,7 +142,7 @@ def validate_chrx_sex(sex, where: str) -> str:
|
|
|
119
142
|
|
|
120
143
|
|
|
121
144
|
def validate_manifest_entry_agreement(entry: dict, meta: dict, path: Path) -> None:
|
|
122
|
-
for key in ("chr", "sex", "num_snp", "nnz", "reference_checksum"):
|
|
145
|
+
for key in ("chr", "sex", "num_snp", "nnz", "reference_checksum", "reference_bim"):
|
|
123
146
|
if entry.get(key) != meta.get(key):
|
|
124
147
|
raise ValueError(f"{path}: manifest/per-file metadata mismatch for {key}")
|
|
125
148
|
suffix_runtime = runtime_from_suffix(path)
|
|
@@ -127,6 +150,19 @@ def validate_manifest_entry_agreement(entry: dict, meta: dict, path: Path) -> No
|
|
|
127
150
|
raise ValueError(f"{path}: manifest file extension and metadata format disagree")
|
|
128
151
|
|
|
129
152
|
|
|
153
|
+
def default_reference_bim_filename(chr_label: str) -> str:
|
|
154
|
+
return f"reference_chr{chr_label}.bim"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def validate_reference_bim_filename(value, where: str) -> str:
|
|
158
|
+
if not isinstance(value, str) or value == "":
|
|
159
|
+
raise ValueError(f"{where} must be a non-empty filename")
|
|
160
|
+
path = Path(value)
|
|
161
|
+
if path.is_absolute() or len(path.parts) != 1 or value in {".", ".."} or ".." in value:
|
|
162
|
+
raise ValueError(f"{where} must be a plain relative filename")
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
|
|
130
166
|
def runtime_from_suffix(path: Path) -> str:
|
|
131
167
|
if path.suffix == ".npz":
|
|
132
168
|
return PY_RUNTIME_FORMAT
|
|
@@ -14,11 +14,14 @@ from ._ld_schema import (
|
|
|
14
14
|
NPZ_REQUIRED,
|
|
15
15
|
PY_RUNTIME_FORMAT,
|
|
16
16
|
SHARD_SCHEMA,
|
|
17
|
+
default_reference_bim_filename,
|
|
17
18
|
md5_file,
|
|
18
19
|
validate_chr_sex,
|
|
19
20
|
validate_positive_int,
|
|
21
|
+
validate_reference_bim_filename,
|
|
20
22
|
validate_shard_metadata,
|
|
21
23
|
)
|
|
24
|
+
from ._ld_reference import validate_bundled_reference_bim
|
|
22
25
|
from ._utils import CHR_RANK
|
|
23
26
|
|
|
24
27
|
|
|
@@ -70,8 +73,14 @@ def write_ld_npz_shards(root, shard_specs) -> list[dict]:
|
|
|
70
73
|
raise ValueError(f"duplicate LD shard output file: {file_key}")
|
|
71
74
|
seen_files.add(file_key)
|
|
72
75
|
|
|
73
|
-
shard_payload = {
|
|
76
|
+
shard_payload = {
|
|
77
|
+
k: v
|
|
78
|
+
for k, v in normalized.items()
|
|
79
|
+
if k not in {"file", "reference_shard"}
|
|
80
|
+
}
|
|
74
81
|
meta = _write_ld_npz_shard(path, **shard_payload)
|
|
82
|
+
reference_bim = meta["reference_bim"]
|
|
83
|
+
_ensure_reference_bim(root / reference_bim, normalized["reference_shard"], meta)
|
|
75
84
|
records.append(
|
|
76
85
|
{
|
|
77
86
|
"chr": meta["chr"],
|
|
@@ -81,6 +90,7 @@ def write_ld_npz_shards(root, shard_specs) -> list[dict]:
|
|
|
81
90
|
"num_snp": meta["num_snp"],
|
|
82
91
|
"nnz": meta["nnz"],
|
|
83
92
|
"reference_checksum": meta["reference_checksum"],
|
|
93
|
+
"reference_bim": reference_bim,
|
|
84
94
|
}
|
|
85
95
|
)
|
|
86
96
|
return records
|
|
@@ -131,6 +141,7 @@ def _ld_npz_manifest_record(path: Path, root: Path) -> dict:
|
|
|
131
141
|
"num_snp": meta["num_snp"],
|
|
132
142
|
"nnz": meta["nnz"],
|
|
133
143
|
"reference_checksum": meta["reference_checksum"],
|
|
144
|
+
"reference_bim": _require_reference_bim(root, meta),
|
|
134
145
|
}
|
|
135
146
|
|
|
136
147
|
|
|
@@ -155,6 +166,7 @@ def _normalize_ld_shard_write_spec(spec: dict) -> dict:
|
|
|
155
166
|
"build_metadata",
|
|
156
167
|
"extra_metadata",
|
|
157
168
|
"file",
|
|
169
|
+
"reference_bim",
|
|
158
170
|
}
|
|
159
171
|
unknown = sorted(set(spec).difference(allowed))
|
|
160
172
|
if unknown:
|
|
@@ -178,12 +190,26 @@ def _normalize_ld_shard_write_spec(spec: dict) -> dict:
|
|
|
178
190
|
reference_checksum = spec.get("reference_checksum", getattr(ref, "checksum", None))
|
|
179
191
|
if not isinstance(reference_checksum, str) or reference_checksum == "":
|
|
180
192
|
raise ValueError("LD shard write spec reference_checksum must be a non-empty string")
|
|
193
|
+
reference_bim = validate_reference_bim_filename(
|
|
194
|
+
spec.get("reference_bim", default_reference_bim_filename(chr_label)),
|
|
195
|
+
"LD shard write spec reference_bim",
|
|
196
|
+
)
|
|
197
|
+
if ref is None:
|
|
198
|
+
raise ValueError("LD shard write spec must provide reference_shard so reference_bim can be bundled")
|
|
199
|
+
if getattr(ref, "label", None) != chr_label:
|
|
200
|
+
raise ValueError("LD shard write spec reference_shard label must match chr")
|
|
201
|
+
if getattr(ref, "num_snp", None) != num_snp:
|
|
202
|
+
raise ValueError("LD shard write spec reference_shard num_snp must match num_snp")
|
|
203
|
+
if getattr(ref, "checksum", None) != reference_checksum:
|
|
204
|
+
raise ValueError("LD shard write spec reference_shard checksum must match reference_checksum")
|
|
181
205
|
|
|
182
206
|
return {
|
|
183
207
|
"chr_label": chr_label,
|
|
184
208
|
"sex": sex,
|
|
185
209
|
"num_snp": num_snp,
|
|
186
210
|
"reference_checksum": reference_checksum,
|
|
211
|
+
"reference_shard": ref,
|
|
212
|
+
"reference_bim": reference_bim,
|
|
187
213
|
"a1freq": _coerce_a1freq(spec.get("a1freq"), num_snp),
|
|
188
214
|
"ld_pairs": spec.get("ld_pairs"),
|
|
189
215
|
"build_metadata": dict(spec.get("build_metadata", {})),
|
|
@@ -199,6 +225,7 @@ def _write_ld_npz_shard(
|
|
|
199
225
|
sex,
|
|
200
226
|
num_snp,
|
|
201
227
|
reference_checksum,
|
|
228
|
+
reference_bim,
|
|
202
229
|
a1freq,
|
|
203
230
|
ld_pairs,
|
|
204
231
|
build_metadata=None,
|
|
@@ -217,7 +244,9 @@ def _write_ld_npz_shard(
|
|
|
217
244
|
sex=sex,
|
|
218
245
|
num_snp=num_snp,
|
|
219
246
|
nnz=nnz,
|
|
247
|
+
num_monomorphic_snps=int(np.count_nonzero((a1freq == 0.0) | (a1freq == 1.0))),
|
|
220
248
|
reference_checksum=reference_checksum,
|
|
249
|
+
reference_bim=reference_bim,
|
|
221
250
|
build_metadata=build_metadata,
|
|
222
251
|
extra_metadata=extra_metadata,
|
|
223
252
|
)
|
|
@@ -337,7 +366,9 @@ def _ld_npz_metadata(
|
|
|
337
366
|
sex,
|
|
338
367
|
num_snp,
|
|
339
368
|
nnz,
|
|
369
|
+
num_monomorphic_snps,
|
|
340
370
|
reference_checksum,
|
|
371
|
+
reference_bim,
|
|
341
372
|
build_metadata=None,
|
|
342
373
|
extra_metadata=None,
|
|
343
374
|
) -> dict:
|
|
@@ -354,7 +385,9 @@ def _ld_npz_metadata(
|
|
|
354
385
|
"matrix": "symmetric",
|
|
355
386
|
"diagonal": "explicit_unit",
|
|
356
387
|
"value": "r",
|
|
388
|
+
"num_monomorphic_snps": int(num_monomorphic_snps),
|
|
357
389
|
"reference_checksum": reference_checksum,
|
|
390
|
+
"reference_bim": reference_bim,
|
|
358
391
|
}
|
|
359
392
|
build = dict(LD_BUILD_METADATA_DEFAULTS)
|
|
360
393
|
if build_metadata:
|
|
@@ -362,6 +395,17 @@ def _ld_npz_metadata(
|
|
|
362
395
|
meta.update(build)
|
|
363
396
|
if extra_metadata:
|
|
364
397
|
meta.update(extra_metadata)
|
|
398
|
+
meta.update(
|
|
399
|
+
{
|
|
400
|
+
"chr": chr_label,
|
|
401
|
+
"sex": sex,
|
|
402
|
+
"num_snp": int(num_snp),
|
|
403
|
+
"nnz": int(nnz),
|
|
404
|
+
"num_monomorphic_snps": int(num_monomorphic_snps),
|
|
405
|
+
"reference_checksum": reference_checksum,
|
|
406
|
+
"reference_bim": reference_bim,
|
|
407
|
+
}
|
|
408
|
+
)
|
|
365
409
|
validate_shard_metadata(meta, Path("<generated>"), expected_format=NPZ_FORMAT)
|
|
366
410
|
return meta
|
|
367
411
|
|
|
@@ -372,6 +416,37 @@ def _default_ld_shard_filename(chr_label: str, sex, suffix: str) -> str:
|
|
|
372
416
|
return f"ld_chr{chr_label}{suffix}"
|
|
373
417
|
|
|
374
418
|
|
|
419
|
+
def _ensure_reference_bim(path: Path, reference_shard, meta: dict) -> None:
|
|
420
|
+
if path.exists():
|
|
421
|
+
_validate_existing_reference_bim(path, meta)
|
|
422
|
+
return
|
|
423
|
+
_write_reference_bim_from_shard(path, reference_shard)
|
|
424
|
+
_validate_existing_reference_bim(path, meta)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _write_reference_bim_from_shard(path: Path, reference_shard) -> None:
|
|
428
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
429
|
+
with open(path, "w", encoding="utf-8", newline="") as f:
|
|
430
|
+
for chr_label, snp, bp, a1, a2 in zip(
|
|
431
|
+
reference_shard.chr,
|
|
432
|
+
reference_shard.snp,
|
|
433
|
+
reference_shard.bp,
|
|
434
|
+
reference_shard.a1,
|
|
435
|
+
reference_shard.a2,
|
|
436
|
+
):
|
|
437
|
+
f.write(f"{chr_label}\t{snp}\t0\t{int(bp)}\t{a1}\t{a2}\n")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _validate_existing_reference_bim(path: Path, meta: dict) -> None:
|
|
441
|
+
validate_bundled_reference_bim(path, meta, target="LD shard")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _require_reference_bim(root: Path, meta: dict) -> str:
|
|
445
|
+
reference_bim = meta["reference_bim"]
|
|
446
|
+
_validate_existing_reference_bim(root / reference_bim, meta)
|
|
447
|
+
return reference_bim
|
|
448
|
+
|
|
449
|
+
|
|
375
450
|
def _write_deterministic_npz(path: Path, arrays: dict[str, np.ndarray]) -> None:
|
|
376
451
|
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
377
452
|
for name, array in arrays.items():
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
CANONICAL_CHR_ORDER = [str(i) for i in range(1, 23)] + ["X"]
|
|
6
|
+
CHR_RANK = {c: i for i, c in enumerate(CANONICAL_CHR_ORDER)}
|
|
7
|
+
_ALLELE_HASH_P = 2_147_483_647
|
|
8
|
+
_ALLELE_HASH_BASE1 = 257
|
|
9
|
+
_ALLELE_HASH_BASE2 = 263
|
|
10
|
+
_ALLELE_HASH_MAX_CHARS = 150
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_requested_shards(shards, available_labels, where: str) -> list[str]:
|
|
14
|
+
labels = list(available_labels)
|
|
15
|
+
if shards is None:
|
|
16
|
+
return labels
|
|
17
|
+
if isinstance(shards, str):
|
|
18
|
+
raise ValueError(f"{where}: shards must be a non-empty list of unique canonical contig labels")
|
|
19
|
+
|
|
20
|
+
requested = list(shards)
|
|
21
|
+
if not requested:
|
|
22
|
+
raise ValueError(f"{where}: shards must be a non-empty list of unique canonical contig labels")
|
|
23
|
+
|
|
24
|
+
seen = set()
|
|
25
|
+
prev_rank = None
|
|
26
|
+
for label in requested:
|
|
27
|
+
if label not in CHR_RANK:
|
|
28
|
+
raise ValueError(f"{where}: unsupported shard label {label!r}; expected canonical labels 1-22 or X")
|
|
29
|
+
if label in seen:
|
|
30
|
+
raise ValueError(f"{where}: duplicate shard label {label!r} in shards")
|
|
31
|
+
rank = CHR_RANK[label]
|
|
32
|
+
if prev_rank is not None and rank <= prev_rank:
|
|
33
|
+
raise ValueError(f"{where}: shards must be in canonical subsequence order")
|
|
34
|
+
seen.add(label)
|
|
35
|
+
prev_rank = rank
|
|
36
|
+
if label not in labels:
|
|
37
|
+
raise ValueError(f"{where}: requested shard {label!r} is not present")
|
|
38
|
+
return requested
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def allele_hash64(alleles) -> np.ndarray:
|
|
42
|
+
arr = np.asarray(alleles, dtype=object).reshape(-1)
|
|
43
|
+
n = arr.size
|
|
44
|
+
if n == 0:
|
|
45
|
+
return np.array([], dtype=np.uint64)
|
|
46
|
+
|
|
47
|
+
strings = [str(allele) for allele in arr]
|
|
48
|
+
lengths = np.fromiter((len(s) for s in strings), dtype=np.int64, count=n)
|
|
49
|
+
if np.any(lengths > _ALLELE_HASH_MAX_CHARS):
|
|
50
|
+
warnings.warn(
|
|
51
|
+
"Allele length exceeds 150 characters; hashing uses first 150 characters",
|
|
52
|
+
RuntimeWarning,
|
|
53
|
+
stacklevel=2,
|
|
54
|
+
)
|
|
55
|
+
strings = [s[:_ALLELE_HASH_MAX_CHARS] for s in strings]
|
|
56
|
+
lengths = np.minimum(lengths, _ALLELE_HASH_MAX_CHARS)
|
|
57
|
+
|
|
58
|
+
encoded = [s.encode("utf-8") for s in strings]
|
|
59
|
+
byte_lengths = np.fromiter((len(b) for b in encoded), dtype=np.int64, count=n)
|
|
60
|
+
max_len = int(byte_lengths.max(initial=0))
|
|
61
|
+
mat = np.zeros((n, max_len), dtype=np.uint8)
|
|
62
|
+
if max_len:
|
|
63
|
+
flat = np.frombuffer(b"".join(encoded), dtype=np.uint8)
|
|
64
|
+
row_idx = np.repeat(np.arange(n, dtype=np.int64), byte_lengths)
|
|
65
|
+
starts = np.cumsum(byte_lengths, dtype=np.int64) - byte_lengths
|
|
66
|
+
col_idx = np.arange(flat.size, dtype=np.int64) - np.repeat(starts, byte_lengths)
|
|
67
|
+
mat[row_idx, col_idx] = flat
|
|
68
|
+
|
|
69
|
+
h1 = np.ones(n, dtype=np.int64)
|
|
70
|
+
h2 = np.ones(n, dtype=np.int64)
|
|
71
|
+
for j in range(max_len):
|
|
72
|
+
active = j < byte_lengths
|
|
73
|
+
x = mat[active, j].astype(np.int64) + 1
|
|
74
|
+
h1[active] = (h1[active] * _ALLELE_HASH_BASE1 + x) % _ALLELE_HASH_P
|
|
75
|
+
h2[active] = (h2[active] * _ALLELE_HASH_BASE2 + x) % _ALLELE_HASH_P
|
|
76
|
+
|
|
77
|
+
return (h1.astype(np.uint64) << np.uint64(32)) + h2.astype(np.uint64)
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import json
|
|
2
|
-
import io
|
|
3
2
|
from pathlib import Path
|
|
4
3
|
|
|
5
4
|
import numpy as np
|
|
@@ -12,27 +11,6 @@ from ._utils import validate_requested_shards
|
|
|
12
11
|
_CACHE_SCHEMA = "annotations_cache/0.1"
|
|
13
12
|
|
|
14
13
|
|
|
15
|
-
def _is_ignored_bed_line(line: str) -> bool:
|
|
16
|
-
return (
|
|
17
|
-
line == ""
|
|
18
|
-
or line.startswith("#")
|
|
19
|
-
or line.startswith("track ")
|
|
20
|
-
or line.startswith("browser ")
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _read_bed_payload(path: Path) -> str:
|
|
25
|
-
kept = []
|
|
26
|
-
with open(path, "r", encoding="utf-8") as f:
|
|
27
|
-
for raw in f:
|
|
28
|
-
line = raw.rstrip("\r\n")
|
|
29
|
-
if _is_ignored_bed_line(line):
|
|
30
|
-
continue
|
|
31
|
-
kept.append(line)
|
|
32
|
-
if not kept:
|
|
33
|
-
raise ValueError(f"{path}: BED file is empty")
|
|
34
|
-
return "\n".join(kept) + "\n"
|
|
35
|
-
|
|
36
14
|
|
|
37
15
|
def _as_sparse_binary(mat) -> sparse.csr_matrix:
|
|
38
16
|
if sparse.issparse(mat):
|
|
@@ -94,14 +72,16 @@ def _annotation_name_from_path(path: Path) -> str:
|
|
|
94
72
|
|
|
95
73
|
|
|
96
74
|
def _parse_bed(path: Path) -> dict[str, np.ndarray]:
|
|
97
|
-
|
|
75
|
+
# Lines beginning with '#' and blank lines are skipped natively.
|
|
76
|
+
# 'track'/'browser' metadata lines are not supported; prefix with '#' if present.
|
|
98
77
|
try:
|
|
99
78
|
df = pd.read_csv(
|
|
100
|
-
|
|
101
|
-
sep=
|
|
79
|
+
path,
|
|
80
|
+
sep="\t",
|
|
102
81
|
header=None,
|
|
103
82
|
dtype=str,
|
|
104
|
-
|
|
83
|
+
comment="#",
|
|
84
|
+
skip_blank_lines=True,
|
|
105
85
|
keep_default_na=False,
|
|
106
86
|
na_filter=False,
|
|
107
87
|
)
|
|
@@ -113,7 +93,7 @@ def _parse_bed(path: Path) -> dict[str, np.ndarray]:
|
|
|
113
93
|
if df.shape[0] == 0:
|
|
114
94
|
raise ValueError(f"{path}: BED file is empty")
|
|
115
95
|
if df.shape[1] < 3:
|
|
116
|
-
raise ValueError(f"{path}: BED must have at least 3
|
|
96
|
+
raise ValueError(f"{path}: BED must have at least 3 tab-separated columns")
|
|
117
97
|
|
|
118
98
|
chr_col = df.iloc[:, 0]
|
|
119
99
|
start_raw = df.iloc[:, 1]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
+
import warnings
|
|
2
3
|
|
|
3
4
|
import numpy as np
|
|
4
5
|
from scipy import sparse
|
|
@@ -15,7 +16,16 @@ from ._utils import validate_requested_shards
|
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
class LDShard:
|
|
18
|
-
def __init__(
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
chr_label,
|
|
22
|
+
sex,
|
|
23
|
+
num_snp,
|
|
24
|
+
ld_r,
|
|
25
|
+
a1freq,
|
|
26
|
+
reference_checksum,
|
|
27
|
+
num_monomorphic_snps=0,
|
|
28
|
+
):
|
|
19
29
|
self._chr = str(chr_label)
|
|
20
30
|
self._sex = sex
|
|
21
31
|
self._num_snp = int(num_snp)
|
|
@@ -23,6 +33,7 @@ class LDShard:
|
|
|
23
33
|
self._ld_r2 = _build_ld_r2(self._ld_r)
|
|
24
34
|
self._a1freq = np.asarray(a1freq, dtype=np.float32).reshape(-1)
|
|
25
35
|
self._reference_checksum = str(reference_checksum)
|
|
36
|
+
self._num_monomorphic_snps = int(num_monomorphic_snps)
|
|
26
37
|
|
|
27
38
|
@property
|
|
28
39
|
def chr(self) -> str:
|
|
@@ -56,11 +67,16 @@ class LDShard:
|
|
|
56
67
|
def checksum(self) -> str:
|
|
57
68
|
return self._reference_checksum
|
|
58
69
|
|
|
70
|
+
@property
|
|
71
|
+
def num_monomorphic_snps(self) -> int:
|
|
72
|
+
return self._num_monomorphic_snps
|
|
73
|
+
|
|
59
74
|
|
|
60
75
|
class LDPanel:
|
|
61
|
-
def __init__(self, shard_groups, default_chrX_sex="female"):
|
|
76
|
+
def __init__(self, shard_groups, default_chrX_sex="female", reference=None):
|
|
62
77
|
self._shard_groups = [list(g) for g in shard_groups]
|
|
63
78
|
self._default_chrX_sex = validate_chrx_sex(default_chrX_sex, "default_chrX_sex")
|
|
79
|
+
self._reference = reference
|
|
64
80
|
self._shard_offsets = []
|
|
65
81
|
pos = 0
|
|
66
82
|
for group in self._shard_groups:
|
|
@@ -77,6 +93,8 @@ class LDPanel:
|
|
|
77
93
|
)
|
|
78
94
|
pos += first.num_snp
|
|
79
95
|
self._num_snp = pos
|
|
96
|
+
if self._reference is not None:
|
|
97
|
+
self._validate_reference_shape()
|
|
80
98
|
self._validate_default_chrX_sex()
|
|
81
99
|
|
|
82
100
|
@property
|
|
@@ -108,6 +126,10 @@ class LDPanel:
|
|
|
108
126
|
def default_chrX_sex(self) -> str:
|
|
109
127
|
return self._default_chrX_sex
|
|
110
128
|
|
|
129
|
+
@property
|
|
130
|
+
def reference(self):
|
|
131
|
+
return self._reference
|
|
132
|
+
|
|
111
133
|
def a1freq(self, chrX_sex=None) -> np.ndarray:
|
|
112
134
|
shards = [shard for shard, _start, _stop in self.iter_shards_with_offsets(chrX_sex)]
|
|
113
135
|
if not shards:
|
|
@@ -118,7 +140,8 @@ class LDPanel:
|
|
|
118
140
|
available = [group[0].label for group in self._shard_groups]
|
|
119
141
|
selected = validate_requested_shards(shards, available, "LDPanel.select_shards")
|
|
120
142
|
by_label = {group[0].label: group for group in self._shard_groups}
|
|
121
|
-
|
|
143
|
+
reference = None if self._reference is None else self._reference.select_shards(selected)
|
|
144
|
+
return LDPanel([by_label[label] for label in selected], self._default_chrX_sex, reference)
|
|
122
145
|
|
|
123
146
|
def multiply_r2(self, M, chrX_sex=None):
|
|
124
147
|
if sparse.issparse(M):
|
|
@@ -156,6 +179,17 @@ class LDPanel:
|
|
|
156
179
|
f"got {self._default_chrX_sex!r}, present {sorted(present)!r}"
|
|
157
180
|
)
|
|
158
181
|
|
|
182
|
+
def _validate_reference_shape(self) -> None:
|
|
183
|
+
ref_shards = list(self._reference.shards)
|
|
184
|
+
if len(ref_shards) != len(self._shard_groups):
|
|
185
|
+
raise ValueError("LDPanel reference shard count must match LD shard groups")
|
|
186
|
+
for ref_shard, group in zip(ref_shards, self._shard_groups):
|
|
187
|
+
first = group[0]
|
|
188
|
+
if ref_shard.label != first.label:
|
|
189
|
+
raise ValueError("LDPanel reference shard labels must match LD shard groups")
|
|
190
|
+
if ref_shard.num_snp != first.num_snp:
|
|
191
|
+
raise ValueError("LDPanel reference shard sizes must match LD shard groups")
|
|
192
|
+
|
|
159
193
|
def _multiply_r2_dense(self, M, chrX_sex=None):
|
|
160
194
|
arr = np.asarray(M)
|
|
161
195
|
if arr.ndim == 1:
|
|
@@ -213,26 +247,32 @@ class LDPanel:
|
|
|
213
247
|
return sparse.vstack(parts, format=M.getformat()).astype(out_dtype)
|
|
214
248
|
|
|
215
249
|
|
|
216
|
-
def load_ld(path, reference, default_chrX_sex=None) -> LDPanel:
|
|
250
|
+
def load_ld(path, reference=None, shards=None, default_chrX_sex=None) -> LDPanel:
|
|
217
251
|
default_chrX_sex = validate_chrx_sex(
|
|
218
252
|
"female" if default_chrX_sex is None else default_chrX_sex,
|
|
219
253
|
"default_chrX_sex",
|
|
220
254
|
)
|
|
221
255
|
path = Path(path)
|
|
256
|
+
if not path.is_dir():
|
|
257
|
+
raise ValueError("load_ld: path must identify a panel root directory")
|
|
258
|
+
|
|
259
|
+
manifest = read_manifest(path / "ld_manifest.json", expected_runtime=PY_RUNTIME_FORMAT)
|
|
260
|
+
if reference is None:
|
|
261
|
+
reference = _load_bundled_reference(path, manifest, shards)
|
|
262
|
+
elif shards is not None:
|
|
263
|
+
reference = reference.select_shards(shards)
|
|
264
|
+
|
|
222
265
|
ref_shards = list(reference.shards)
|
|
223
266
|
if not ref_shards:
|
|
224
267
|
raise ValueError("load_ld: reference must contain at least one shard")
|
|
225
268
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
groups = _load_panel_root(path, manifest, ref_shards)
|
|
229
|
-
else:
|
|
230
|
-
groups = _load_single_shard(path, ref_shards)
|
|
231
|
-
|
|
232
|
-
return LDPanel(groups, default_chrX_sex=default_chrX_sex)
|
|
269
|
+
groups = _load_panel_root(path, manifest, ref_shards)
|
|
270
|
+
return LDPanel(groups, default_chrX_sex=default_chrX_sex, reference=reference)
|
|
233
271
|
|
|
234
272
|
|
|
235
273
|
def validate_ld_distribution(path, check_payload_structure=False) -> dict:
|
|
274
|
+
if not Path(path).is_dir():
|
|
275
|
+
raise ValueError("validate_ld_distribution: path must identify a panel root directory")
|
|
236
276
|
return validate_npz_distribution(path, check_payload_structure=check_payload_structure)
|
|
237
277
|
|
|
238
278
|
|
|
@@ -311,6 +351,12 @@ def _write_ld_npz_distribution(*args, **kwargs):
|
|
|
311
351
|
return write_ld_npz_distribution(*args, **kwargs)
|
|
312
352
|
|
|
313
353
|
|
|
354
|
+
def _load_bundled_reference(root: Path, manifest: dict, shards):
|
|
355
|
+
from ._ld_reference import load_bundled_reference_panel
|
|
356
|
+
|
|
357
|
+
return load_bundled_reference_panel(root, manifest, shards, where="load_ld")
|
|
358
|
+
|
|
359
|
+
|
|
314
360
|
def _load_panel_root(root: Path, manifest: dict, ref_shards: list) -> list[list[LDShard]]:
|
|
315
361
|
entries = list(manifest["shards"])
|
|
316
362
|
groups = []
|
|
@@ -346,22 +392,6 @@ def _load_panel_root(root: Path, manifest: dict, ref_shards: list) -> list[list[
|
|
|
346
392
|
return groups
|
|
347
393
|
|
|
348
394
|
|
|
349
|
-
def _load_single_shard(path: Path, ref_shards: list) -> list[list[LDShard]]:
|
|
350
|
-
shard, _meta = _load_npz_shard(path)
|
|
351
|
-
if len(ref_shards) != 1:
|
|
352
|
-
raise ValueError(
|
|
353
|
-
"single-shard LD loads require a single-shard reference; "
|
|
354
|
-
f"reference has {len(ref_shards)} shards"
|
|
355
|
-
)
|
|
356
|
-
if ref_shards[0].label != shard.chr:
|
|
357
|
-
raise ValueError(
|
|
358
|
-
"single-shard LD chromosome does not match single-shard reference: "
|
|
359
|
-
f"LD chr {shard.chr!r}, reference chr {ref_shards[0].label!r}"
|
|
360
|
-
)
|
|
361
|
-
_validate_reference_compatibility(shard, ref_shards[0], path)
|
|
362
|
-
return [[shard]]
|
|
363
|
-
|
|
364
|
-
|
|
365
395
|
def _load_npz_shard(path: Path) -> tuple[LDShard, dict]:
|
|
366
396
|
require_file(path)
|
|
367
397
|
ld_r, a1freq, meta = read_npz_shard(path, check_payload_structure=False)
|
|
@@ -372,7 +402,15 @@ def _load_npz_shard(path: Path) -> tuple[LDShard, dict]:
|
|
|
372
402
|
ld_r,
|
|
373
403
|
a1freq,
|
|
374
404
|
meta["reference_checksum"],
|
|
405
|
+
int(meta["num_monomorphic_snps"]),
|
|
375
406
|
)
|
|
407
|
+
if shard.num_monomorphic_snps:
|
|
408
|
+
warnings.warn(
|
|
409
|
+
f"{path}: LD shard contains {shard.num_monomorphic_snps} monomorphic SNPs; "
|
|
410
|
+
"LD involving those SNPs is undefined and represented by omitted off-diagonal entries",
|
|
411
|
+
RuntimeWarning,
|
|
412
|
+
stacklevel=2,
|
|
413
|
+
)
|
|
376
414
|
return shard, meta
|
|
377
415
|
|
|
378
416
|
|
|
@@ -8,7 +8,7 @@ import numpy as np
|
|
|
8
8
|
import pandas as pd
|
|
9
9
|
from pandas.errors import ParserError
|
|
10
10
|
|
|
11
|
-
from ._utils import CANONICAL_CHR_ORDER, CHR_RANK, validate_requested_shards
|
|
11
|
+
from ._utils import CANONICAL_CHR_ORDER, CHR_RANK, allele_hash64, validate_requested_shards
|
|
12
12
|
|
|
13
13
|
logger = logging.getLogger(__name__)
|
|
14
14
|
|
|
@@ -16,8 +16,6 @@ _CACHE_SCHEMA = "reference_cache/0.1"
|
|
|
16
16
|
_BIM_NCOLS = 6
|
|
17
17
|
_IGNORED_CHR = {"Y", "MT"}
|
|
18
18
|
_DNA_ALLELE_RE = re.compile(r"^[ACGT]+$")
|
|
19
|
-
|
|
20
|
-
|
|
21
19
|
def _validate_reference_sort_order(
|
|
22
20
|
chr_col: pd.Series,
|
|
23
21
|
bp_col: np.ndarray,
|
|
@@ -34,23 +32,21 @@ def _validate_reference_sort_order(
|
|
|
34
32
|
f"{path}:{idx + 1}: chr must use canonical labels 1-22 or X"
|
|
35
33
|
)
|
|
36
34
|
|
|
37
|
-
order_df = pd.DataFrame(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
)
|
|
35
|
+
order_df = pd.DataFrame({
|
|
36
|
+
"chr_rank": chr_rank.to_numpy(dtype=np.int64),
|
|
37
|
+
"bp": np.asarray(bp_col, dtype=np.int64),
|
|
38
|
+
"a1_hash64": allele_hash64(a1_col.to_numpy(dtype=object)),
|
|
39
|
+
"a2_hash64": allele_hash64(a2_col.to_numpy(dtype=object)),
|
|
40
|
+
"line": np.asarray(line_numbers, dtype=np.int64),
|
|
41
|
+
})
|
|
46
42
|
|
|
47
43
|
dup_mask = order_df.duplicated(
|
|
48
|
-
subset=["chr_rank", "bp", "
|
|
44
|
+
subset=["chr_rank", "bp", "a1_hash64", "a2_hash64"], keep="first"
|
|
49
45
|
)
|
|
50
46
|
if dup_mask.any():
|
|
51
47
|
line = int(order_df.loc[dup_mask.idxmax(), "line"])
|
|
52
48
|
raise ValueError(
|
|
53
|
-
f"{path}:{line}: duplicate (chr, bp,
|
|
49
|
+
f"{path}:{line}: duplicate (chr, bp, a1_hash64, a2_hash64) matching key is not allowed"
|
|
54
50
|
)
|
|
55
51
|
|
|
56
52
|
prev = order_df.shift(1)
|
|
@@ -58,26 +54,14 @@ def _validate_reference_sort_order(
|
|
|
58
54
|
(order_df["chr_rank"] < prev["chr_rank"])
|
|
59
55
|
| (
|
|
60
56
|
(order_df["chr_rank"] == prev["chr_rank"])
|
|
61
|
-
& (
|
|
62
|
-
(order_df["bp"] < prev["bp"])
|
|
63
|
-
| (
|
|
64
|
-
(order_df["bp"] == prev["bp"])
|
|
65
|
-
& (
|
|
66
|
-
(order_df["a1"] < prev["a1"])
|
|
67
|
-
| (
|
|
68
|
-
(order_df["a1"] == prev["a1"])
|
|
69
|
-
& (order_df["a2"] < prev["a2"])
|
|
70
|
-
)
|
|
71
|
-
)
|
|
72
|
-
)
|
|
73
|
-
)
|
|
57
|
+
& (order_df["bp"] < prev["bp"])
|
|
74
58
|
)
|
|
75
59
|
)
|
|
76
60
|
bad_order = bad_order.fillna(False)
|
|
77
61
|
if bad_order.any():
|
|
78
62
|
line = int(order_df.loc[bad_order.idxmax(), "line"])
|
|
79
63
|
raise ValueError(
|
|
80
|
-
f"{path}:{line}: rows must be sorted by (chr_rank, bp
|
|
64
|
+
f"{path}:{line}: rows must be sorted by (chr_rank, bp) in canonical contig order"
|
|
81
65
|
)
|
|
82
66
|
|
|
83
67
|
|
|
@@ -221,6 +205,18 @@ class ReferenceShard:
|
|
|
221
205
|
self._bp = np.asarray(bp_arr, dtype=np.int64)
|
|
222
206
|
self._a1 = np.asarray(a1_arr, dtype=object)
|
|
223
207
|
self._a2 = np.asarray(a2_arr, dtype=object)
|
|
208
|
+
if not (
|
|
209
|
+
self._chr.size
|
|
210
|
+
== self._snp.size
|
|
211
|
+
== self._bp.size
|
|
212
|
+
== self._a1.size
|
|
213
|
+
== self._a2.size
|
|
214
|
+
):
|
|
215
|
+
raise ValueError("ReferenceShard vector lengths must match")
|
|
216
|
+
if self._chr.size and not np.all(self._chr == self._label):
|
|
217
|
+
raise ValueError(f"Reference shard {self._label} contains multiple chr labels")
|
|
218
|
+
self._a1_hash64 = allele_hash64(self._a1)
|
|
219
|
+
self._a2_hash64 = allele_hash64(self._a2)
|
|
224
220
|
self._checksum = _checksum_from_arrays(self._chr, self._bp, self._a1, self._a2)
|
|
225
221
|
|
|
226
222
|
@property
|
|
@@ -251,12 +247,31 @@ class ReferenceShard:
|
|
|
251
247
|
def a2(self) -> np.ndarray:
|
|
252
248
|
return self._a2
|
|
253
249
|
|
|
250
|
+
@property
|
|
251
|
+
def a1_hash64(self) -> np.ndarray:
|
|
252
|
+
return self._a1_hash64
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def a2_hash64(self) -> np.ndarray:
|
|
256
|
+
return self._a2_hash64
|
|
257
|
+
|
|
254
258
|
@property
|
|
255
259
|
def checksum(self) -> str:
|
|
256
260
|
return self._checksum
|
|
257
261
|
|
|
258
262
|
@classmethod
|
|
259
|
-
def _from_arrays(
|
|
263
|
+
def _from_arrays(
|
|
264
|
+
cls,
|
|
265
|
+
label,
|
|
266
|
+
chr_arr,
|
|
267
|
+
snp_arr,
|
|
268
|
+
bp_arr,
|
|
269
|
+
a1_arr,
|
|
270
|
+
a2_arr,
|
|
271
|
+
checksum,
|
|
272
|
+
a1_hash64=None,
|
|
273
|
+
a2_hash64=None,
|
|
274
|
+
):
|
|
260
275
|
obj = cls.__new__(cls)
|
|
261
276
|
obj._label = label
|
|
262
277
|
obj._chr = np.asarray(chr_arr, dtype=object)
|
|
@@ -264,6 +279,28 @@ class ReferenceShard:
|
|
|
264
279
|
obj._bp = np.asarray(bp_arr, dtype=np.int64)
|
|
265
280
|
obj._a1 = np.asarray(a1_arr, dtype=object)
|
|
266
281
|
obj._a2 = np.asarray(a2_arr, dtype=object)
|
|
282
|
+
if not (
|
|
283
|
+
obj._chr.size
|
|
284
|
+
== obj._snp.size
|
|
285
|
+
== obj._bp.size
|
|
286
|
+
== obj._a1.size
|
|
287
|
+
== obj._a2.size
|
|
288
|
+
):
|
|
289
|
+
raise ValueError("Invalid reference cache: panel-wide vector lengths mismatch")
|
|
290
|
+
if obj._chr.size and not np.all(obj._chr == obj._label):
|
|
291
|
+
raise ValueError(f"Invalid reference cache: shard {obj._label} contains multiple chr labels")
|
|
292
|
+
obj._a1_hash64 = (
|
|
293
|
+
allele_hash64(obj._a1)
|
|
294
|
+
if a1_hash64 is None
|
|
295
|
+
else np.asarray(a1_hash64, dtype=np.uint64).reshape(-1)
|
|
296
|
+
)
|
|
297
|
+
obj._a2_hash64 = (
|
|
298
|
+
allele_hash64(obj._a2)
|
|
299
|
+
if a2_hash64 is None
|
|
300
|
+
else np.asarray(a2_hash64, dtype=np.uint64).reshape(-1)
|
|
301
|
+
)
|
|
302
|
+
if obj._a1_hash64.size != obj._bp.size or obj._a2_hash64.size != obj._bp.size:
|
|
303
|
+
raise ValueError("Invalid reference cache: allele hash vector lengths mismatch")
|
|
267
304
|
obj._checksum = checksum
|
|
268
305
|
return obj
|
|
269
306
|
|
|
@@ -313,6 +350,18 @@ class ReferencePanel:
|
|
|
313
350
|
return np.array([], dtype=object)
|
|
314
351
|
return np.concatenate([s.a2 for s in self._shards])
|
|
315
352
|
|
|
353
|
+
@property
|
|
354
|
+
def a1_hash64(self) -> np.ndarray:
|
|
355
|
+
if not self._shards:
|
|
356
|
+
return np.array([], dtype=np.uint64)
|
|
357
|
+
return np.concatenate([s.a1_hash64 for s in self._shards])
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def a2_hash64(self) -> np.ndarray:
|
|
361
|
+
if not self._shards:
|
|
362
|
+
return np.array([], dtype=np.uint64)
|
|
363
|
+
return np.concatenate([s.a2_hash64 for s in self._shards])
|
|
364
|
+
|
|
316
365
|
@property
|
|
317
366
|
def shard_offsets(self) -> list:
|
|
318
367
|
return list(self._shard_offsets)
|
|
@@ -327,6 +376,13 @@ class ReferencePanel:
|
|
|
327
376
|
by_label = {s.label: s for s in self._shards}
|
|
328
377
|
return ReferencePanel([by_label[label] for label in selected])
|
|
329
378
|
|
|
379
|
+
def validate_checksums(self) -> bool:
|
|
380
|
+
for shard in self._shards:
|
|
381
|
+
computed = _checksum_from_arrays(shard.chr, shard.bp, shard.a1, shard.a2)
|
|
382
|
+
if computed != shard.checksum:
|
|
383
|
+
raise ValueError(f"Reference checksum mismatch for shard {shard.label}")
|
|
384
|
+
return True
|
|
385
|
+
|
|
330
386
|
def is_object_compatible(self, obj) -> bool:
|
|
331
387
|
log_fn = logger.warning
|
|
332
388
|
|
|
@@ -441,12 +497,18 @@ def load_reference(path, shards=None) -> ReferencePanel:
|
|
|
441
497
|
return ReferencePanel(out_shards)
|
|
442
498
|
|
|
443
499
|
|
|
444
|
-
def save_reference_cache(panel: ReferencePanel, path) -> None:
|
|
500
|
+
def save_reference_cache(panel: ReferencePanel, path, mode: str = "full") -> None:
|
|
501
|
+
mode = str(mode).lower()
|
|
502
|
+
if mode not in {"full", "thin"}:
|
|
503
|
+
raise ValueError("save_reference_cache mode must be 'full' or 'thin'")
|
|
504
|
+
|
|
445
505
|
# Metadata (schema, labels, checksums) as a compact JSON blob stored in the npz.
|
|
446
506
|
# SNP-axis numeric vectors (bp) and string arrays are stored as native
|
|
447
507
|
# binary numpy arrays — not JSON — per the performance contract.
|
|
508
|
+
# Python keeps reference caches full even when mode='thin' is requested.
|
|
448
509
|
meta = {
|
|
449
510
|
"schema": _CACHE_SCHEMA,
|
|
511
|
+
"mode": "full",
|
|
450
512
|
"shard_labels": [s.label for s in panel.shards],
|
|
451
513
|
"shard_checksums": [s.checksum for s in panel.shards],
|
|
452
514
|
}
|
|
@@ -460,6 +522,8 @@ def save_reference_cache(panel: ReferencePanel, path) -> None:
|
|
|
460
522
|
arrays[p + "bp"] = s.bp
|
|
461
523
|
arrays[p + "a1"] = np.asarray(s.a1, dtype=str)
|
|
462
524
|
arrays[p + "a2"] = np.asarray(s.a2, dtype=str)
|
|
525
|
+
arrays[p + "a1_hash64"] = s.a1_hash64
|
|
526
|
+
arrays[p + "a2_hash64"] = s.a2_hash64
|
|
463
527
|
np.savez_compressed(path, **arrays)
|
|
464
528
|
|
|
465
529
|
|
|
@@ -469,6 +533,11 @@ def load_reference_cache(path, shards=None) -> ReferencePanel:
|
|
|
469
533
|
schema = meta.get("schema")
|
|
470
534
|
if schema != _CACHE_SCHEMA:
|
|
471
535
|
raise ValueError(f"Unsupported reference cache schema: {schema!r}")
|
|
536
|
+
mode = meta.get("mode")
|
|
537
|
+
if mode is None:
|
|
538
|
+
raise ValueError("reference cache missing mode; delete and rebuild old cache")
|
|
539
|
+
if mode != "full":
|
|
540
|
+
raise ValueError(f"Unsupported Python reference cache mode: {mode!r}")
|
|
472
541
|
|
|
473
542
|
labels = list(meta.get("shard_labels", []))
|
|
474
543
|
checksums = list(meta.get("shard_checksums", []))
|
|
@@ -481,6 +550,8 @@ def load_reference_cache(path, shards=None) -> ReferencePanel:
|
|
|
481
550
|
for label in selected:
|
|
482
551
|
i = label_to_index[label]
|
|
483
552
|
p = f"s{i}_"
|
|
553
|
+
if p + "a1_hash64" not in data or p + "a2_hash64" not in data:
|
|
554
|
+
raise ValueError("reference cache missing a1_hash64/a2_hash64; rebuild cache")
|
|
484
555
|
shard_objs.append(
|
|
485
556
|
ReferenceShard._from_arrays(
|
|
486
557
|
label,
|
|
@@ -490,6 +561,8 @@ def load_reference_cache(path, shards=None) -> ReferencePanel:
|
|
|
490
561
|
data[p + "a1"],
|
|
491
562
|
data[p + "a2"],
|
|
492
563
|
checksums[i],
|
|
564
|
+
data[p + "a1_hash64"],
|
|
565
|
+
data[p + "a2_hash64"],
|
|
493
566
|
)
|
|
494
567
|
)
|
|
495
568
|
return ReferencePanel(shard_objs)
|
|
@@ -5,20 +5,77 @@ import numpy as np
|
|
|
5
5
|
import pandas as pd
|
|
6
6
|
from pandas.errors import ParserError
|
|
7
7
|
|
|
8
|
-
from ._utils import validate_requested_shards
|
|
8
|
+
from ._utils import allele_hash64, validate_requested_shards
|
|
9
9
|
|
|
10
10
|
_CACHE_SCHEMA = "sumstats_cache/0.1"
|
|
11
11
|
_REQUIRED_COLS = ("chr", "bp", "a1", "a2", "z", "n")
|
|
12
12
|
_OPTIONAL_COLS = ("p", "beta", "se", "eaf", "info")
|
|
13
|
+
_SUMSTATS_COL_MAP = {
|
|
14
|
+
"pos": "bp",
|
|
15
|
+
"effectallele": "a1",
|
|
16
|
+
"otherallele": "a2",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_KEY_DTYPE = np.dtype([("bp", "<i8"), ("a1_hash64", "<u8"), ("a2_hash64", "<u8")])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _numeric_keys(bp_arr, a1_hash64, a2_hash64) -> np.ndarray:
|
|
24
|
+
bp = np.asarray(bp_arr, dtype=np.int64).reshape(-1)
|
|
25
|
+
a1h = np.asarray(a1_hash64, dtype=np.uint64).reshape(-1)
|
|
26
|
+
a2h = np.asarray(a2_hash64, dtype=np.uint64).reshape(-1)
|
|
27
|
+
if not (bp.size == a1h.size == a2h.size):
|
|
28
|
+
raise ValueError("numeric key vector lengths mismatch")
|
|
29
|
+
keys = np.empty(bp.size, dtype=_KEY_DTYPE)
|
|
30
|
+
keys["bp"] = bp
|
|
31
|
+
keys["a1_hash64"] = a1h
|
|
32
|
+
keys["a2_hash64"] = a2h
|
|
33
|
+
return keys
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check_unique_sorted_keys(sorted_keys: np.ndarray, where: str) -> None:
|
|
37
|
+
if sorted_keys.size < 2:
|
|
38
|
+
return
|
|
39
|
+
dup = sorted_keys[1:] == sorted_keys[:-1]
|
|
40
|
+
if np.any(dup):
|
|
41
|
+
pos = int(np.flatnonzero(dup)[0] + 1)
|
|
42
|
+
key = sorted_keys[pos]
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Ambiguous duplicate sumstats/reference matching key in {where}: "
|
|
45
|
+
f"bp={int(key['bp'])}, a1_hash64={int(key['a1_hash64'])}, "
|
|
46
|
+
f"a2_hash64={int(key['a2_hash64'])}"
|
|
47
|
+
)
|
|
13
48
|
|
|
14
49
|
|
|
15
|
-
def
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
50
|
+
def _match_shard_numeric(ref_keys: np.ndarray, src_keys: np.ndarray, shard_label: str) -> np.ndarray:
|
|
51
|
+
if src_keys.size == 0:
|
|
52
|
+
return np.full(ref_keys.size, -1, dtype=np.int64)
|
|
53
|
+
|
|
54
|
+
ref_order = np.argsort(ref_keys, kind="mergesort", order=("bp", "a1_hash64", "a2_hash64"))
|
|
55
|
+
src_order = np.argsort(src_keys, kind="mergesort", order=("bp", "a1_hash64", "a2_hash64"))
|
|
56
|
+
ref_sorted = ref_keys[ref_order]
|
|
57
|
+
src_sorted = src_keys[src_order]
|
|
58
|
+
_check_unique_sorted_keys(ref_sorted, f"reference shard {shard_label}")
|
|
59
|
+
_check_unique_sorted_keys(src_sorted, f"sumstats shard {shard_label}")
|
|
60
|
+
|
|
61
|
+
pos = np.searchsorted(src_sorted, ref_keys)
|
|
62
|
+
in_range = pos < src_sorted.size
|
|
63
|
+
matched = np.zeros(ref_keys.size, dtype=bool)
|
|
64
|
+
matched[in_range] = src_sorted[pos[in_range]] == ref_keys[in_range]
|
|
65
|
+
|
|
66
|
+
out = np.full(ref_keys.size, -1, dtype=np.int64)
|
|
67
|
+
out[matched] = src_order[pos[matched]]
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _canonicalize_columns(df: pd.DataFrame, path: Path) -> pd.DataFrame:
|
|
72
|
+
columns = [_SUMSTATS_COL_MAP.get(str(c).lower(), str(c).lower()) for c in df.columns]
|
|
73
|
+
duplicates = sorted({c for c in columns if columns.count(c) > 1})
|
|
74
|
+
if duplicates:
|
|
75
|
+
raise ValueError(f"{path}: duplicate columns after column normalization: {', '.join(duplicates)}")
|
|
76
|
+
out = df.copy()
|
|
77
|
+
out.columns = columns
|
|
78
|
+
return out
|
|
22
79
|
|
|
23
80
|
|
|
24
81
|
def _parse_sumstats(path: Path) -> pd.DataFrame:
|
|
@@ -34,6 +91,8 @@ def _parse_sumstats(path: Path) -> pd.DataFrame:
|
|
|
34
91
|
except ParserError as exc:
|
|
35
92
|
raise ValueError(f"{path}: malformed TSV") from exc
|
|
36
93
|
|
|
94
|
+
df = _canonicalize_columns(df, path)
|
|
95
|
+
|
|
37
96
|
missing = [c for c in _REQUIRED_COLS if c not in df.columns]
|
|
38
97
|
if missing:
|
|
39
98
|
raise ValueError(f"{path}: missing required columns: {', '.join(missing)}")
|
|
@@ -289,40 +348,45 @@ def _build_sumstats_from_aligned(
|
|
|
289
348
|
|
|
290
349
|
|
|
291
350
|
def _build_sumstats_panel(df: pd.DataFrame, reference) -> Sumstats:
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
)
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
351
|
+
n = int(reference.num_snp)
|
|
352
|
+
aligned_z = np.full(n, np.nan, dtype=float)
|
|
353
|
+
aligned_n = np.full(n, np.nan, dtype=float)
|
|
354
|
+
aligned_p = np.full(n, np.nan, dtype=float) if "p" in df.columns else None
|
|
355
|
+
|
|
356
|
+
aligned_optional = {
|
|
357
|
+
col: (np.full(n, np.nan, dtype=float) if col in df.columns else None)
|
|
358
|
+
for col in ("beta", "se", "eaf", "info")
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
src_chr = df["chr"].to_numpy(dtype=object)
|
|
362
|
+
src_bp = df["bp"].to_numpy(dtype=np.int64)
|
|
363
|
+
src_a1_hash64 = allele_hash64(df["a1"].to_numpy(dtype=object))
|
|
364
|
+
src_a2_hash64 = allele_hash64(df["a2"].to_numpy(dtype=object))
|
|
365
|
+
|
|
366
|
+
for ref_shard, off in zip(reference.shards, reference.shard_offsets):
|
|
367
|
+
start = int(off["start0"])
|
|
368
|
+
stop = int(off["stop0"])
|
|
369
|
+
src_mask = src_chr == ref_shard.label
|
|
370
|
+
src_idx = np.flatnonzero(src_mask)
|
|
371
|
+
ref_keys = _numeric_keys(ref_shard.bp, ref_shard.a1_hash64, ref_shard.a2_hash64)
|
|
372
|
+
src_keys = _numeric_keys(src_bp[src_idx], src_a1_hash64[src_idx], src_a2_hash64[src_idx])
|
|
373
|
+
local_match = _match_shard_numeric(ref_keys, src_keys, ref_shard.label)
|
|
374
|
+
has_match = local_match >= 0
|
|
375
|
+
matched_src = src_idx[local_match[has_match]]
|
|
376
|
+
aligned_ix = np.arange(start, stop, dtype=np.int64)[has_match]
|
|
377
|
+
|
|
378
|
+
aligned_z[aligned_ix] = df["z"].to_numpy(dtype=float)[matched_src]
|
|
379
|
+
aligned_n[aligned_ix] = df["n"].to_numpy(dtype=float)[matched_src]
|
|
380
|
+
if aligned_p is not None:
|
|
381
|
+
aligned_p[aligned_ix] = df["p"].to_numpy(dtype=float)[matched_src]
|
|
382
|
+
for col, out in aligned_optional.items():
|
|
383
|
+
if out is not None:
|
|
384
|
+
out[aligned_ix] = df[col].to_numpy(dtype=float)[matched_src]
|
|
385
|
+
|
|
386
|
+
if aligned_p is None:
|
|
387
|
+
aligned_logp = np.full(n, np.nan, dtype=float)
|
|
317
388
|
else:
|
|
318
|
-
aligned_logp =
|
|
319
|
-
|
|
320
|
-
aligned_optional = {}
|
|
321
|
-
for col in ("beta", "se", "eaf", "info"):
|
|
322
|
-
if col in key_to:
|
|
323
|
-
aligned_optional[col] = key_to[col].reindex(ref_keys).to_numpy(dtype=float)
|
|
324
|
-
else:
|
|
325
|
-
aligned_optional[col] = None
|
|
389
|
+
aligned_logp = _derive_logp(aligned_p)
|
|
326
390
|
|
|
327
391
|
return _build_sumstats_from_aligned(
|
|
328
392
|
reference,
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
statgen-0.2.0/statgen/_utils.py
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
CANONICAL_CHR_ORDER = [str(i) for i in range(1, 23)] + ["X"]
|
|
2
|
-
CHR_RANK = {c: i for i, c in enumerate(CANONICAL_CHR_ORDER)}
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
def validate_requested_shards(shards, available_labels, where: str) -> list[str]:
|
|
6
|
-
labels = list(available_labels)
|
|
7
|
-
if shards is None:
|
|
8
|
-
return labels
|
|
9
|
-
if isinstance(shards, str):
|
|
10
|
-
raise ValueError(f"{where}: shards must be a non-empty list of unique canonical contig labels")
|
|
11
|
-
|
|
12
|
-
requested = list(shards)
|
|
13
|
-
if not requested:
|
|
14
|
-
raise ValueError(f"{where}: shards must be a non-empty list of unique canonical contig labels")
|
|
15
|
-
|
|
16
|
-
seen = set()
|
|
17
|
-
prev_rank = None
|
|
18
|
-
for label in requested:
|
|
19
|
-
if label not in CHR_RANK:
|
|
20
|
-
raise ValueError(f"{where}: unsupported shard label {label!r}; expected canonical labels 1-22 or X")
|
|
21
|
-
if label in seen:
|
|
22
|
-
raise ValueError(f"{where}: duplicate shard label {label!r} in shards")
|
|
23
|
-
rank = CHR_RANK[label]
|
|
24
|
-
if prev_rank is not None and rank <= prev_rank:
|
|
25
|
-
raise ValueError(f"{where}: shards must be in canonical subsequence order")
|
|
26
|
-
seen.add(label)
|
|
27
|
-
prev_rank = rank
|
|
28
|
-
if label not in labels:
|
|
29
|
-
raise ValueError(f"{where}: requested shard {label!r} is not present")
|
|
30
|
-
return requested
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|