agglovar 0.0.1.dev16__tar.gz → 0.0.1.dev18__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.
Files changed (59) hide show
  1. {agglovar-0.0.1.dev16/src/agglovar.egg-info → agglovar-0.0.1.dev18}/PKG-INFO +2 -2
  2. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/pyproject.toml +15 -6
  3. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/__init__.py +7 -1
  4. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/align/op.py +5 -1
  5. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/align/score.py +14 -3
  6. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/bed/col.py +12 -2
  7. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/bed/intersect.py +33 -4
  8. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/bed/join.py +85 -12
  9. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/bed/merge.py +21 -4
  10. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/dev/imports.py +12 -2
  11. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/expr/variant.py +41 -1
  12. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/fa.py +7 -1
  13. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/io.py +11 -2
  14. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/kmer/plot.py +5 -1
  15. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/kmer/util.py +9 -1
  16. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/merge/base.py +26 -17
  17. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/merge/config/parser.py +4 -1
  18. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/merge/config/stage.py +10 -1
  19. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/merge/config/strategy.py +10 -1
  20. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/merge/cumulative.py +238 -44
  21. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/meta/decorators.py +3 -0
  22. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/meta/descriptors.py +13 -4
  23. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/base.py +39 -3
  24. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/overlap/__init__.py +36 -0
  25. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/overlap/_const.py +11 -0
  26. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/overlap/_overlap.py +520 -79
  27. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/overlap/_stage.py +16 -1
  28. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/weights.py +14 -2
  29. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/seqmatch.py +4 -2
  30. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/util/lazy.py +3 -0
  31. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/util/str.py +8 -1
  32. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/util/var.py +4 -0
  33. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/vcf/_vcf_const.py +3 -0
  34. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/vcf/_vcf_header.py +21 -4
  35. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/vcf/_vcf_read.py +69 -25
  36. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/vcf/_vcf_write.py +3 -0
  37. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18/src/agglovar.egg-info}/PKG-INFO +2 -2
  38. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar.egg-info/requires.txt +1 -1
  39. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/LICENSE +0 -0
  40. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/README.md +0 -0
  41. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/setup.cfg +0 -0
  42. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/align/__init__.py +0 -0
  43. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/bed/__init__.py +0 -0
  44. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/dev/__init__.py +0 -0
  45. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/expr/__init__.py +0 -0
  46. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/kmer/__init__.py +0 -0
  47. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/merge/__init__.py +0 -0
  48. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/merge/config/__init__.py +0 -0
  49. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/merge/om/__init__.py +0 -0
  50. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/merge/om/_executor_base.py +0 -0
  51. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/merge/om/_executor_nr.py +0 -0
  52. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/meta/__init__.py +0 -0
  53. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/__init__.py +0 -0
  54. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/schema.py +0 -0
  55. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/util/__init__.py +0 -0
  56. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar/vcf/__init__.py +0 -0
  57. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar.egg-info/SOURCES.txt +0 -0
  58. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar.egg-info/dependency_links.txt +0 -0
  59. {agglovar-0.0.1.dev16 → agglovar-0.0.1.dev18}/src/agglovar.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agglovar
3
- Version: 0.0.1.dev16
3
+ Version: 0.0.1.dev18
4
4
  Summary: Toolkit for fast genomic variant transformations and intersects
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.12
@@ -10,7 +10,7 @@ Requires-Dist: biopython>=1.85
10
10
  Requires-Dist: edlib>=1.3.9.post1
11
11
  Requires-Dist: numpy>=2.3.0
12
12
  Requires-Dist: ply>=3.11
13
- Requires-Dist: polars[numpy,pandas,pyarrow]>=1.35.2
13
+ Requires-Dist: polars[numpy,pandas,pyarrow]==1.42.1
14
14
  Requires-Dist: pysam>=0.23.3
15
15
  Provides-Extra: plot
16
16
  Requires-Dist: matplotlib>=3.10.3; extra == "plot"
