polyform-lattice 0.1.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.
polyform/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishes on every pushed tag that looks like a version, e.g. v0.1.0
4
+ on:
5
+ push:
6
+ tags:
7
+ - "v*"
8
+
9
+ jobs:
10
+ build:
11
+ name: Build distributions
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.12"
19
+
20
+ - name: Build sdist and wheel
21
+ run: |
22
+ python -m pip install --upgrade build
23
+ python -m build
24
+
25
+ - name: Check metadata
26
+ run: |
27
+ python -m pip install --upgrade twine
28
+ python -m twine check dist/*
29
+
30
+ - uses: actions/upload-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+
35
+ publish:
36
+ name: Publish to PyPI
37
+ needs: build
38
+ runs-on: ubuntu-latest
39
+ environment: pypi
40
+ permissions:
41
+ id-token: write # required for Trusted Publishing (OIDC); no API token needed
42
+ steps:
43
+ - uses: actions/download-artifact@v4
44
+ with:
45
+ name: dist
46
+ path: dist/
47
+
48
+ - name: Publish
49
+ uses: pypa/gh-action-pypi-publish@release/v1
polyform/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """
2
+ Command-line interface for PolyForm.
3
+
4
+ polyform support --csv proteoforms.csv --outdir out_support
5
+ polyform weight --csv proteoforms.csv --outdir out_weight
6
+ polyform topology --xlsx atlas.xlsx --outdir out_topology
7
+
8
+ Each subcommand runs the corresponding analysis, writes CSVs (and, unless
9
+ ``--no-figures`` is passed, figures) to ``--outdir``, and prints a short
10
+ summary of the result tables.
11
+ """
12
+ import argparse
13
+ import sys
14
+
15
+ from . import __version__
16
+
17
+
18
+ def _summarise(results):
19
+ for key, df in results.items():
20
+ try:
21
+ print(f" {key:28s} {len(df):>8d} rows x {len(df.columns):>3d} cols")
22
+ except Exception:
23
+ print(f" {key:28s} {type(df).__name__}")
24
+
25
+
26
+ def main(argv=None):
27
+ argv = list(sys.argv[1:] if argv is None else argv)
28
+
29
+ parser = argparse.ArgumentParser(
30
+ prog="polyform",
31
+ description="Structured analysis of proteoform distributions on modal lattices.",
32
+ )
33
+ parser.add_argument("--version", action="version",
34
+ version=f"polyform {__version__}")
35
+ sub = parser.add_subparsers(dest="command", required=True)
36
+
37
+ # support --------------------------------------------------------------
38
+ p_sup = sub.add_parser("support", help="global support-mode structural metrics")
39
+ p_sup.add_argument("--csv", default="proteoforms.csv",
40
+ help="proteoform catalogue CSV (default: proteoforms.csv)")
41
+ p_sup.add_argument("--fasta", default=None,
42
+ help="optional UniProt FASTA to resolve lengths")
43
+ p_sup.add_argument("--pos-base", type=int, default=0,
44
+ help="set to 1 if RESID positions are 1-based")
45
+ p_sup.add_argument("--outdir", default="polyform_support_outputs")
46
+ p_sup.add_argument("--dpi", type=int, default=300)
47
+ p_sup.add_argument("--no-figures", action="store_true")
48
+
49
+ # weight ---------------------------------------------------------------
50
+ p_w = sub.add_parser("weight", help="demonstration occupancy metrics")
51
+ p_w.add_argument("--csv", default="proteoforms.csv")
52
+ p_w.add_argument("--fasta", default=None)
53
+ p_w.add_argument("--pos-base", type=int, default=0)
54
+ p_w.add_argument("--outdir", default="polyform_02_demo_occupancy_outputs")
55
+ p_w.add_argument("--select-n", type=int, default=8)
56
+ p_w.add_argument("--min-states", type=int, default=5)
57
+ p_w.add_argument("--min-delta-k", type=int, default=3)
58
+ p_w.add_argument("--dpi", type=int, default=300)
59
+ p_w.add_argument("--seed", type=int, default=7)
60
+ p_w.add_argument("--no-figures", action="store_true")
61
+
62
+ # topology -------------------------------------------------------------
63
+ p_t = sub.add_parser("topology", help="tissue-resolved support topology")
64
+ p_t.add_argument("--xlsx", default="pr2c00034_si_002 (1).xlsx",
65
+ help="multi-tissue atlas spreadsheet")
66
+ p_t.add_argument("--outdir", default="polyform_03_tissue_support_topology_outputs")
67
+ p_t.add_argument("--sheet", default="All_Tissues")
68
+ p_t.add_argument("--no-group-by-sequence", action="store_true")
69
+ p_t.add_argument("--no-k0-anchor", action="store_true")
70
+ p_t.add_argument("--dpi", type=int, default=300)
71
+ p_t.add_argument("--seed", type=int, default=7)
72
+ p_t.add_argument("--no-figures", action="store_true")
73
+
74
+ args = parser.parse_args(argv)
75
+
76
+ if args.command == "support":
77
+ from .support_mode import run_support_mode
78
+ results = run_support_mode(
79
+ csv=args.csv, fasta_path=args.fasta, pos_base=args.pos_base,
80
+ outdir=args.outdir, make_figures=not args.no_figures, dpi=args.dpi,
81
+ )
82
+ elif args.command == "weight":
83
+ from .weight_mode import run_weight_mode
84
+ results = run_weight_mode(
85
+ csv=args.csv, fasta_path=args.fasta, pos_base=args.pos_base,
86
+ outdir=args.outdir, select_n=args.select_n, min_states=args.min_states,
87
+ min_delta_k=args.min_delta_k, make_figures=not args.no_figures,
88
+ dpi=args.dpi, random_seed=args.seed,
89
+ )
90
+ elif args.command == "topology":
91
+ from .topology import run_topology
92
+ results = run_topology(
93
+ xlsx=args.xlsx, outdir=args.outdir, sheet_name=args.sheet,
94
+ group_by_sequence=not args.no_group_by_sequence,
95
+ add_k0_anchor=not args.no_k0_anchor,
96
+ make_figures=not args.no_figures, dpi=args.dpi, random_seed=args.seed,
97
+ )
98
+ else: # pragma: no cover
99
+ parser.error(f"unknown command {args.command!r}")
100
+
101
+ print(f"\nResult tables (written to {args.outdir}):")
102
+ _summarise(results)
103
+ return 0
104
+
105
+
106
+ if __name__ == "__main__":
107
+ raise SystemExit(main())
polyform/core.py ADDED
@@ -0,0 +1,289 @@
1
+ """
2
+ polyform.core
3
+ =============
4
+
5
+ Pure, stateless primitives shared across PolyForm's analyses, exposed as a
6
+ convenience API. These functions are copied verbatim from the manuscript
7
+ analysis scripts so that library use and manuscript reproduction stay identical.
8
+
9
+ Nothing here reads global state or performs I/O beyond the explicit file
10
+ readers (`find_csv`, `read_fasta`).
11
+ """
12
+ import os
13
+ import glob
14
+ import numpy as np
15
+ import pandas as pd
16
+ from math import log10, lgamma
17
+ from collections import defaultdict
18
+
19
+ __all__ = [
20
+ "find_csv", "read_fasta", "parse_ptms", "log10_comb",
21
+ "log10sumexp_base10", "safe_log10_state_fraction", "tuple_to_string",
22
+ "state_to_string", "hamming_distance_state", "normalized_unevenness",
23
+ "shannon_entropy", "normalized_entropy",
24
+ "connected_components_for_states", "component_distance",
25
+ ]
26
+
27
+ def find_csv(path):
28
+ if os.path.exists(path):
29
+ return path
30
+
31
+ candidates = []
32
+ candidates += glob.glob("*.csv")
33
+ candidates += glob.glob("/content/*.csv")
34
+
35
+ proteoform_candidates = [
36
+ c for c in candidates
37
+ if "proteoform" in os.path.basename(c).lower()
38
+ ]
39
+
40
+ if proteoform_candidates:
41
+ return sorted(proteoform_candidates)[0]
42
+
43
+ if candidates:
44
+ return sorted(candidates)[0]
45
+
46
+ raise FileNotFoundError(
47
+ "No CSV file found. Upload proteoforms.csv or set CSV to the correct path."
48
+ )
49
+
50
+
51
+ def read_fasta(path):
52
+ """
53
+ Read a UniProt-style FASTA.
54
+ Returns dictionary accession -> sequence.
55
+ """
56
+ seqs = {}
57
+ acc = None
58
+ buf = []
59
+
60
+ with open(path) as fh:
61
+ for line in fh:
62
+ line = line.rstrip("\n")
63
+
64
+ if line.startswith(">"):
65
+ if acc is not None:
66
+ seqs[acc] = "".join(buf)
67
+
68
+ header = line[1:]
69
+ parts = header.split("|")
70
+
71
+ if len(parts) >= 2:
72
+ acc = parts[1]
73
+ else:
74
+ acc = header.split()[0]
75
+
76
+ buf = []
77
+ else:
78
+ buf.append(line.strip())
79
+
80
+ if acc is not None:
81
+ seqs[acc] = "".join(buf)
82
+
83
+ return seqs
84
+
85
+
86
+ def parse_ptms(s, pos_base=0):
87
+ """
88
+ Parse PTM strings of the form:
89
+ 'RESID:55@3|RESID:76@8'
90
+
91
+ Returns:
92
+ [(position, mark_code), ...]
93
+ where position is adjusted by POS_BASE.
94
+ """
95
+ if pd.isna(s) or not str(s).strip():
96
+ return []
97
+
98
+ out = []
99
+
100
+ for tok in str(s).split("|"):
101
+ tok = tok.strip()
102
+ if not tok:
103
+ continue
104
+
105
+ if tok.startswith("RESID:"):
106
+ body = tok[len("RESID:"):]
107
+ if "@" not in body:
108
+ continue
109
+
110
+ mark, pos = body.split("@", 1)
111
+
112
+ try:
113
+ pos = int(pos) - pos_base
114
+ out.append((pos, str(mark)))
115
+ except Exception:
116
+ continue
117
+
118
+ return out
119
+
120
+
121
+ def log10_comb(n, k):
122
+ """
123
+ log10 binomial coefficient using lgamma for numerical stability.
124
+ """
125
+ if k < 0 or k > n:
126
+ return -np.inf
127
+
128
+ return (lgamma(n + 1) - lgamma(k + 1) - lgamma(n - k + 1)) / np.log(10)
129
+
130
+
131
+ def log10sumexp_base10(values):
132
+ """
133
+ log10(sum_i 10^values_i), stable for large logs.
134
+ """
135
+ values = np.asarray(values, dtype=float)
136
+ values = values[np.isfinite(values)]
137
+
138
+ if len(values) == 0:
139
+ return np.nan
140
+
141
+ m = np.max(values)
142
+ return m + log10(np.sum(10 ** (values - m)))
143
+
144
+
145
+ def safe_log10_state_fraction(n_acc, L):
146
+ """
147
+ log10(n_acc / 2^L), avoiding overflow/underflow.
148
+ """
149
+ if n_acc <= 0 or L <= 0:
150
+ return np.nan
151
+
152
+ return log10(n_acc) - L * LOG10_2
153
+
154
+
155
+ def tuple_to_string(t):
156
+ if isinstance(t, tuple):
157
+ return ";".join(map(str, t))
158
+ return str(t)
159
+
160
+
161
+ def state_to_string(state):
162
+ if len(state) == 0:
163
+ return "0^L"
164
+ return ",".join(map(str, state))
165
+
166
+
167
+ def hamming_distance_state(a, b):
168
+ """
169
+ Hamming distance between binary states encoded as tuples of modified positions.
170
+ """
171
+ return len(set(a).symmetric_difference(set(b)))
172
+
173
+
174
+ def normalized_unevenness(rho):
175
+ """
176
+ U_state = 0 when weights are even across accessed states.
177
+ U_state = 1 when all weight is concentrated into one state.
178
+
179
+ rho must sum to 1 over accessed states.
180
+ """
181
+ rho = np.asarray(rho, dtype=float)
182
+ rho = rho[rho > 0]
183
+
184
+ n = len(rho)
185
+
186
+ if n == 0:
187
+ return np.nan
188
+ if n == 1:
189
+ return 1.0
190
+
191
+ q = rho / rho.sum()
192
+ concentration = np.sum(q ** 2)
193
+
194
+ return (concentration - 1 / n) / (1 - 1 / n)
195
+
196
+
197
+ def shannon_entropy(rho):
198
+ """
199
+ Statistical entropy of a discrete occupancy vector.
200
+ Not physical entropy.
201
+ """
202
+ rho = np.asarray(rho, dtype=float)
203
+ rho = rho[rho > 0]
204
+
205
+ if len(rho) == 0:
206
+ return np.nan
207
+
208
+ return -np.sum(rho * np.log(rho))
209
+
210
+
211
+ def normalized_entropy(rho):
212
+ """
213
+ Entropy normalized by log(number of accessed states).
214
+ Returns 1 for an even distribution over n>1 states.
215
+ """
216
+ rho = np.asarray(rho, dtype=float)
217
+ rho = rho[rho > 0]
218
+ n = len(rho)
219
+
220
+ if n == 0:
221
+ return np.nan
222
+ if n == 1:
223
+ return 0.0
224
+
225
+ return shannon_entropy(rho) / np.log(n)
226
+
227
+
228
+ def connected_components_for_states(states):
229
+ """
230
+ Compute connected components of observed support under Hamming adjacency.
231
+
232
+ States are tuples of modified positions.
233
+ Edge exists when Hamming distance = 1.
234
+
235
+ For selected proteins, pairwise computation is transparent and sufficient.
236
+ """
237
+ states = list(states)
238
+ n = len(states)
239
+
240
+ parent = list(range(n))
241
+
242
+ def find(a):
243
+ while parent[a] != a:
244
+ parent[a] = parent[parent[a]]
245
+ a = parent[a]
246
+ return a
247
+
248
+ def union(a, b):
249
+ ra, rb = find(a), find(b)
250
+ if ra != rb:
251
+ parent[rb] = ra
252
+
253
+ state_sets = [set(s) for s in states]
254
+
255
+ for i in range(n):
256
+ for j in range(i + 1, n):
257
+ if len(state_sets[i].symmetric_difference(state_sets[j])) == 1:
258
+ union(i, j)
259
+
260
+ groups = defaultdict(list)
261
+ for i, s in enumerate(states):
262
+ groups[find(i)].append(s)
263
+
264
+ comps = list(groups.values())
265
+
266
+ # Sort components by size descending, then minimum grade.
267
+ comps = sorted(comps, key=lambda c: (-len(c), min(len(x) for x in c)))
268
+
269
+ comp_map = {}
270
+ for cid, comp in enumerate(comps, start=1):
271
+ for s in comp:
272
+ comp_map[s] = cid
273
+
274
+ return comps, comp_map
275
+
276
+
277
+ def component_distance(comp_a, comp_b):
278
+ """
279
+ Minimum Hamming distance between two components.
280
+ """
281
+ best = np.inf
282
+
283
+ for a in comp_a:
284
+ for b in comp_b:
285
+ d = hamming_distance_state(a, b)
286
+ if d < best:
287
+ best = d
288
+
289
+ return best