ldclust 0.2.0__py3-none-any.whl

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.
ldclust/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """ldclust: LD-based SNP clustering + haplotype-testing protocol."""
2
+
3
+ from .library import (PROTOCOL_P, DISTANCES, load_ld, load_ld_h5, load_ids,
4
+ drop_nan_snps, distances, spectral_embedding,
5
+ cluster_hdbscan, cluster_optics, cluster_soptics,
6
+ cluster_dbscan, cluster_fof, cluster_louvain,
7
+ spectral_eig, cluster_spectral, cluster_ap,
8
+ cluster_mcl, cluster_lpa, cluster_leiden,
9
+ cluster_walktrap, cluster_infomap, cluster_pam,
10
+ cluster_cnm, cluster_cw, cluster_kmeans,
11
+ cluster_minibatch_kmeans, load_dosage,
12
+ cross_product_matrix, cluster_gmm, cluster_hc,
13
+ dynamic_tree_cut, cluster_dpblocks, cluster_sbm,
14
+ cluster_cdhit, coassociation, cluster_consensus,
15
+ cluster_dpgmm, write_blocks,
16
+ write_hdbscan_file, write_hlist, run_hap_assoc,
17
+ parse_assoc_hap, select_blocks, causal_snps,
18
+ snp_positions, clustering_stats, pairwise_ari)
19
+
20
+ __version__ = "0.2.0"
ldclust/cli.py ADDED
@@ -0,0 +1,431 @@
1
+ """
2
+ ldclust command line: run one clustering method on an LD matrix and
3
+ write the benchmark artifacts (blocks csv, labels npy, hlist for PLINK
4
+ --hap-assoc, and a row in <out-dir>/summary.tsv).
5
+
6
+ Methods:
7
+ hdbscan HDBSCAN on precomputed distances (--dist d1|d2, default d2)
8
+ optics OPTICS on precomputed distances d2 (sklearn)
9
+ soptics OPTICS on the spectral embedding of the r2 matrix
10
+ fof friends-of-friends percolation on r2 >= --eps (no noise label)
11
+ dbscan DBSCAN on precomputed distances d2 with --eps
12
+ louvain modularity communities on the weighted r2 graph
13
+ spectral normalized-affinity spectral clustering (Ng et al. 2002)
14
+ ap affinity propagation on the raw r2 similarity
15
+ mcl Markov clustering on the weighted r2 graph
16
+ lpa asynchronous label propagation on the r2 graph
17
+ leiden Leiden RBConfiguration partition on the r2 graph
18
+ walktrap Walktrap communities on the r2 graph
19
+ infomap Infomap two-level partition on the r2 graph
20
+ pam similarity k-medoids on the raw r2
21
+ cnm greedy modularity (CNM) on the r2 graph
22
+ cw Chinese Whispers on the r2 graph
23
+ gmm Gaussian mixture on the cross-product-matrix embedding
24
+ kmeans K-Means on the Euclidean embedding of 1 - r2
25
+ minibatch Mini-Batch K-Means on the same embedding
26
+ hc agglomerative linkage on 1-r2 + dynamic tree cut (noise = -1)
27
+ dpblocks dynamic-programming contiguous blocks minimizing tag SNPs
28
+ sbm degree-corrected stochastic block model (needs graph-tool;
29
+ run inside its conda environment - see README)
30
+ cdhit CD-HIT-style greedy seed-and-recruit on r2 >= --eps
31
+ consensus evidence-accumulation consensus of existing runs' labels
32
+ (--bases tag1,tag2,... --consensus dtc|fof)
33
+ dpgmm Dirichlet-process GMM on the gmm embedding (--k = cap only)
34
+
35
+ Usage:
36
+ ldclust -p <prefix> --method <name> [--eps 0.5] [--dist d2]
37
+ [--out-dir data/bench] [--check <labels.npy>]
38
+ (prefix names prefix.ld.h5 + prefix.snplist; --method hdbscan|gmm
39
+ also read prefix.gmm.raw via PLINK --recodeA output)
40
+
41
+ Author: Gennady Khvorykh, info@inzilico.com
42
+ Started: 2026-09-21
43
+ """
44
+
45
+ import argparse
46
+ import os
47
+ import time
48
+
49
+ import numpy as np
50
+
51
+ from . import __version__
52
+ from . import library as ld
53
+
54
+
55
+ def build_parser():
56
+ parser = argparse.ArgumentParser(
57
+ prog="ldclust",
58
+ description="LD-based clustering runner (ldclust package)")
59
+ parser.add_argument("-p", "--prefix", required=True,
60
+ help="/path/to/prefix of prefix.ld.h5 file")
61
+ parser.add_argument("--method", required=True,
62
+ choices=["hdbscan", "optics", "soptics", "fof",
63
+ "dbscan", "louvain", "spectral", "ap",
64
+ "mcl", "lpa", "leiden", "walktrap",
65
+ "infomap", "pam", "cnm", "cw", "gmm",
66
+ "kmeans", "minibatch", "hc", "dpblocks",
67
+ "sbm", "cdhit", "consensus", "dpgmm"])
68
+ parser.add_argument("--dist", choices=["d1", "d2"], default="d2")
69
+ parser.add_argument("--eps", type=float, default=None,
70
+ help="threshold for fof and cdhit (r2), dbscan "
71
+ "(distance), consensus-fof (co-association)")
72
+ parser.add_argument("--min-samples", type=int, default=5)
73
+ parser.add_argument("--xi", type=float, default=0.05)
74
+ parser.add_argument("--variance", type=float, default=0.95)
75
+ parser.add_argument("--k", type=int, default=None,
76
+ help="components: soptics embedding dim / pam "
77
+ "blocks / gmm components / dpgmm cap")
78
+ parser.add_argument("--dim", type=int, default=50,
79
+ help="embedding dimension for gmm/dpgmm")
80
+ parser.add_argument("--gamma", type=float, default=None,
81
+ help="resolution for louvain")
82
+ parser.add_argument("--theta", type=float, default=None,
83
+ help="eigenvalue cutoff for spectral "
84
+ "(k = #(w > theta))")
85
+ parser.add_argument("--preference", type=float, default=None,
86
+ help="self-similarity (diagonal) for ap")
87
+ parser.add_argument("--inflation", type=float, default=None,
88
+ help="inflation for mcl")
89
+ parser.add_argument("--cutoff", type=float, default=0.05,
90
+ help="edge cutoff on r2 for graph methods; "
91
+ "consensus bases count; sbm")
92
+ parser.add_argument("--steps", type=int, default=4,
93
+ help="random-walk length for walktrap")
94
+ parser.add_argument("--linkage", choices=["average", "complete"],
95
+ default="average", help="agglomeration for hc")
96
+ parser.add_argument("--min-cluster-size", type=int, default=3,
97
+ help="dynamic tree cut minimum block size for hc")
98
+ parser.add_argument("--deep-split", type=int, default=None,
99
+ choices=[0, 1, 2, 3, 4],
100
+ help="dynamic tree cut deepSplit preset for hc")
101
+ parser.add_argument("--tag-thr", type=float, default=None,
102
+ help="r2 tag-coverage threshold for dpblocks")
103
+ parser.add_argument("--max-len", type=int, default=None,
104
+ help="maximum block length in SNPs for dpblocks")
105
+ parser.add_argument("--no-deg-corr", action="store_true",
106
+ help="plain SBM (no degree correction) for sbm")
107
+ parser.add_argument("--binary", action="store_true",
108
+ help="ignore r2 weights (binary graph) for sbm")
109
+ parser.add_argument("--nested", action="store_true",
110
+ help="nested SBM (level-0 partition) for sbm")
111
+ parser.add_argument("--priority", choices=["degree", "order"],
112
+ default="degree", help="seed priority for cdhit")
113
+ parser.add_argument("--recruit", choices=["seed", "member"],
114
+ default="seed", help="recruit semantics for cdhit")
115
+ parser.add_argument("--bases", default=None,
116
+ help="comma-separated base run tags for consensus "
117
+ "(labels.npy in --out-dir)")
118
+ parser.add_argument("--consensus", choices=["dtc", "fof"], default="dtc",
119
+ help="consensus function on the co-association "
120
+ "matrix")
121
+ parser.add_argument("--conc", type=float, default=None,
122
+ help="DP weight concentration prior for dpgmm "
123
+ "(default: sklearn 1/cap)")
124
+ parser.add_argument("--out-dir", default="data/bench")
125
+ parser.add_argument("--check", default=None,
126
+ help="optional labels.npy to verify determinism")
127
+ parser.add_argument("--version", action="version",
128
+ version="%(prog)s " + __version__)
129
+ return parser
130
+
131
+
132
+ def main(argv=None):
133
+ t1 = time.time()
134
+ parser = build_parser()
135
+ args = parser.parse_args(argv)
136
+
137
+ if args.method == "fof" and args.eps is None:
138
+ parser.error("--eps is required for fof")
139
+ if args.method == "dbscan" and args.eps is None:
140
+ parser.error("--eps is required for dbscan")
141
+ if args.method == "louvain" and args.gamma is None:
142
+ parser.error("--gamma is required for louvain")
143
+ if args.method == "spectral" and args.theta is None:
144
+ parser.error("--theta is required for spectral")
145
+ if args.method == "ap" and args.preference is None:
146
+ parser.error("--preference is required for ap")
147
+ if args.method == "mcl" and args.inflation is None:
148
+ parser.error("--inflation is required for mcl")
149
+ if args.method == "leiden" and args.gamma is None:
150
+ parser.error("--gamma is required for leiden")
151
+ if args.method == "pam" and args.k is None:
152
+ parser.error("--k is required for pam")
153
+ if args.method == "gmm" and args.k is None:
154
+ parser.error("--k is required for gmm")
155
+ if args.method == "kmeans" and args.k is None:
156
+ parser.error("--k is required for kmeans")
157
+ if args.method == "minibatch" and args.k is None:
158
+ parser.error("--k is required for minibatch")
159
+ if args.method == "dpgmm" and args.k is None:
160
+ parser.error("--k (component cap) is required for dpgmm")
161
+ if args.method == "hc" and args.deep_split is None:
162
+ parser.error("--deep-split is required for hc")
163
+ if args.method == "cdhit" and args.eps is None:
164
+ parser.error("--eps is required for cdhit")
165
+ if args.method == "consensus" and not args.bases:
166
+ parser.error("--bases is required for consensus")
167
+ if (args.method == "consensus" and args.consensus == "fof"
168
+ and args.eps is None):
169
+ parser.error("--eps (co-association threshold) is required for "
170
+ "fof consensus")
171
+ if args.method == "dpblocks" and (args.tag_thr is None
172
+ or args.max_len is None):
173
+ parser.error("--tag-thr and --max-len are required for dpblocks")
174
+
175
+ os.makedirs(args.out_dir, exist_ok=True)
176
+ name = os.path.basename(args.prefix)
177
+
178
+ ids, r2, layout = ld.load_ld(args.prefix)
179
+ r2, ids, n_dropped = ld.drop_nan_snps(r2, ids)
180
+ if n_dropped:
181
+ print(f"Dropped {n_dropped} SNPs with NaN")
182
+ print(f"Loaded {ids.size} SNPs ({layout})")
183
+
184
+ substrate = "precomputed-" + args.dist
185
+ if args.method == "hdbscan":
186
+ labels, probs, sec = ld.cluster_hdbscan(ld.distances(r2, args.dist))
187
+ tag = f"hdbscan.{args.dist}"
188
+ elif args.method == "optics":
189
+ labels, probs, sec = ld.cluster_optics(ld.distances(r2, "d2"),
190
+ args.min_samples, args.xi)
191
+ tag = "optics"
192
+ elif args.method == "soptics":
193
+ ksfx = f".emb{args.k}" if args.k else ""
194
+ emb_file = os.path.join(args.out_dir, f"{name}{ksfx}.embedding.npy")
195
+ if os.path.isfile(emb_file):
196
+ coords = np.load(emb_file)
197
+ print(f"Loaded embedding from {emb_file} (k = {coords.shape[1]})")
198
+ else:
199
+ coords, k, _ = ld.spectral_embedding(r2, args.variance, args.k)
200
+ np.save(emb_file, coords)
201
+ print(f"Built spectral embedding (k = {k}), saved to {emb_file}")
202
+ labels, probs, sec = ld.cluster_soptics(coords, args.min_samples,
203
+ args.xi)
204
+ tag = "soptics" + (f"k{args.k}" if args.k else "")
205
+ substrate = "spectral-embedding"
206
+ elif args.method == "fof":
207
+ labels, probs, sec = ld.cluster_fof(r2, args.eps)
208
+ tag = f"fof{args.eps}"
209
+ substrate = "r2-graph"
210
+ elif args.method == "louvain":
211
+ labels, probs, sec = ld.cluster_louvain(r2, args.gamma)
212
+ tag = f"louvain{args.gamma}"
213
+ substrate = "r2-graph"
214
+ elif args.method == "spectral":
215
+ labels, probs, sec = ld.cluster_spectral(r2, args.theta)
216
+ tag = f"spectral{args.theta}"
217
+ substrate = "normalized-affinity"
218
+ elif args.method == "ap":
219
+ labels, probs, sec = ld.cluster_ap(r2, args.preference)
220
+ tag = f"ap{args.preference}"
221
+ substrate = "r2-similarity"
222
+ elif args.method == "mcl":
223
+ labels, probs, sec = ld.cluster_mcl(r2, args.inflation, args.cutoff)
224
+ tag = f"mcl{args.inflation}"
225
+ substrate = "r2-graph"
226
+ elif args.method == "lpa":
227
+ labels, probs, sec = ld.cluster_lpa(r2, args.cutoff)
228
+ tag = "lpa"
229
+ substrate = "r2-graph"
230
+ elif args.method == "leiden":
231
+ labels, probs, sec = ld.cluster_leiden(r2, args.gamma, args.cutoff)
232
+ tag = f"leiden{args.gamma}"
233
+ substrate = "r2-graph"
234
+ elif args.method == "walktrap":
235
+ labels, probs, sec = ld.cluster_walktrap(r2, args.steps, args.cutoff)
236
+ tag = f"walktrap{args.steps}"
237
+ substrate = "r2-graph"
238
+ elif args.method == "infomap":
239
+ labels, probs, sec = ld.cluster_infomap(r2, args.cutoff)
240
+ tag = "infomap"
241
+ substrate = "r2-graph"
242
+ elif args.method == "pam":
243
+ labels, probs, sec = ld.cluster_pam(r2, args.k)
244
+ tag = f"pam{args.k}"
245
+ substrate = "r2-similarity"
246
+ elif args.method == "cnm":
247
+ labels, probs, sec = ld.cluster_cnm(r2, args.cutoff)
248
+ tag = "cnm"
249
+ substrate = "r2-graph"
250
+ elif args.method == "cw":
251
+ labels, probs, sec = ld.cluster_cw(r2, args.cutoff)
252
+ tag = "cw"
253
+ substrate = "r2-graph"
254
+ elif args.method == "gmm":
255
+ # relationship matrix from the genotype dosages, then GMM on
256
+ # its spectral embedding with a full per-component covariance
257
+ gmm_prefix = args.prefix + ".gmm" # genotype prefix must expose .raw
258
+ emb_file = os.path.join(args.out_dir,
259
+ f"{name}.gmm{args.dim}.embedding.npy")
260
+ if os.path.isfile(emb_file):
261
+ coords = np.load(emb_file)
262
+ print(f"Loaded embedding from {emb_file} (d = {coords.shape[1]})")
263
+ else:
264
+ k_grm, raw_snps = ld.cross_product_matrix(gmm_prefix)
265
+ if not np.array_equal(raw_snps, ids):
266
+ print("Cross-product SNP order differs from snplist - "
267
+ "reordering")
268
+ pos_of = {s: i for i, s in enumerate(raw_snps)}
269
+ order = np.array([pos_of[s] for s in ids])
270
+ k_grm = k_grm[np.ix_(order, order)]
271
+ coords, dim_used, _ = ld.spectral_embedding(k_grm, k=args.dim)
272
+ del k_grm
273
+ np.save(emb_file, coords)
274
+ print(f"Built cross-product embedding (d = {dim_used}), "
275
+ f"saved to {emb_file}")
276
+ labels, probs, sec = ld.cluster_gmm(k=args.k, dim=args.dim,
277
+ coords=coords)
278
+ tag = f"gmm{args.k}d{args.dim}"
279
+ substrate = "cross-product-embedding"
280
+ elif args.method == "kmeans":
281
+ # K-Means on the Euclidean embedding of the distance-converted LD
282
+ # matrix (d = 1 - r2 is squared Euclidean; classical MDS coords)
283
+ emb_file = os.path.join(
284
+ args.out_dir, f"{name}.kmeans{args.dim}.embedding.npy")
285
+ if os.path.isfile(emb_file):
286
+ coords = np.load(emb_file)
287
+ print(f"Loaded embedding from {emb_file} (d = {coords.shape[1]})")
288
+ else:
289
+ coords, dim_used, _ = ld.spectral_embedding(r2, k=args.dim)
290
+ np.save(emb_file, coords)
291
+ print(f"Built r2 embedding (d = {dim_used}), saved to {emb_file}")
292
+ labels, probs, sec = ld.cluster_kmeans(r2, args.k, args.dim)
293
+ tag = f"kmeans{args.k}d{args.dim}"
294
+ substrate = "r2-embedding"
295
+ elif args.method == "minibatch":
296
+ # Mini-Batch K-Means on the same embedding as kmeans (shared cache)
297
+ emb_file = os.path.join(
298
+ args.out_dir, f"{name}.kmeans{args.dim}.embedding.npy")
299
+ if os.path.isfile(emb_file):
300
+ coords = np.load(emb_file)
301
+ print(f"Loaded embedding from {emb_file} (d = {coords.shape[1]})")
302
+ else:
303
+ coords, dim_used, _ = ld.spectral_embedding(r2, k=args.dim)
304
+ np.save(emb_file, coords)
305
+ print(f"Built r2 embedding (d = {dim_used}), saved to {emb_file}")
306
+ labels, probs, sec = ld.cluster_minibatch_kmeans(r2, args.k, args.dim)
307
+ tag = f"mbk{args.k}d{args.dim}"
308
+ substrate = "r2-embedding"
309
+ elif args.method == "hc":
310
+ # WGCNA-style: scipy linkage on the condensed d1 = 1 - r2 (the
311
+ # analogue of WGCNA's 1 - |cor|), then the dynamic tree cut
312
+ # (Langfelder et al. 2008, tree stage) on the dendrogram
313
+ labels, probs, sec = ld.cluster_hc(ld.distances(r2, "d1"),
314
+ args.linkage,
315
+ args.min_cluster_size,
316
+ args.deep_split)
317
+ tag = f"dtc{args.deep_split}" + ("" if args.linkage == "average"
318
+ else args.linkage[0])
319
+ substrate = "precomputed-d1"
320
+ elif args.method == "dpblocks":
321
+ # Zhang et al. 2002 DP framework, r2-tagging cost: optimal
322
+ # contiguous partition of the ordered SNPs minimizing total tag
323
+ # SNPs (every SNP covered at r2 >= tag_thr; greedy set cover per
324
+ # candidate block)
325
+ labels, probs, sec = ld.cluster_dpblocks(r2, args.tag_thr,
326
+ args.max_len)
327
+ tag = f"dp{args.tag_thr}L{args.max_len}"
328
+ substrate = "r2-ordered"
329
+ elif args.method == "sbm":
330
+ # degree-corrected SBM on the weighted r2 graph (Peixoto 2017 MDL,
331
+ # graph-tool); run this CLI inside the graph-tool environment
332
+ labels, probs, sec = ld.cluster_sbm(r2, args.cutoff,
333
+ deg_corr=not args.no_deg_corr,
334
+ weighted=not args.binary,
335
+ nested=args.nested)
336
+ tag = ("sbm" + ("nd" if args.no_deg_corr else "")
337
+ + ("b" if args.binary else "w") + str(args.cutoff)
338
+ + ("n" if args.nested else ""))
339
+ substrate = "r2-graph-mdl"
340
+ elif args.method == "cdhit":
341
+ # CD-HIT-style greedy seed-and-recruit: hub SNPs (weighted degree)
342
+ # seed blocks first, recruits need r2 >= eps to the seed (no
343
+ # transitive chaining - the anti-FoF)
344
+ labels, probs, sec = ld.cluster_cdhit(r2, args.eps, args.priority,
345
+ args.recruit)
346
+ tag = (f"cd{args.eps}" + ("o" if args.priority == "order" else "")
347
+ + ("m" if args.recruit == "member" else ""))
348
+ substrate = "r2-similarity"
349
+ elif args.method == "consensus":
350
+ # evidence-accumulation consensus: co-association matrix of the
351
+ # base partitions, then DTC on 1 - C (C is PSD like r2) or
352
+ # percolation
353
+ bases = args.bases.split(",")
354
+ base_labels = [np.load(os.path.join(
355
+ args.out_dir, f"{name}.{b}.labels.npy")) for b in bases]
356
+ ds = args.deep_split if args.deep_split is not None else 2
357
+ labels, probs, sec = ld.cluster_consensus(
358
+ base_labels, method=args.consensus, thr=args.eps, deep_split=ds,
359
+ min_cluster_size=args.min_cluster_size)
360
+ if args.consensus == "dtc":
361
+ tag = f"consdtc{ds}"
362
+ else:
363
+ tag = f"consfof{args.eps:g}"
364
+ substrate = "coassociation-of:" + "+".join(bases)
365
+ elif args.method == "dpgmm":
366
+ # Dirichlet-process GMM on the same cached embedding as gmm; --k is
367
+ # only an upper bound - the DP empties unsupported components
368
+ emb_file = os.path.join(
369
+ args.out_dir, f"{name}.gmm{args.dim}.embedding.npy")
370
+ if os.path.isfile(emb_file):
371
+ coords = np.load(emb_file)
372
+ print(f"Loaded embedding from {emb_file} (d = {coords.shape[1]})")
373
+ else:
374
+ gmm_prefix = args.prefix + ".gmm"
375
+ k_grm, raw_snps = ld.cross_product_matrix(gmm_prefix)
376
+ if not np.array_equal(raw_snps, ids):
377
+ print("Cross-product SNP order differs from snplist - "
378
+ "reordering")
379
+ pos_of = {s: i for i, s in enumerate(raw_snps)}
380
+ order = np.array([pos_of[s] for s in ids])
381
+ k_grm = k_grm[np.ix_(order, order)]
382
+ coords, dim_used, _ = ld.spectral_embedding(k_grm, k=args.dim)
383
+ del k_grm
384
+ np.save(emb_file, coords)
385
+ print(f"Built cross-product embedding (d = {dim_used}), "
386
+ f"saved to {emb_file}")
387
+ labels, probs, sec = ld.cluster_dpgmm(
388
+ coords=coords, n_components=args.k, dim=args.dim,
389
+ weight_concentration_prior=args.conc)
390
+ tag = f"dpgmm{args.k}" + (f"a{args.conc:g}" if args.conc else "adef")
391
+ substrate = "cross-product-embedding"
392
+ elif args.method == "dbscan":
393
+ labels, probs, sec = ld.cluster_dbscan(ld.distances(r2, "d2"),
394
+ args.eps, args.min_samples)
395
+ tag = f"dbscan{args.eps}"
396
+
397
+ stats = ld.clustering_stats(labels)
398
+ print(f"{tag}: {stats['n_blocks']} blocks, noise {stats['noise']:.2%}, "
399
+ f"median size {stats['size_median']}, max {stats['size_max']}, "
400
+ f"{sec:.0f} s")
401
+
402
+ if args.check:
403
+ ref = np.load(args.check)
404
+ if ref.size == labels.size:
405
+ print(f"Determinism check vs {args.check}: "
406
+ f"{int((ref != labels).sum())} label differences")
407
+
408
+ ld.write_blocks(os.path.join(args.out_dir, f"{name}.{tag}.blocks.csv"),
409
+ ids, labels)
410
+ np.save(os.path.join(args.out_dir, f"{name}.{tag}.labels.npy"), labels)
411
+ ld.write_hlist(os.path.join(args.out_dir, f"{name}.{tag}.hlist"),
412
+ ids, labels)
413
+
414
+ row = {"method": args.method, "tag": tag, "substrate": substrate,
415
+ "params": f"eps={args.eps};min_samples={args.min_samples};"
416
+ f"xi={args.xi};dist={args.dist}", **stats,
417
+ "seconds": sec}
418
+ summary = os.path.join(args.out_dir, "summary.tsv")
419
+ header = not os.path.isfile(summary)
420
+ with open(summary, "a") as f:
421
+ if header:
422
+ f.write("\t".join(row.keys()) + "\n")
423
+ f.write("\t".join(str(v) for v in row.values()) + "\n")
424
+ print("Appended summary row to", summary)
425
+ dur = time.strftime("%H:%M:%S", time.gmtime(time.time() - t1))
426
+ print(f"Done {tag}, time elapsed: {dur}")
427
+ return 0
428
+
429
+
430
+ if __name__ == "__main__":
431
+ raise SystemExit(main())