@@ -15,7 +15,9 @@ dependencies = [
15
15
  "edlib>=1.3.9.post1",
16
16
  "numpy>=2.3.0",
17
17
  "ply>=3.11",
18
- "polars[numpy,pandas,pyarrow]>=1.35.2",
18
+ # 1.41.2: merge_sorted() crashes on List(Struct) columns before this (used by the cumulative
19
+ # merge to carry the complex-variant "seg" column).
20
+ "polars[numpy,pandas,pyarrow]==1.42.1",
19
21
  "pysam>=0.23.3",
20
22
  ]
21
23
 
@@ -52,11 +54,10 @@ maintainers = [
52
54
 
53
55
  [dependency-groups]
54
56
  dev = [
55
- "arrow[parquet]>=1.4.0",
56
57
  "flake8>=7.3.0",
57
58
  "flake8-bugbear>=24.12.12",
58
59
  "flake8-docstrings>=1.7.0",
59
- "flake8-dunder-all>=0.5.0",
60
+ "flake8-dunder-all>=0.6.0",
60
61
  "flake8-mutable>=1.2.0",
61
62
  "flake8-pyproject>=1.2.3",
62
63
  "flake8-rst-docstrings>=0.3.1",
@@ -107,9 +108,17 @@ max-line-length = 120
107
108
  [tool.flake8]
108
109
  max-line-length = 120
109
110
  docstring-convention = "pep257"
111
+ # DALL100/DALL101 (flake8-dunder-all >= 0.6.0) require a top-level PEP 562 __dir__ in every module.
112
+ # This package has no module-level __getattr__ to justify one, and a __dir__ returning only __all__
113
+ # would break agglovar.dev.imports.librl, which walks dir(module) to build its reload graph.
114
+ #
115
+ # These cannot go in extend-ignore: flake8 validates those codes against '^[A-Z]{1,3}[0-9]{0,3}$'
116
+ # and the 4-letter DALL prefix fails. per-file-ignores skips that check, but selects the single
117
+ # longest matching pattern rather than unioning them, so the codes must be repeated in every entry.
110
118
  per-file-ignores = [
111
- "src/agglovar/merge/config/parser.py: D200, D205, D212, D300, D400, D401, D403, D415",
112
- "src/agglovar/vcf/__init__.py: DALL002, F401, F403",
113
- "src/agglovar/pairwise/overlap/__init__.py: DALL002, F401, F403",
119
+ "*: DALL100, DALL101",
120
+ "src/agglovar/merge/config/parser.py: DALL100, DALL101, D200, D205, D212, D300, D400, D401, D403, D415",
121
+ "src/agglovar/vcf/__init__.py: DALL100, DALL101, DALL002, F401, F403",
122
+ "src/agglovar/pairwise/overlap/__init__.py: DALL100, DALL101, DALL002, F401, F403",
114
123
  ]
115
124
  rst-roles = ["class", "meth", "const", "ref", "func", "mod", "attr", "data"]
@@ -1,6 +1,6 @@
1
1
  """Agglovar: A toolkit for fast genomic variant transformations and intersects."""
2
2
 
3
- __version__ = '0.0.1.dev16'
3
+ __version__ = '0.0.1.dev18'
4
4
 
5
5
  __all__ = [
6
6
  'align',
@@ -29,3 +29,9 @@ from . import schema
29
29
  from . import seqmatch
30
30
  from . import vcf
31
31
  from . import util
32
+
33
+
34
+ # Configure logging
35
+
36
+ import logging
37
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
@@ -42,10 +42,14 @@ __all__ = [
42
42
  ]
43
43
 
44
44
 
45
- import numpy as np
45
+ import logging
46
46
  from types import MappingProxyType
47
47
  from typing import Mapping
48
48
 
49
+ import numpy as np
50
+
51
+ logger = logging.getLogger(__name__)
52
+
49
53
  INT_STR_SET: frozenset[str] = frozenset({'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', })
50
54
  """Set of valid integer character strings representing operation codes."""
51
55
 
@@ -12,13 +12,24 @@ __all__ = [
12
12
  'get_affine_by_params',
13
13
  ]
14
14
 
15
- from abc import ABC, abstractmethod
16
- import numpy as np
15
+ from abc import (
16
+ ABC,
17
+ abstractmethod,
18
+ )
19
+ import logging
17
20
  import re
18
- from typing import Any, Iterable, Self
21
+ from typing import (
22
+ Any,
23
+ Iterable,
24
+ Self,
25
+ )
26
+
27
+ import numpy as np
19
28
 
20
29
  from . import op
21
30
 
31
+ logger = logging.getLogger(__name__)
32
+
22
33
  AFFINE_SCORE_MATCH = 2.0
23
34
  """Default match score."""
24
35
 
@@ -7,13 +7,23 @@ __all__ = [
7
7
  'standardize',
8
8
  ]
9
9
 
10
- from collections.abc import Iterable, Container
10
+ from collections.abc import (
11
+ Iterable,
12
+ Container,
13
+ )
11
14
  from dataclasses import dataclass
15
+ import logging
12
16
  import re
13
- from typing import Optional, Self, Any
17
+ from typing import (
18
+ Optional,
19
+ Self,
20
+ Any,
21
+ )
14
22
 
15
23
  import polars as pl
16
24
 
25
+ logger = logging.getLogger(__name__)
26
+
17
27
 
18
28
  @dataclass(frozen=True)
19
29
  class CoordColExpr(Iterable[pl.Expr]):
@@ -1,19 +1,32 @@
1
1
  """Table intersects."""
2
2
 
3
+ import logging
3
4
  from pathlib import Path
4
- from typing import Iterable, Optional
5
+ from typing import (
6
+ Iterable,
7
+ Optional,
8
+ )
5
9
 
6
10
  import polars as pl
7
11
 
8
- from .join import pairwise_join, pairwise_join_iter
12
+ from .col import (
13
+ CoordCol,
14
+ get_coord_cols,
15
+ )
16
+ from .join import (
17
+ CHUNK_SIZE,
18
+ pairwise_join,
19
+ pairwise_join_iter,
20
+ )
9
21
  from .merge import merge_depth
10
- from .col import CoordCol, get_coord_cols
11
22
 
12
23
  __all__ = [
13
24
  'as_bool',
14
25
  'as_proportion',
15
26
  ]
16
27
 
28
+ logger = logging.getLogger(__name__)
29
+
17
30
 
18
31
  def as_bool(
19
32
  df_a: pl.LazyFrame | pl.DataFrame,
@@ -24,6 +37,7 @@ def as_bool(
24
37
  col_names_a: Optional[CoordCol | Iterable[str] | str] = None,
25
38
  col_names_b: Optional[CoordCol | Iterable[str] | str] = None,
26
39
  temp_dir: bool | str | Path = False,
40
+ chunk_size: int = CHUNK_SIZE,
27
41
  ) -> pl.LazyFrame:
28
42
  """Add a boolean column to df_a indicating whether each record intersects with df_b.
29
43
 
@@ -36,6 +50,8 @@ def as_bool(
36
50
  :param col_names_b: Columns in b (chromosome or query ID, pos, end).
37
51
  :param temp_dir: How to materialise the prepared tables before iterating. See
38
52
  :func:`agglovar.bed.join.pairwise_join`.
53
+ :param chunk_size: Chunk A by this size per chromosome. See
54
+ :func:`agglovar.bed.join.pairwise_join`.
39
55
 
40
56
  :return: A LazyFrame with two columns: ``_index`` and ``name``.
41
57
  """
@@ -53,12 +69,15 @@ def as_bool(
53
69
 
54
70
  hit_val = not negate
55
71
 
72
+ logger.debug('as_bool: name=%r distance=%d negate=%s' % (name, distance, negate))
73
+
56
74
  join_list = []
57
75
 
58
76
  for df_join in pairwise_join_iter(
59
77
  df_a=df_a,
60
78
  df_b=df_b,
61
79
  distance=distance,
80
+ chunk_size=chunk_size,
62
81
  col_names_a=col_names_a,
63
82
  col_names_b=col_names_b,
64
83
  temp_dir=temp_dir,
@@ -70,6 +89,8 @@ def as_bool(
70
89
  .lazy()
71
90
  )
72
91
 
92
+ logger.debug('as_bool: collected %d non-empty join chunk(s)' % len(join_list))
93
+
73
94
  if '_index' in df_a.collect_schema().names():
74
95
  df_a_indexed = df_a
75
96
  else:
@@ -107,6 +128,7 @@ def as_proportion(
107
128
  col_names_a: Optional[CoordCol | Iterable[str] | str] = None,
108
129
  col_names_b: Optional[CoordCol | Iterable[str] | str] = None,
109
130
  temp_dir: bool | str | Path = False,
131
+ chunk_size: int = CHUNK_SIZE,
110
132
  ) -> pl.LazyFrame:
111
133
  """Compute the proportion of each interval in ``df_a`` covered by intervals in ``df_b``.
112
134
 
@@ -114,12 +136,14 @@ def as_proportion(
114
136
  proportion. Zero-length intervals (``pos == end``) produce ``NaN`` (0 / 0).
115
137
 
116
138
  :param df_a: Table a.
117
- :param df_b: Table b.
139
+ :param df_b: Table b.`
118
140
  :param name: Name of the column to add.
119
141
  :param col_names_a: Columns in a (chromosome or query ID, pos, end).
120
142
  :param col_names_b: Columns in b (chromosome or query ID, pos, end).
121
143
  :param temp_dir: How to materialise the prepared tables before iterating. See
122
144
  :func:`agglovar.bed.join.pairwise_join`.
145
+ :param chunk_size: Chunk A by this size per chromosome. See
146
+ :func:`agglovar.bed.join.pairwise_join`.
123
147
 
124
148
  :return: A LazyFrame with two columns: ``_index`` and ``name``.
125
149
  """
@@ -131,6 +155,8 @@ def as_proportion(
131
155
 
132
156
  col_expr_a = col_names_a.exprs()
133
157
 
158
+ logger.debug('as_proportion: name=%r' % name)
159
+
134
160
  if isinstance(df_a, pl.DataFrame):
135
161
  df_a = df_a.lazy()
136
162
 
@@ -153,9 +179,12 @@ def as_proportion(
153
179
  .collect()
154
180
  )
155
181
 
182
+ logger.debug('as_proportion: %d non-redundant B region(s) after merge_depth' % df_b_nr.height)
183
+
156
184
  df_join = pairwise_join(
157
185
  df_a=df_a_clean,
158
186
  df_b=df_b_nr,
187
+ chunk_size=chunk_size,
159
188
  col_names_a=col_names_a,
160
189
  col_names_b=col_names_b,
161
190
  temp_dir=temp_dir,
@@ -5,16 +5,34 @@ __all__ = [
5
5
  'pairwise_join_iter',
6
6
  ]
7
7
 
8
+ import logging
8
9
  from pathlib import Path
9
- from typing import Iterable, Iterator, Optional
10
+ import time
11
+ from typing import (
12
+ Iterable,
13
+ Iterator,
14
+ Optional,
15
+ )
10
16
 
11
17
  import polars as pl
12
18
 
13
- from .col import CoordCol, get_coord_cols
14
19
  from ..util.lazy import materialize_pair
15
20
 
16
- CHUNK_SIZE: int = 2_500
17
- """Default size of join chunks. Breaks up tables into batches of this size or less."""
21
+ from .col import (
22
+ CoordCol,
23
+ get_coord_cols,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ CHUNK_SIZE: int = 25_000
29
+ """
30
+ Default number of df_a records per chunk. Table A is processed one chromosome at a time and split
31
+ into chunks of this size or less; each chunk drives one ``join_where`` (IEJoin) against the B
32
+ records overlapping the chunk's position span. Larger chunks amortize the fixed per-chunk overhead
33
+ (query setup and the per-chunk B pre-filter) over more rows, but widen each chunk's span so more B
34
+ records enter each join. Tune based on available RAM.
35
+ """
18
36
 
19
37
 
20
38
  class _JoinResources:
@@ -183,6 +201,11 @@ def pairwise_join_iter(
183
201
  col_names_b=col_names_b,
184
202
  )
185
203
 
204
+ logger.debug(
205
+ 'Starting bed join: distance=%d chunk_size=%d temp_dir=%r cols_a=%s cols_b=%s'
206
+ % (distance, chunk_size, temp_dir, tuple(resources.col_a), tuple(resources.col_b))
207
+ )
208
+
186
209
  with materialize_pair(
187
210
  resources.df_a, resources.df_b, temp_dir, prefix='bed_join_prep_',
188
211
  ) as (df_a_mat, df_b_mat):
@@ -262,6 +285,10 @@ def _join_chunks(
262
285
  col_a = join_resources.col_a
263
286
  col_b = join_resources.col_b
264
287
 
288
+ # Per-chunk timing and the df_b survivor count require extra collects, so gate them on DEBUG:
289
+ # an INFO-level run pays nothing and never materializes the diagnostics.
290
+ debug = logger.isEnabledFor(logging.DEBUG)
291
+
265
292
  # Restrict the chrom loop to chroms present in both tables. Chroms unique to A
266
293
  # would otherwise drive a wasted A chunk loop with no possible matches.
267
294
  for chrom, last_index_a in (
@@ -275,11 +302,30 @@ def _join_chunks(
275
302
  )
276
303
  .sort(col_a.chrom)
277
304
  ).collect().rows():
305
+ # Collect this chromosome's A and B once into memory. The chunk loop below re-filters
306
+ # df_a_chrom (per index range) and df_b_chrom (per prefilter bounds) many times; leaving
307
+ # them lazy re-runs the chrom filter over the full materialized df_a/df_b on every chunk,
308
+ # which dominates runtime (fixed per-chunk cost independent of the join size). Collecting
309
+ # per chrom makes the per-chunk filters operate on the small per-chrom frame instead.
310
+ # Mirrors agglovar.pairwise.overlap._overlap._join_iter_chunked.
278
311
  df_a_chrom = (
279
312
  df_a.filter(pl.col(col_a.chrom) == chrom)
280
313
  .with_row_index('_index_chrom_a')
314
+ .collect()
315
+ .lazy()
316
+ )
317
+ df_b_chrom = (
318
+ df_b.filter(pl.col(col_b.chrom) == chrom)
319
+ .collect()
320
+ .lazy()
281
321
  )
282
- df_b_chrom = df_b.filter(pl.col(col_b.chrom) == chrom)
322
+
323
+ if debug:
324
+ chrom_t0 = time.perf_counter()
325
+ chrom_chunks = 0
326
+ chrom_pairs = 0
327
+ chrom_join_s = 0.0
328
+ chrom_b_max = 0
283
329
 
284
330
  start_index_a = 0
285
331
  while start_index_a < last_index_a:
@@ -312,12 +358,39 @@ def _join_chunks(
312
358
  pl.col(col_b.end) + distance >= pos_min,
313
359
  )
314
360
 
315
- yield _build_join_pair(
316
- df_a_chunk,
317
- df_b_chunk,
318
- distance,
319
- col_a,
320
- col_b,
321
- ).collect().lazy()
361
+ # The join itself is computed identically regardless of log level; only the diagnostics
362
+ # below are gated on DEBUG, so raising the log level cannot change the result.
363
+ if debug:
364
+ t0 = time.perf_counter()
322
365
 
366
+ df_join = _build_join_pair(df_a_chunk, df_b_chunk, distance, col_a, col_b).collect()
367
+
368
+ if debug:
369
+ dt = time.perf_counter() - t0
370
+ # n_b is the count of B records surviving the range prefilter for this chunk. When
371
+ # A is position-local it stays small; when it approaches the chrom's B total the
372
+ # prefilter is not pruning. pos_min-end_max is the chunk's position span.
373
+ n_b = df_b_chunk.select(pl.len()).collect().item()
374
+
375
+ logger.debug(
376
+ 'Join chunk: chrom %s [%d:%d] pos=%s-%s n_b=%d pairs=%d in %.3fs'
377
+ % (chrom, start_index_a, end_index_a, pos_min, end_max,
378
+ n_b, df_join.height, dt)
379
+ )
380
+
381
+ chrom_chunks += 1
382
+ chrom_pairs += df_join.height
383
+ chrom_join_s += dt
384
+ if n_b > chrom_b_max:
385
+ chrom_b_max = n_b
386
+
387
+ yield df_join.lazy()
323
388
  start_index_a = end_index_a
389
+
390
+ if debug:
391
+ logger.debug(
392
+ 'Join chrom done: chrom %s n_a=%d chunks=%d n_b_max=%d pairs=%d '
393
+ 'join=%.3fs wall=%.3fs'
394
+ % (chrom, last_index_a, chrom_chunks, chrom_b_max, chrom_pairs,
395
+ chrom_join_s, time.perf_counter() - chrom_t0)
396
+ )
@@ -4,11 +4,21 @@ __all__ = [
4
4
  'merge_depth',
5
5
  ]
6
6
 
7
- from typing import Optional, Iterable
7
+ import logging
8
+ from typing import (
9
+ Optional,
10
+ Iterable,
11
+ )
8
12
 
9
13
  import polars as pl
10
14
 
11
- from .col import CoordCol, get_coord_cols, make_unique_col
15
+ from .col import (
16
+ CoordCol,
17
+ get_coord_cols,
18
+ make_unique_col,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
12
22
 
13
23
 
14
24
  def merge_depth(
@@ -16,7 +26,7 @@ def merge_depth(
16
26
  distance: int = 0,
17
27
  col_names: Optional[CoordCol | Iterable[str] | str] = None,
18
28
  col_max_depth: Optional[str] = 'max_depth'
19
- ):
29
+ ) -> pl.LazyFrame:
20
30
  """Merge overlapping regions in a table.
21
31
 
22
32
  :param df: Table of coordinates.
@@ -38,6 +48,11 @@ def merge_depth(
38
48
  if isinstance(df, pl.DataFrame):
39
49
  df = df.lazy()
40
50
 
51
+ logger.debug(
52
+ 'merge_depth: distance=%d cols=%s max_depth=%r'
53
+ % (distance, tuple(col_names), col_max_depth)
54
+ )
55
+
41
56
  return (
42
57
  melt_depth(
43
58
  df=df.with_columns(pl.col(col_names.end) + distance),
@@ -65,7 +80,7 @@ def melt_depth(
65
80
  col_names: CoordCol,
66
81
  col_loc: str = '_loc',
67
82
  col_depth: str = '_depth',
68
- ):
83
+ ) -> pl.LazyFrame:
69
84
  """Get a table of depths by locations of changes.
70
85
 
71
86
  The table will have one row for each location of change with end positions having two entries (one for the
@@ -148,6 +163,8 @@ def melt_depth(
148
163
 
149
164
  assert col_loc != col_depth, 'Column names "%s" and "%s" are the same' % (col_loc, col_depth)
150
165
 
166
+ logger.debug('melt_depth: cols=%s loc=%s depth=%s' % (tuple(col_names), col_loc, col_depth))
167
+
151
168
  return (
152
169
  pl.concat(
153
170
  [
@@ -11,14 +11,24 @@ __all__ = [
11
11
 
12
12
  import ast
13
13
  from collections.abc import Iterable
14
- from dataclasses import dataclass, field
14
+ from dataclasses import (
15
+ dataclass,
16
+ field,
17
+ )
15
18
  from graphlib import TopologicalSorter
16
19
  import importlib
17
20
  import inspect
21
+ import logging
18
22
  import re
19
23
  import sys
20
24
  from types import ModuleType
21
- from typing import Any, Generator, Optional
25
+ from typing import (
26
+ Any,
27
+ Generator,
28
+ Optional,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
22
32
 
23
33
 
24
34
  @dataclass(frozen=True)
@@ -6,13 +6,19 @@ __all__ = [
6
6
  'sort_cols',
7
7
  'id_expr_noalias',
8
8
  'id_expr',
9
+ 'id_expr_for_table',
9
10
  'lead_src_expr',
10
11
  ]
11
12
 
13
+ import logging
12
14
  from typing import Optional
13
15
 
14
16
  import polars as pl
15
17
 
18
+ import agglovar.schema as _agg_schema
19
+
20
+ logger = logging.getLogger(__name__)
21
+
16
22
  id_snv_expr = (
17
23
  pl.concat_str(
18
24
  pl.col('chrom'),
@@ -61,7 +67,41 @@ id_expr_noalias = (
61
67
  """Expression for generating variant IDs."""
62
68
 
63
69
  id_expr = id_expr_noalias.alias('id')
64
- """Expression for generating variant IDs including alias "id" for the result (ideal for `with_column()`)."""
70
+ """Expression for generating variant IDs including alias "id" for the result (ideal for `with_column()`).
71
+
72
+ Requires both the SNV columns (``ref``, ``alt``) and the non-SNV column (``varlen``) to be present:
73
+ Polars resolves both branches of the expression regardless of which one a row selects. Use
74
+ :func:`id_expr_for_table` for single-type tables, which carry only the columns listed in
75
+ :data:`agglovar.schema.STANDARD_FIELDS` for their type.
76
+ """
77
+
78
+
79
+ def id_expr_for_table(table: str, alias: Optional[str] = 'id') -> pl.Expr:
80
+ """Get a variant ID expression for a single variant-type table.
81
+
82
+ Single-type tables carry only the columns listed for their type in
83
+ :data:`agglovar.schema.STANDARD_FIELDS`, so :data:`id_expr` cannot be applied to them (see its
84
+ documentation). This routine returns the one branch that matches the table's columns.
85
+
86
+ :param table: Table name from :data:`agglovar.schema.STANDARD_FIELDS` (e.g. "snv", "insdel").
87
+ :param alias: Output column name for the resulting IDs, or `None` to leave it unaliased.
88
+
89
+ :returns: An expression yielding variant IDs for *table*.
90
+
91
+ :raises ValueError: If *table* is not a known variant-type table.
92
+ """
93
+ fields = _agg_schema.STANDARD_FIELDS.get(table)
94
+
95
+ if fields is None:
96
+ raise ValueError(
97
+ f'Unknown variant-type table: {table!r}: '
98
+ f'expected one of {", ".join(sorted(_agg_schema.STANDARD_FIELDS))}'
99
+ )
100
+
101
+ # Tables without "varlen" (SNV) are identified by their changed bases, all others by length
102
+ expr = id_nonsnv_expr if 'varlen' in fields else id_snv_expr
103
+
104
+ return expr.alias(alias) if alias is not None else expr
65
105
 
66
106
 
67
107
  def lead_src_expr(
@@ -6,14 +6,20 @@ __all__ = [
6
6
  ]
7
7
 
8
8
  import hashlib
9
+ import logging
9
10
  from pathlib import Path
10
- from typing import Iterable, Optional
11
+ from typing import (
12
+ Iterable,
13
+ Optional,
14
+ )
11
15
 
12
16
  import Bio.SeqIO
13
17
  import polars as pl
14
18
 
15
19
  from . import io
16
20
 
21
+ logger = logging.getLogger(__name__)
22
+
17
23
 
18
24
  def read_fai(
19
25
  fai_file_name: str | Path,
@@ -4,10 +4,19 @@ __all__ = [
4
4
  'PlainOrGzReader',
5
5
  ]
6
6
 
7
- from dataclasses import dataclass, field
7
+ from dataclasses import (
8
+ dataclass,
9
+ field,
10
+ )
8
11
  import gzip
12
+ import logging
9
13
  from pathlib import Path
10
- from typing import Any, Optional
14
+ from typing import (
15
+ Any,
16
+ Optional,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
11
20
 
12
21
 
13
22
  @dataclass
@@ -5,9 +5,11 @@ __all__ = [
5
5
  ]
6
6
 
7
7
  import collections
8
- import numpy as np
8
+ import logging
9
9
  from typing import Any
10
10
 
11
+ import numpy as np
12
+
11
13
  from . import util as kmer_util
12
14
 
13
15
  # Get type hints for optional matplotlib objects
@@ -22,6 +24,8 @@ try:
22
24
  except ImportError:
23
25
  pass
24
26
 
27
+ logger = logging.getLogger(__name__)
28
+
25
29
 
26
30
  def dotplot(
27
31
  seq_x: str,
@@ -12,12 +12,20 @@ __all__ = [
12
12
  'stream_index',
13
13
  ]
14
14
 
15
+ import logging
15
16
  import math
16
17
  from types import MappingProxyType
17
- from typing import Self, Optional, Iterator, Mapping
18
+ from typing import (
19
+ Self,
20
+ Optional,
21
+ Iterator,
22
+ Mapping,
23
+ )
18
24
 
19
25
  import numpy as np
20
26
 
27
+ logger = logging.getLogger(__name__)
28
+
21
29
  # Integer to base
22
30
  INT_TO_BASE: list[str] = ['A', 'C', 'G', 'T']
23
31
  """Maps 2-bit integer to base string."""