energorna 0.1.0__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.
@@ -0,0 +1,30 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jose Antonio Vilar Sánchez (QMetrika Labs)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ NOTICE — The MIT license above covers this SOURCE CODE. The underlying METHOD
26
+ (mRNA nearest-neighbor thermodynamic fingerprinting for variant scoring) is
27
+ covered by patent P202630522 (OEPM, Spain). Commercial use of the patented
28
+ method may require a separate license. Research and academic use is unaffected.
29
+
30
+ This software is for RESEARCH USE ONLY and is not a medical device.
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: energorna
3
+ Version: 0.1.0
4
+ Summary: Glass-box local periodicity-loss observable of the mRNA stacking profile for repeat-expansion disorders (DM1, Huntington, SCA1)
5
+ Author-email: Jose Antonio Vilar Sanchez <qmetrika@proton.me>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/josevilar-qbioai/energorna
8
+ Project-URL: Repository, https://github.com/josevilar-qbioai/energorna
9
+ Project-URL: Issues, https://github.com/josevilar-qbioai/energorna/issues
10
+ Keywords: repeat expansion,myotonic dystrophy,huntington,SCA1,trinucleotide,age at onset,mRNA,thermodynamics,interruptions
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: numpy>=1.20
24
+ Dynamic: license-file
25
+
26
+ # energorna
27
+
28
+ [![CI](https://github.com/josevilar-qbioai/energorna/actions/workflows/ci.yml/badge.svg)](https://github.com/josevilar-qbioai/energorna/actions/workflows/ci.yml)
29
+ [![PyPI](https://img.shields.io/pypi/v/energorna.svg)](https://pypi.org/project/energorna/)
30
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
31
+
32
+ **Glass-box local periodicity-loss observable of the mRNA stacking-energy profile, for
33
+ repeat-expansion disorders.** Numpy-only, no ViennaRNA / GPU / API. Part of the QMetrika
34
+ Bio-IA thermodynamic engine (same Turner nearest-neighbor parameters as `ef-synonymous`).
35
+
36
+ *[English](#english) · [Español](#español)*
37
+
38
+ ---
39
+
40
+ ## English
41
+
42
+ ### The idea
43
+
44
+ In repeat-expansion diseases (myotonic dystrophy type 1, Huntington, SCA1, DM2…), age at
45
+ onset is not set only by *how many* repeats there are, but by whether the repeat tract is
46
+ **interrupted**. Interruptions (e.g. CCG in the CTG tract of DM1, CAA in the CAG tract of
47
+ Huntington, CAT in SCA1) break the **periodicity** of the nearest-neighbor stacking energy
48
+ profile Φ, disrupt the slipped-strand substrate that drives somatic instability, and
49
+ **delay onset**.
50
+
51
+ `energorna` computes a single glass-box observable — `periodicity_loss = 1 − ACF_period(Φ)`,
52
+ the loss of autocorrelation (at the motif period) of the Turner stacking profile — that
53
+ captures this. It **auto-calibrates the period** per motif (3 for trinucleotides, 4 for the
54
+ CCTG tetranucleotide) and, unlike simply counting interruptions, is sensitive to their
55
+ **type** and **position**.
56
+
57
+ ### Install
58
+
59
+ ```bash
60
+ pip install energorna
61
+ ```
62
+
63
+ ### Use — CLI
64
+
65
+ ```bash
66
+ # pure (CTG)40 vs an interrupted allele
67
+ energorna score --repeat CTG --n 40
68
+ energorna score --seq CTGCTGCTG...CCGCTG... --motif CTG --json
69
+ energorna score --seq-file allele.fasta --motif CAG
70
+ ```
71
+
72
+ Output shows the observable next to the traditional baselines:
73
+
74
+ ```
75
+ motif CTG (period 3) · 62 units · longest uninterrupted run 51
76
+ periodicity_loss = 0.18xx <- observable
77
+ periodicity_loss (3') = 0.19xx
78
+ n_interruptions = 4 (count baseline)
79
+ fragmentation_index = 0.09xx (fragmentation baseline)
80
+ ```
81
+
82
+ ### Use — Python
83
+
84
+ ```python
85
+ import energorna as ef
86
+
87
+ ef.calibrate_lag("CCTG") # -> 4 (auto-detects the motif period)
88
+ ef.periodicity_loss(seq, unit="CTG") # global observable
89
+ ef.periodicity_profile(seq, unit="CTG") # (pos, loss) resolved 5'->3'
90
+ ef.periodicity_loss_3prime(seq, unit="CTG") # 3'-weighted
91
+ ef.n_interruptions(seq, "CTG") # count baseline (to compare)
92
+ ```
93
+
94
+ ### Why it matters (validation)
95
+
96
+ Across three diseases, `periodicity_loss` (or its axis, the longest uninterrupted stretch)
97
+ predicts age at onset **better than counting repeats**:
98
+
99
+ | Disease | Data | Result |
100
+ |---|---|---|
101
+ | **DM1** | Pešović 2018, n=7 | periodicity vs onset residual **r=+0.81** (LOO robust, bootstrap CI [+0.59,+0.99], perm p=0.027) vs count +0.53; captures type (CCG≫CTC) & position |
102
+ | **Huntington** | GeM-HD 2019 (published directions) | LOI < canonical < duplication — matches onset ordering |
103
+ | **SCA1** | Menon 2013 Table S3, n=35 | longest uninterrupted stretch predicts onset **R²=0.64** vs 0.24 for total size (r=−0.80, p=5×10⁻⁵) |
104
+
105
+ ### Honest scope
106
+
107
+ A **biologically motivated correlate**, not proven causality. Individual, interruption-typed
108
+ cohorts at scale (OPTIMISTIC, Enroll-HD) are access-controlled; the claim is "better
109
+ predictor than count on the available data, with a mechanistic hypothesis and falsifiable
110
+ predictions" (correlation with measured somatic instability, UV melting, MSH3 binding).
111
+ Research use only.
112
+
113
+ ---
114
+
115
+ ## Español
116
+
117
+ ### La idea
118
+
119
+ En enfermedades de expansión de repeticiones (distrofia miotónica tipo 1, Huntington,
120
+ SCA1, DM2…), la edad de inicio no la fija solo *cuántas* repeticiones hay, sino si el
121
+ tracto está **interrumpido**. Las interrupciones (p. ej. CCG en el tracto CTG de DM1, CAA
122
+ en el CAG de Huntington, CAT en SCA1) rompen la **periodicidad** del perfil de energía de
123
+ apilamiento nearest-neighbor Φ, alteran el sustrato slipped-strand que dirige la
124
+ inestabilidad somática y **retrasan el inicio**.
125
+
126
+ `energorna` calcula un único observable glass-box — `periodicity_loss = 1 − ACF_periodo(Φ)`,
127
+ la pérdida de autocorrelación (al periodo del motivo) del perfil de apilamiento Turner —
128
+ que lo captura. **Auto-calibra el periodo** por motivo (3 en trinucleótidos, 4 en el
129
+ tetranucleótido CCTG) y, a diferencia de contar interrupciones, es sensible a su **tipo**
130
+ y **posición**.
131
+
132
+ ### Instalación
133
+
134
+ ```bash
135
+ pip install energorna
136
+ ```
137
+
138
+ ### Uso — CLI
139
+
140
+ ```bash
141
+ energorna score --repeat CTG --n 40
142
+ energorna score --seq CTGCTG...CCGCTG... --motif CTG --json
143
+ energorna score --seq-file alelo.fasta --motif CAG
144
+ ```
145
+
146
+ ### Uso — Python
147
+
148
+ ```python
149
+ import energorna as ef
150
+ ef.calibrate_lag("CCTG") # -> 4 (auto-detecta el periodo)
151
+ ef.periodicity_loss(seq, unit="CTG") # observable global
152
+ ef.periodicity_profile(seq, unit="CTG") # perfil (pos, pérdida) 5'->3'
153
+ ef.periodicity_loss_3prime(seq, unit="CTG") # ponderado 3'
154
+ ```
155
+
156
+ ### Por qué importa (validación)
157
+
158
+ En tres enfermedades, `periodicity_loss` (o su eje, el tramo ininterrumpido más largo)
159
+ predice la edad de inicio **mejor que contar repeticiones**: DM1 (Pešović n=7, r=+0,81),
160
+ Huntington (direcciones publicadas, LOI<canónico<duplicación) y SCA1 (Menon 2013 n=35,
161
+ R²=0,64 vs 0,24 del tamaño total). Ver la tabla en la sección en inglés.
162
+
163
+ ### Alcance honesto
164
+
165
+ Es un **correlato biológicamente motivado**, no una causalidad demostrada. Los datos
166
+ individuales tipados a escala son de acceso controlado; el claim es "mejor predictor que
167
+ el conteo en los datos disponibles, con hipótesis mecanística y predicciones falsables".
168
+ Uso solo para investigación (RUO).
169
+
170
+ ---
171
+
172
+ ### Citation / Cita
173
+
174
+ Vilar Sánchez JA. *energoRNA: local periodicity loss of the mRNA stacking profile predicts
175
+ age at onset in repeat-expansion disorders.* QMetrika Labs, 2026. Method covered by patent
176
+ P202630522 (OEPM). Code under MIT.
177
+
178
+ - Repo: https://github.com/josevilar-qbioai/energorna
179
+ - ORCID: https://orcid.org/0009-0008-1057-4223
@@ -0,0 +1,154 @@
1
+ # energorna
2
+
3
+ [![CI](https://github.com/josevilar-qbioai/energorna/actions/workflows/ci.yml/badge.svg)](https://github.com/josevilar-qbioai/energorna/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/energorna.svg)](https://pypi.org/project/energorna/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
7
+ **Glass-box local periodicity-loss observable of the mRNA stacking-energy profile, for
8
+ repeat-expansion disorders.** Numpy-only, no ViennaRNA / GPU / API. Part of the QMetrika
9
+ Bio-IA thermodynamic engine (same Turner nearest-neighbor parameters as `ef-synonymous`).
10
+
11
+ *[English](#english) · [Español](#español)*
12
+
13
+ ---
14
+
15
+ ## English
16
+
17
+ ### The idea
18
+
19
+ In repeat-expansion diseases (myotonic dystrophy type 1, Huntington, SCA1, DM2…), age at
20
+ onset is not set only by *how many* repeats there are, but by whether the repeat tract is
21
+ **interrupted**. Interruptions (e.g. CCG in the CTG tract of DM1, CAA in the CAG tract of
22
+ Huntington, CAT in SCA1) break the **periodicity** of the nearest-neighbor stacking energy
23
+ profile Φ, disrupt the slipped-strand substrate that drives somatic instability, and
24
+ **delay onset**.
25
+
26
+ `energorna` computes a single glass-box observable — `periodicity_loss = 1 − ACF_period(Φ)`,
27
+ the loss of autocorrelation (at the motif period) of the Turner stacking profile — that
28
+ captures this. It **auto-calibrates the period** per motif (3 for trinucleotides, 4 for the
29
+ CCTG tetranucleotide) and, unlike simply counting interruptions, is sensitive to their
30
+ **type** and **position**.
31
+
32
+ ### Install
33
+
34
+ ```bash
35
+ pip install energorna
36
+ ```
37
+
38
+ ### Use — CLI
39
+
40
+ ```bash
41
+ # pure (CTG)40 vs an interrupted allele
42
+ energorna score --repeat CTG --n 40
43
+ energorna score --seq CTGCTGCTG...CCGCTG... --motif CTG --json
44
+ energorna score --seq-file allele.fasta --motif CAG
45
+ ```
46
+
47
+ Output shows the observable next to the traditional baselines:
48
+
49
+ ```
50
+ motif CTG (period 3) · 62 units · longest uninterrupted run 51
51
+ periodicity_loss = 0.18xx <- observable
52
+ periodicity_loss (3') = 0.19xx
53
+ n_interruptions = 4 (count baseline)
54
+ fragmentation_index = 0.09xx (fragmentation baseline)
55
+ ```
56
+
57
+ ### Use — Python
58
+
59
+ ```python
60
+ import energorna as ef
61
+
62
+ ef.calibrate_lag("CCTG") # -> 4 (auto-detects the motif period)
63
+ ef.periodicity_loss(seq, unit="CTG") # global observable
64
+ ef.periodicity_profile(seq, unit="CTG") # (pos, loss) resolved 5'->3'
65
+ ef.periodicity_loss_3prime(seq, unit="CTG") # 3'-weighted
66
+ ef.n_interruptions(seq, "CTG") # count baseline (to compare)
67
+ ```
68
+
69
+ ### Why it matters (validation)
70
+
71
+ Across three diseases, `periodicity_loss` (or its axis, the longest uninterrupted stretch)
72
+ predicts age at onset **better than counting repeats**:
73
+
74
+ | Disease | Data | Result |
75
+ |---|---|---|
76
+ | **DM1** | Pešović 2018, n=7 | periodicity vs onset residual **r=+0.81** (LOO robust, bootstrap CI [+0.59,+0.99], perm p=0.027) vs count +0.53; captures type (CCG≫CTC) & position |
77
+ | **Huntington** | GeM-HD 2019 (published directions) | LOI < canonical < duplication — matches onset ordering |
78
+ | **SCA1** | Menon 2013 Table S3, n=35 | longest uninterrupted stretch predicts onset **R²=0.64** vs 0.24 for total size (r=−0.80, p=5×10⁻⁵) |
79
+
80
+ ### Honest scope
81
+
82
+ A **biologically motivated correlate**, not proven causality. Individual, interruption-typed
83
+ cohorts at scale (OPTIMISTIC, Enroll-HD) are access-controlled; the claim is "better
84
+ predictor than count on the available data, with a mechanistic hypothesis and falsifiable
85
+ predictions" (correlation with measured somatic instability, UV melting, MSH3 binding).
86
+ Research use only.
87
+
88
+ ---
89
+
90
+ ## Español
91
+
92
+ ### La idea
93
+
94
+ En enfermedades de expansión de repeticiones (distrofia miotónica tipo 1, Huntington,
95
+ SCA1, DM2…), la edad de inicio no la fija solo *cuántas* repeticiones hay, sino si el
96
+ tracto está **interrumpido**. Las interrupciones (p. ej. CCG en el tracto CTG de DM1, CAA
97
+ en el CAG de Huntington, CAT en SCA1) rompen la **periodicidad** del perfil de energía de
98
+ apilamiento nearest-neighbor Φ, alteran el sustrato slipped-strand que dirige la
99
+ inestabilidad somática y **retrasan el inicio**.
100
+
101
+ `energorna` calcula un único observable glass-box — `periodicity_loss = 1 − ACF_periodo(Φ)`,
102
+ la pérdida de autocorrelación (al periodo del motivo) del perfil de apilamiento Turner —
103
+ que lo captura. **Auto-calibra el periodo** por motivo (3 en trinucleótidos, 4 en el
104
+ tetranucleótido CCTG) y, a diferencia de contar interrupciones, es sensible a su **tipo**
105
+ y **posición**.
106
+
107
+ ### Instalación
108
+
109
+ ```bash
110
+ pip install energorna
111
+ ```
112
+
113
+ ### Uso — CLI
114
+
115
+ ```bash
116
+ energorna score --repeat CTG --n 40
117
+ energorna score --seq CTGCTG...CCGCTG... --motif CTG --json
118
+ energorna score --seq-file alelo.fasta --motif CAG
119
+ ```
120
+
121
+ ### Uso — Python
122
+
123
+ ```python
124
+ import energorna as ef
125
+ ef.calibrate_lag("CCTG") # -> 4 (auto-detecta el periodo)
126
+ ef.periodicity_loss(seq, unit="CTG") # observable global
127
+ ef.periodicity_profile(seq, unit="CTG") # perfil (pos, pérdida) 5'->3'
128
+ ef.periodicity_loss_3prime(seq, unit="CTG") # ponderado 3'
129
+ ```
130
+
131
+ ### Por qué importa (validación)
132
+
133
+ En tres enfermedades, `periodicity_loss` (o su eje, el tramo ininterrumpido más largo)
134
+ predice la edad de inicio **mejor que contar repeticiones**: DM1 (Pešović n=7, r=+0,81),
135
+ Huntington (direcciones publicadas, LOI<canónico<duplicación) y SCA1 (Menon 2013 n=35,
136
+ R²=0,64 vs 0,24 del tamaño total). Ver la tabla en la sección en inglés.
137
+
138
+ ### Alcance honesto
139
+
140
+ Es un **correlato biológicamente motivado**, no una causalidad demostrada. Los datos
141
+ individuales tipados a escala son de acceso controlado; el claim es "mejor predictor que
142
+ el conteo en los datos disponibles, con hipótesis mecanística y predicciones falsables".
143
+ Uso solo para investigación (RUO).
144
+
145
+ ---
146
+
147
+ ### Citation / Cita
148
+
149
+ Vilar Sánchez JA. *energoRNA: local periodicity loss of the mRNA stacking profile predicts
150
+ age at onset in repeat-expansion disorders.* QMetrika Labs, 2026. Method covered by patent
151
+ P202630522 (OEPM). Code under MIT.
152
+
153
+ - Repo: https://github.com/josevilar-qbioai/energorna
154
+ - ORCID: https://orcid.org/0009-0008-1057-4223
@@ -0,0 +1,23 @@
1
+ """energorna — glass-box local periodicity-loss observable for repeat-expansion disorders.
2
+
3
+ Self-contained engine (numpy-only). Turner nearest-neighbor stacking parameters.
4
+ The interruption that breaks the periodicity of the stacking profile Φ predicts age at
5
+ onset better than counting repeats — validated across DM1, Huntington and SCA1.
6
+ """
7
+ __version__ = "0.1.0"
8
+
9
+ from .engine import (
10
+ stacking_profile,
11
+ calibrate_lag,
12
+ periodicity_loss,
13
+ periodicity_profile,
14
+ periodicity_loss_3prime,
15
+ n_interruptions,
16
+ fragmentation_index,
17
+ STACKING_TURNER,
18
+ )
19
+
20
+ __all__ = [
21
+ "stacking_profile", "calibrate_lag", "periodicity_loss", "periodicity_profile",
22
+ "periodicity_loss_3prime", "n_interruptions", "fragmentation_index", "STACKING_TURNER",
23
+ ]
@@ -0,0 +1,88 @@
1
+ """
2
+ energorna CLI — score the local periodicity loss of a repeat-expansion allele.
3
+
4
+ Examples:
5
+ energorna score --seq CTGCTGCCGCTGCTG... --motif CTG
6
+ energorna score --seq-file allele.fasta --motif CAG --json
7
+ energorna score --repeat CTG --n 40 --json # pure (CTG)40
8
+
9
+ Numpy-only, glass-box. Prints periodicity_loss (the observable), its 3'-weighted
10
+ variant, and the traditional count/fragmentation baselines for comparison.
11
+ """
12
+ import argparse
13
+ import json
14
+ import re
15
+ import sys
16
+
17
+ from . import (periodicity_loss, periodicity_loss_3prime, periodicity_profile,
18
+ calibrate_lag, n_interruptions, fragmentation_index)
19
+
20
+
21
+ def _read_seq(literal=None, path=None, repeat=None, n=None):
22
+ if repeat and n:
23
+ seq = repeat.upper() * int(n)
24
+ elif literal:
25
+ seq = literal
26
+ elif path:
27
+ seq = "".join(l.strip() for l in open(path) if not l.startswith(">"))
28
+ else:
29
+ return None
30
+ seq = re.sub(r"\s", "", seq).upper().replace("U", "T")
31
+ return seq if re.fullmatch(r"[ACGT]+", seq or "") else None
32
+
33
+
34
+ def _longest_run(seq, motif):
35
+ units = [seq[i:i+len(motif)] for i in range(0, len(seq) - len(motif) + 1, len(motif))]
36
+ best = cur = 0
37
+ for u in units:
38
+ cur = cur + 1 if u == motif else 0
39
+ best = max(best, cur)
40
+ return best
41
+
42
+
43
+ def main(argv=None):
44
+ ap = argparse.ArgumentParser(prog="energorna", description=__doc__,
45
+ formatter_class=argparse.RawDescriptionHelpFormatter)
46
+ sub = ap.add_subparsers(dest="cmd")
47
+ s = sub.add_parser("score", help="score a repeat allele")
48
+ s.add_argument("--seq"); s.add_argument("--seq-file")
49
+ s.add_argument("--repeat"); s.add_argument("--n", type=int)
50
+ s.add_argument("--motif", default="CTG", help="repeat unit (default CTG)")
51
+ s.add_argument("--json", action="store_true")
52
+ args = ap.parse_args(argv)
53
+
54
+ if args.cmd != "score":
55
+ ap.print_help(); return 1
56
+
57
+ seq = _read_seq(args.seq, args.seq_file, args.repeat, args.n)
58
+ if not seq:
59
+ print("error: provide a valid nucleotide sequence via --seq / --seq-file / --repeat+--n",
60
+ file=sys.stderr)
61
+ return 2
62
+ motif = args.motif.upper().replace("U", "T")
63
+
64
+ out = {
65
+ "motif": motif,
66
+ "motif_period": calibrate_lag(motif),
67
+ "n_units": len(seq) // len(motif),
68
+ "longest_uninterrupted_run": _longest_run(seq, motif),
69
+ "n_interruptions": n_interruptions(seq, motif),
70
+ "periodicity_loss": round(periodicity_loss(seq, unit=motif), 6),
71
+ "periodicity_loss_3prime": round(periodicity_loss_3prime(seq, unit=motif), 6),
72
+ "fragmentation_index": round(fragmentation_index(seq, motif), 6),
73
+ }
74
+
75
+ if args.json:
76
+ print(json.dumps(out, indent=2))
77
+ else:
78
+ print(f"motif {out['motif']} (period {out['motif_period']}) · "
79
+ f"{out['n_units']} units · longest uninterrupted run {out['longest_uninterrupted_run']}")
80
+ print(f" periodicity_loss = {out['periodicity_loss']:.4f} <- observable")
81
+ print(f" periodicity_loss (3') = {out['periodicity_loss_3prime']:.4f}")
82
+ print(f" n_interruptions = {out['n_interruptions']} (count baseline)")
83
+ print(f" fragmentation_index = {out['fragmentation_index']:.4f} (fragmentation baseline)")
84
+ return 0
85
+
86
+
87
+ if __name__ == "__main__":
88
+ raise SystemExit(main())
@@ -0,0 +1,118 @@
1
+ """
2
+ energorna.engine — glass-box observable of local PERIODICITY LOSS in the nearest-neighbor
3
+ stacking energy profile, for repeat-expansion disorders.
4
+
5
+ Self-contained (numpy-only). No ViennaRNA / GPU / API / wet-lab. Every number is traceable.
6
+ The central observable is the loss of autocorrelation (at lag = motif period) of the
7
+ nearest-neighbor stacking free-energy profile (Turner RNA parameters).
8
+
9
+ Public API:
10
+ stacking_profile(seq) -> per-position stacking profile Φ (kcal/mol)
11
+ calibrate_lag(unit) -> motif period (optimal ACF lag)
12
+ periodicity_loss(seq, unit|lag) -> global scalar: 1 - ACF_period(Φ)
13
+ periodicity_profile(seq) -> (relative_pos, local_loss) per window
14
+ periodicity_loss_3prime(seq) -> 3'-weighted scalar
15
+ n_interruptions(seq, unit) -> COUNT baseline (# interruptions)
16
+ fragmentation_index(seq, unit) -> FRAGMENTATION baseline (1 - Σ run^α / n^α)
17
+
18
+ Turner (RNA) nearest-neighbor stacking parameters; keys stored as DNA (T≡U).
19
+ Source: Turner 2004 / NNDB.
20
+ """
21
+ import numpy as np
22
+
23
+ STACKING_TURNER = {
24
+ 'AA': -0.93, 'AT': -1.10, 'AG': -2.08, 'AC': -2.24,
25
+ 'TA': -1.33, 'TT': -0.93, 'TG': -2.35, 'TC': -2.11,
26
+ 'GA': -2.35, 'GT': -2.24, 'GG': -3.26, 'GC': -3.42,
27
+ 'CA': -2.11, 'CT': -2.08, 'CG': -2.36, 'CC': -3.26,
28
+ }
29
+
30
+
31
+ def stacking_profile(seq):
32
+ """Nearest-neighbor stacking energy profile (Turner), one value per position."""
33
+ s = seq.upper().replace('U', 'T')
34
+ n = len(s)
35
+ bonds = np.array([STACKING_TURNER.get(s[i:i+2], 0.0) for i in range(n - 1)])
36
+ prof = np.zeros(n)
37
+ if n == 1:
38
+ return prof
39
+ prof[0], prof[-1] = bonds[0], bonds[-1]
40
+ for i in range(1, n - 1):
41
+ prof[i] = (bonds[i-1] + bonds[i]) / 2
42
+ return prof
43
+
44
+
45
+ def _acf(x, lag):
46
+ """Autocorrelation of x at a given lag."""
47
+ x = np.asarray(x, float)
48
+ if len(x) <= lag + 1 or x.std() == 0:
49
+ return 1.0
50
+ x = x - x.mean()
51
+ return float(np.corrcoef(x[:-lag], x[lag:])[0, 1])
52
+
53
+
54
+ def calibrate_lag(unit, lo=2, hi=8):
55
+ """Motif period = lag (lo..hi) maximizing the ACF of the pure repeat.
56
+
57
+ Trinucleotides (CTG, CAG, CGG) -> 3; tetranucleotide (CCTG) -> 4.
58
+ """
59
+ prof = stacking_profile(unit * 40)
60
+ scores = {L: _acf(prof, L) for L in range(lo, hi + 1)}
61
+ return max(scores, key=lambda L: scores[L])
62
+
63
+
64
+ def periodicity_loss(seq, unit=None, lag=None):
65
+ """Global scalar: 1 - ACF at lag=period. If `unit`, calibrate the lag; else lag=3."""
66
+ if lag is None:
67
+ lag = calibrate_lag(unit) if unit else 3
68
+ return 1 - _acf(stacking_profile(seq), lag)
69
+
70
+
71
+ def periodicity_profile(seq, unit=None, lag=None, win=21, step=3):
72
+ """Position-resolved profile of the local periodicity loss.
73
+
74
+ Returns (pos, val): pos in [0,1] (0=5', 1=3'), val = 1 - ACF_period per window.
75
+ """
76
+ if lag is None:
77
+ lag = calibrate_lag(unit) if unit else 3
78
+ prof = stacking_profile(seq)
79
+ pos, val = [], []
80
+ for i in range(0, len(prof) - win, step):
81
+ pos.append((i + win / 2) / len(prof))
82
+ val.append(1 - _acf(prof[i:i+win], lag))
83
+ return np.array(pos), np.array(val)
84
+
85
+
86
+ def periodicity_loss_3prime(seq, unit=None, lag=None, win=21, step=3):
87
+ """Periodicity loss weighted toward the 3' end (linear weight 0->1 from 5'->3')."""
88
+ pos, val = periodicity_profile(seq, unit, lag, win, step)
89
+ return float(np.average(val, weights=pos)) if len(pos) else 0.0
90
+
91
+
92
+ # ── traditional baselines (for benchmarking) ──
93
+ def _units(seq, k):
94
+ s = seq.upper().replace('U', 'T')
95
+ return [s[i:i+k] for i in range(0, len(s) - k + 1, k)]
96
+
97
+
98
+ def n_interruptions(seq, unit='CTG'):
99
+ """Number of units that are NOT the pure motif (count baseline)."""
100
+ unit = unit.upper().replace('U', 'T')
101
+ return sum(1 for t in _units(seq, len(unit)) if t != unit)
102
+
103
+
104
+ def fragmentation_index(seq, unit='CTG', alpha=1.6):
105
+ """1 - FI, where FI = Σ(perfect_run)^α / n^α (fragmentation baseline)."""
106
+ unit = unit.upper().replace('U', 'T')
107
+ trip = _units(seq, len(unit))
108
+ n = len(trip)
109
+ runs, cur = [], 0
110
+ for t in trip:
111
+ if t == unit:
112
+ cur += 1
113
+ elif cur:
114
+ runs.append(cur); cur = 0
115
+ if cur:
116
+ runs.append(cur)
117
+ fi = sum(r**alpha for r in runs) / (n**alpha) if n else 0.0
118
+ return 1 - fi
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: energorna
3
+ Version: 0.1.0
4
+ Summary: Glass-box local periodicity-loss observable of the mRNA stacking profile for repeat-expansion disorders (DM1, Huntington, SCA1)
5
+ Author-email: Jose Antonio Vilar Sanchez <qmetrika@proton.me>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/josevilar-qbioai/energorna
8
+ Project-URL: Repository, https://github.com/josevilar-qbioai/energorna
9
+ Project-URL: Issues, https://github.com/josevilar-qbioai/energorna/issues
10
+ Keywords: repeat expansion,myotonic dystrophy,huntington,SCA1,trinucleotide,age at onset,mRNA,thermodynamics,interruptions
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: numpy>=1.20
24
+ Dynamic: license-file
25
+
26
+ # energorna
27
+
28
+ [![CI](https://github.com/josevilar-qbioai/energorna/actions/workflows/ci.yml/badge.svg)](https://github.com/josevilar-qbioai/energorna/actions/workflows/ci.yml)
29
+ [![PyPI](https://img.shields.io/pypi/v/energorna.svg)](https://pypi.org/project/energorna/)
30
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
31
+
32
+ **Glass-box local periodicity-loss observable of the mRNA stacking-energy profile, for
33
+ repeat-expansion disorders.** Numpy-only, no ViennaRNA / GPU / API. Part of the QMetrika
34
+ Bio-IA thermodynamic engine (same Turner nearest-neighbor parameters as `ef-synonymous`).
35
+
36
+ *[English](#english) · [Español](#español)*
37
+
38
+ ---
39
+
40
+ ## English
41
+
42
+ ### The idea
43
+
44
+ In repeat-expansion diseases (myotonic dystrophy type 1, Huntington, SCA1, DM2…), age at
45
+ onset is not set only by *how many* repeats there are, but by whether the repeat tract is
46
+ **interrupted**. Interruptions (e.g. CCG in the CTG tract of DM1, CAA in the CAG tract of
47
+ Huntington, CAT in SCA1) break the **periodicity** of the nearest-neighbor stacking energy
48
+ profile Φ, disrupt the slipped-strand substrate that drives somatic instability, and
49
+ **delay onset**.
50
+
51
+ `energorna` computes a single glass-box observable — `periodicity_loss = 1 − ACF_period(Φ)`,
52
+ the loss of autocorrelation (at the motif period) of the Turner stacking profile — that
53
+ captures this. It **auto-calibrates the period** per motif (3 for trinucleotides, 4 for the
54
+ CCTG tetranucleotide) and, unlike simply counting interruptions, is sensitive to their
55
+ **type** and **position**.
56
+
57
+ ### Install
58
+
59
+ ```bash
60
+ pip install energorna
61
+ ```
62
+
63
+ ### Use — CLI
64
+
65
+ ```bash
66
+ # pure (CTG)40 vs an interrupted allele
67
+ energorna score --repeat CTG --n 40
68
+ energorna score --seq CTGCTGCTG...CCGCTG... --motif CTG --json
69
+ energorna score --seq-file allele.fasta --motif CAG
70
+ ```
71
+
72
+ Output shows the observable next to the traditional baselines:
73
+
74
+ ```
75
+ motif CTG (period 3) · 62 units · longest uninterrupted run 51
76
+ periodicity_loss = 0.18xx <- observable
77
+ periodicity_loss (3') = 0.19xx
78
+ n_interruptions = 4 (count baseline)
79
+ fragmentation_index = 0.09xx (fragmentation baseline)
80
+ ```
81
+
82
+ ### Use — Python
83
+
84
+ ```python
85
+ import energorna as ef
86
+
87
+ ef.calibrate_lag("CCTG") # -> 4 (auto-detects the motif period)
88
+ ef.periodicity_loss(seq, unit="CTG") # global observable
89
+ ef.periodicity_profile(seq, unit="CTG") # (pos, loss) resolved 5'->3'
90
+ ef.periodicity_loss_3prime(seq, unit="CTG") # 3'-weighted
91
+ ef.n_interruptions(seq, "CTG") # count baseline (to compare)
92
+ ```
93
+
94
+ ### Why it matters (validation)
95
+
96
+ Across three diseases, `periodicity_loss` (or its axis, the longest uninterrupted stretch)
97
+ predicts age at onset **better than counting repeats**:
98
+
99
+ | Disease | Data | Result |
100
+ |---|---|---|
101
+ | **DM1** | Pešović 2018, n=7 | periodicity vs onset residual **r=+0.81** (LOO robust, bootstrap CI [+0.59,+0.99], perm p=0.027) vs count +0.53; captures type (CCG≫CTC) & position |
102
+ | **Huntington** | GeM-HD 2019 (published directions) | LOI < canonical < duplication — matches onset ordering |
103
+ | **SCA1** | Menon 2013 Table S3, n=35 | longest uninterrupted stretch predicts onset **R²=0.64** vs 0.24 for total size (r=−0.80, p=5×10⁻⁵) |
104
+
105
+ ### Honest scope
106
+
107
+ A **biologically motivated correlate**, not proven causality. Individual, interruption-typed
108
+ cohorts at scale (OPTIMISTIC, Enroll-HD) are access-controlled; the claim is "better
109
+ predictor than count on the available data, with a mechanistic hypothesis and falsifiable
110
+ predictions" (correlation with measured somatic instability, UV melting, MSH3 binding).
111
+ Research use only.
112
+
113
+ ---
114
+
115
+ ## Español
116
+
117
+ ### La idea
118
+
119
+ En enfermedades de expansión de repeticiones (distrofia miotónica tipo 1, Huntington,
120
+ SCA1, DM2…), la edad de inicio no la fija solo *cuántas* repeticiones hay, sino si el
121
+ tracto está **interrumpido**. Las interrupciones (p. ej. CCG en el tracto CTG de DM1, CAA
122
+ en el CAG de Huntington, CAT en SCA1) rompen la **periodicidad** del perfil de energía de
123
+ apilamiento nearest-neighbor Φ, alteran el sustrato slipped-strand que dirige la
124
+ inestabilidad somática y **retrasan el inicio**.
125
+
126
+ `energorna` calcula un único observable glass-box — `periodicity_loss = 1 − ACF_periodo(Φ)`,
127
+ la pérdida de autocorrelación (al periodo del motivo) del perfil de apilamiento Turner —
128
+ que lo captura. **Auto-calibra el periodo** por motivo (3 en trinucleótidos, 4 en el
129
+ tetranucleótido CCTG) y, a diferencia de contar interrupciones, es sensible a su **tipo**
130
+ y **posición**.
131
+
132
+ ### Instalación
133
+
134
+ ```bash
135
+ pip install energorna
136
+ ```
137
+
138
+ ### Uso — CLI
139
+
140
+ ```bash
141
+ energorna score --repeat CTG --n 40
142
+ energorna score --seq CTGCTG...CCGCTG... --motif CTG --json
143
+ energorna score --seq-file alelo.fasta --motif CAG
144
+ ```
145
+
146
+ ### Uso — Python
147
+
148
+ ```python
149
+ import energorna as ef
150
+ ef.calibrate_lag("CCTG") # -> 4 (auto-detecta el periodo)
151
+ ef.periodicity_loss(seq, unit="CTG") # observable global
152
+ ef.periodicity_profile(seq, unit="CTG") # perfil (pos, pérdida) 5'->3'
153
+ ef.periodicity_loss_3prime(seq, unit="CTG") # ponderado 3'
154
+ ```
155
+
156
+ ### Por qué importa (validación)
157
+
158
+ En tres enfermedades, `periodicity_loss` (o su eje, el tramo ininterrumpido más largo)
159
+ predice la edad de inicio **mejor que contar repeticiones**: DM1 (Pešović n=7, r=+0,81),
160
+ Huntington (direcciones publicadas, LOI<canónico<duplicación) y SCA1 (Menon 2013 n=35,
161
+ R²=0,64 vs 0,24 del tamaño total). Ver la tabla en la sección en inglés.
162
+
163
+ ### Alcance honesto
164
+
165
+ Es un **correlato biológicamente motivado**, no una causalidad demostrada. Los datos
166
+ individuales tipados a escala son de acceso controlado; el claim es "mejor predictor que
167
+ el conteo en los datos disponibles, con hipótesis mecanística y predicciones falsables".
168
+ Uso solo para investigación (RUO).
169
+
170
+ ---
171
+
172
+ ### Citation / Cita
173
+
174
+ Vilar Sánchez JA. *energoRNA: local periodicity loss of the mRNA stacking profile predicts
175
+ age at onset in repeat-expansion disorders.* QMetrika Labs, 2026. Method covered by patent
176
+ P202630522 (OEPM). Code under MIT.
177
+
178
+ - Repo: https://github.com/josevilar-qbioai/energorna
179
+ - ORCID: https://orcid.org/0009-0008-1057-4223
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ energorna/__init__.py
5
+ energorna/cli.py
6
+ energorna/engine.py
7
+ energorna.egg-info/PKG-INFO
8
+ energorna.egg-info/SOURCES.txt
9
+ energorna.egg-info/dependency_links.txt
10
+ energorna.egg-info/entry_points.txt
11
+ energorna.egg-info/requires.txt
12
+ energorna.egg-info/top_level.txt
13
+ tests/test_ground_truth.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ energorna = energorna.cli:main
@@ -0,0 +1 @@
1
+ numpy>=1.20
@@ -0,0 +1 @@
1
+ energorna
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "energorna"
7
+ version = "0.1.0"
8
+ description = "Glass-box local periodicity-loss observable of the mRNA stacking profile for repeat-expansion disorders (DM1, Huntington, SCA1)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Jose Antonio Vilar Sanchez", email = "qmetrika@proton.me" }]
13
+ keywords = ["repeat expansion", "myotonic dystrophy", "huntington", "SCA1", "trinucleotide", "age at onset", "mRNA", "thermodynamics", "interruptions"]
14
+ dependencies = ["numpy>=1.20"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Science/Research",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.8",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/josevilar-qbioai/energorna"
29
+ Repository = "https://github.com/josevilar-qbioai/energorna"
30
+ Issues = "https://github.com/josevilar-qbioai/energorna/issues"
31
+
32
+ [project.scripts]
33
+ energorna = "energorna.cli:main"
34
+
35
+ [tool.setuptools]
36
+ packages = ["energorna"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,68 @@
1
+ """
2
+ Ground-truth tests for energorna. Values computed from the reference engine and
3
+ reported in the manuscript / study (studies/energorna-dm1). numpy-only.
4
+ """
5
+ import numpy as np
6
+ import energorna as ef
7
+
8
+
9
+ def test_calibrate_lag_period():
10
+ # ACF of the pure repeat peaks at the motif period
11
+ assert ef.calibrate_lag("CTG") == 3
12
+ assert ef.calibrate_lag("CAG") == 3
13
+ assert ef.calibrate_lag("CGG") == 3
14
+ assert ef.calibrate_lag("CCTG") == 4 # tetranucleotide (DM2)
15
+
16
+
17
+ def test_periodicity_loss_pure_is_low():
18
+ assert abs(ef.periodicity_loss("CTG" * 40, unit="CTG") - 0.036937) < 1e-5
19
+
20
+
21
+ def test_interruption_raises_loss():
22
+ pure = ef.periodicity_loss("CTG" * 60, unit="CTG")
23
+ intr = ef.periodicity_loss(_equispaced("CTG", "CCG", k=4), unit="CTG")
24
+ assert intr > pure + 0.1 # an interruption breaks the pattern
25
+
26
+
27
+ def test_huntington_ordering():
28
+ # LOI (uninterrupted) < canonical (1 CAA) < duplication (2 CAA) — matches onset direction
29
+ loi = ef.periodicity_loss("CAG" * 42, unit="CAG")
30
+ can = ef.periodicity_loss("CAG" * 40 + "CAACAG", unit="CAG")
31
+ dup = ef.periodicity_loss("CAG" * 38 + "CAACAGCAACAG", unit="CAG")
32
+ assert loi < can < dup
33
+ assert abs(loi - 0.036145) < 1e-5
34
+ assert abs(dup - 0.229976) < 1e-5
35
+
36
+
37
+ def test_type_discrimination_ccg_gt_ctc():
38
+ # At fixed count/position, CCG (protective in DM1) disrupts Φ more than CTC
39
+ ccg = ef.periodicity_loss(_equispaced("CTG", "CCG", k=4), unit="CTG")
40
+ ctc = ef.periodicity_loss(_equispaced("CTG", "CTC", k=4), unit="CTG")
41
+ assert ccg > ctc
42
+ assert abs(ccg - 0.193759) < 1e-5
43
+
44
+
45
+ def test_baselines():
46
+ seq = _equispaced("CTG", "CCG", k=4)
47
+ assert ef.n_interruptions(seq, "CTG") == 4
48
+ assert 0.0 < ef.fragmentation_index(seq, "CTG") < 1.0
49
+
50
+
51
+ def test_profile_shape():
52
+ pos, val = ef.periodicity_profile("CTG" * 40 + "CCGCTG" * 4, unit="CTG")
53
+ assert len(pos) == len(val) and pos.min() >= 0 and pos.max() <= 1
54
+
55
+
56
+ def _equispaced(motif, interr, k=4, n=60):
57
+ u = [motif] * n
58
+ for i in np.linspace(0, n - 1, k + 2).round().astype(int)[1:-1]:
59
+ u[int(i)] = interr
60
+ return "".join(u)
61
+
62
+
63
+ if __name__ == "__main__":
64
+ for fn in [test_calibrate_lag_period, test_periodicity_loss_pure_is_low,
65
+ test_interruption_raises_loss, test_huntington_ordering,
66
+ test_type_discrimination_ccg_gt_ctc, test_baselines, test_profile_shape]:
67
+ fn(); print("ok:", fn.__name__)
68
+ print("ALL OK")