rosetta-eclab 0.1.1__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.
@@ -0,0 +1,39 @@
1
+ """List the namer's misses on a v2 split with the engine's ranking (development aid; never on the hold-out)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import warnings
7
+
8
+ from spike2 import corpus, score, tasks
9
+
10
+ from .evaluate import answer
11
+
12
+
13
+ def main() -> int:
14
+ warnings.filterwarnings("ignore")
15
+ sys.stdout.reconfigure(encoding="utf-8")
16
+ split = sys.argv[1] if len(sys.argv) > 1 else "test"
17
+ assert split in ("dev", "test", "holdout", "holdout2", "holdout3", "holdout4"), "development (spent) splits only"
18
+ for t in tasks.load(split):
19
+ if t["bucket"] not in ("main", "decoy"):
20
+ continue
21
+ x, dtype, kv, kc = tasks.hidden_and_known(t)
22
+ a = answer(x, dtype, list(kc.values()), t["technique"], tasks.task_vocabulary(t),
23
+ exclude_group=corpus.source_group(t["file"]))
24
+ r = {"status": "ok", **a}
25
+ if t["bucket"] == "main" and score.correct(t, r):
26
+ continue
27
+ if t["bucket"] == "decoy" and not score.hallucinated(t, r):
28
+ continue
29
+ truth = t["truth"]["canonical"] if t["truth"] else f"DECOY {t['decoy']['kind']} {t['decoy'].get('source_name', '')}"
30
+ print(f"\n{t['task_id']} {t['file']} ({t['technique']}) truth={truth} -> {a['verdict']} {a['name']}")
31
+ print(f" known: {', '.join(c.name for c in kc.values())}")
32
+ print(f" evidence: {a.get('evidence', '')[:200]}")
33
+ for row in a.get("ranking", [])[:4]:
34
+ print(f" {row}")
35
+ return 0
36
+
37
+
38
+ if __name__ == "__main__":
39
+ raise SystemExit(main())
@@ -0,0 +1,178 @@
1
+ """Read a BioLogic EC-Lab .mpr file, including columns the reference parser does not know.
2
+
3
+ Known columns are typed and named with the reference parser's column map. For
4
+ unknown column IDs the byte width is solved from the record size, the storage
5
+ type is chosen by plausibility (finite, sensible magnitudes, integers for
6
+ integer types), and the values are decoded so the namer can identify them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import itertools
12
+ import logging
13
+ import warnings
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+
19
+ FLAG_IDS = (1, 2, 3, 21, 31, 65)
20
+ CANDIDATES = {1: ["|u1"], 2: ["<u2", "<i2"], 4: ["<f4", "<u4", "<i4"], 8: ["<f8", "<u8", "<i8"]}
21
+
22
+
23
+ @dataclass
24
+ class Col:
25
+ position: int
26
+ raw_id: int
27
+ name: str
28
+ unit: str | None
29
+ dtype: str
30
+ known: bool
31
+ values: np.ndarray = field(repr=False)
32
+
33
+
34
+ @dataclass
35
+ class MprData:
36
+ path: str
37
+ technique: str | None
38
+ n_points: int
39
+ record_size: int
40
+ columns: list[Col]
41
+ notes: list[str]
42
+
43
+ @property
44
+ def unknown(self) -> list[Col]:
45
+ return [c for c in self.columns if not c.known]
46
+
47
+
48
+ def _modules(raw: bytes):
49
+ from yadg import dgutils
50
+ from yadg.extractors.eclab.mpr_columns import module_header_dtypes
51
+
52
+ for module in raw.split(b"MODULE")[1:]:
53
+ for mhd in module_header_dtypes:
54
+ try:
55
+ header = dgutils.read_value(module, 0x0000, mhd)
56
+ if len(module) == mhd.itemsize + header["length"]:
57
+ yield header["short_name"].strip(), header.get("newver", 0) + header["oldver"], module[mhd.itemsize:]
58
+ break
59
+ except UnicodeDecodeError:
60
+ continue
61
+
62
+
63
+ def _plausibility(v: np.ndarray, dtype: str) -> float:
64
+ x = v.astype("f8")
65
+ fin = np.isfinite(x)
66
+ if fin.mean() < 0.999:
67
+ return 0.0
68
+ y = x[fin]
69
+ if y.size == 0:
70
+ return 0.0
71
+ if dtype.endswith(("f4", "f8")):
72
+ a = np.abs(y[y != 0])
73
+ if a.size and (np.mean((a < 1e-30) | (a > 1e15)) > 0.01):
74
+ return 0.05 # denormal-looking or astronomically large values: wrong type
75
+ return 1.0 if np.mean(np.isclose(y, np.round(y))) < 0.95 else 0.7
76
+ # integers: plausible when they are small relative to the type's range
77
+ span = float(np.max(np.abs(y))) if y.size else 0.0
78
+ width = np.dtype(dtype).itemsize
79
+ return 0.9 if span < 2 ** (8 * width - 4) else 0.2
80
+
81
+
82
+ def read(path: str | Path, reference: str = "galvani") -> MprData:
83
+ from galvani import BioLogic
84
+ from yadg.extractors.eclab.mpr import parse_columns, process_settings
85
+
86
+ logging.getLogger("yadg").setLevel(logging.CRITICAL)
87
+ warnings.filterwarnings("ignore")
88
+ raw = Path(path).read_bytes()
89
+ if not raw.startswith(b"BIO-LOGIC MODULAR FILE"):
90
+ raise ValueError("not a BioLogic EC-Lab .mpr file")
91
+ technique, data, version = None, None, None
92
+ for name, ver, mdata in _modules(raw):
93
+ if name == "VMP Set":
94
+ try:
95
+ technique, _, _ = process_settings(mdata, "11.50" if ver >= 10 else "10.40")
96
+ except Exception:
97
+ technique = None
98
+ elif name == "VMP data":
99
+ data, version = mdata, ver
100
+ if data is None:
101
+ raise ValueError("no 'VMP data' module")
102
+ n_points = int(np.frombuffer(data, offset=0, dtype="<u4", count=1)[0])
103
+ n_columns = int(np.frombuffer(data, offset=4, dtype="|u1", count=1)[0])
104
+ if version in (10, 11):
105
+ yids = np.frombuffer(data, offset=5, dtype=">u2", count=n_columns).tolist()
106
+ full = np.frombuffer(data, offset=6, dtype="<u2", count=n_columns).tolist()
107
+ offset = 0x3EF
108
+ elif version in (2, 3):
109
+ yids = full = np.frombuffer(data, offset=5, dtype="<u2", count=n_columns).tolist()
110
+ offset = 0x195 if version == 2 else 0x196
111
+ else:
112
+ raise NotImplementedError(f"data module version {version} is not supported yet")
113
+ region = len(data) - offset
114
+ if n_points == 0 or region % n_points:
115
+ raise ValueError(f"record size is not a whole number of bytes ({region} / {n_points})")
116
+ rec = region // n_points
117
+
118
+ names, dtypes, units, _ = parse_columns(yids, technique) # yadg's map: names for known columns
119
+ gmap = {int(k): v for k, v in BioLogic.VMPdata_colID_dtype_map.items()}
120
+ layout = [] # record layout in file order: (raw_id or None for the flags byte, ...)
121
+ for yid, fid in zip(yids, full):
122
+ if yid in FLAG_IDS: # all flag IDs share one byte, at the position of the first one
123
+ if not any(entry[0] is None for entry in layout):
124
+ layout.append((None, "flags", None, "|u1", True))
125
+ continue
126
+ layout.append((fid, None, None, None, None))
127
+ # names/dtypes for data columns, in order, from yadg's map (it skips flags the same way)
128
+ data_names = [(n, d, u) for n, d, u in zip(names, dtypes, units) if n != "flags"]
129
+ filled, j = [], 0
130
+ for entry in layout:
131
+ if entry[0] is None:
132
+ filled.append(entry)
133
+ continue
134
+ n, d, u = data_names[j]
135
+ j += 1
136
+ fid = entry[0]
137
+ known_yadg = not n.startswith("unknown_")
138
+ known_ref = (fid in gmap) if reference == "galvani" else known_yadg
139
+ if known_ref and known_yadg:
140
+ filled.append((fid, n, u, d, True))
141
+ elif known_ref: # galvani knows it, yadg does not
142
+ gname, gd = gmap[fid]
143
+ filled.append((fid, gname.split("/")[0], None, gd, True))
144
+ else:
145
+ filled.append((fid, None, None, None, False))
146
+ known_width = sum(np.dtype(d).itemsize for _, _, _, d, kn in filled if kn)
147
+ unknown_idx = [i for i, e in enumerate(filled) if not e[4]]
148
+ rest = rec - known_width
149
+ notes = []
150
+ best = None
151
+ if unknown_idx:
152
+ combos = [c for c in itertools.product((1, 2, 4, 8), repeat=len(unknown_idx)) if sum(c) == rest]
153
+ for widths in combos[:200]:
154
+ for types in itertools.product(*[CANDIDATES[w] for w in widths]):
155
+ trial = list(filled)
156
+ for i, t in zip(unknown_idx, types):
157
+ trial[i] = (trial[i][0], None, None, t, False)
158
+ arr = np.frombuffer(data, offset=offset, count=n_points,
159
+ dtype=np.dtype([(f"f{i}", e[3]) for i, e in enumerate(trial)]))
160
+ score = sum(_plausibility(arr[f"f{i}"], t) for i, t in zip(unknown_idx, types))
161
+ if best is None or score > best[0]:
162
+ best = (score, trial)
163
+ if best is None:
164
+ raise ValueError(f"cannot fit {len(unknown_idx)} unknown column(s) into {rest} unexplained bytes per record")
165
+ filled = best[1]
166
+ notes.append(f"{len(unknown_idx)} column ID(s) unknown to {reference}: "
167
+ + ", ".join(str(filled[i][0]) for i in unknown_idx) + f"; widths solved from the record size ({rec} bytes)")
168
+ elif rest != 0:
169
+ raise ValueError(f"record size {rec} does not match the known columns ({known_width} bytes)")
170
+ arr = np.frombuffer(data, offset=offset, count=n_points,
171
+ dtype=np.dtype([(f"f{i}", e[3]) for i, e in enumerate(filled)]))
172
+ cols, pos = [], 0
173
+ for i, (fid, n, u, d, kn) in enumerate(filled):
174
+ if fid is None:
175
+ continue # flags byte
176
+ cols.append(Col(pos, fid, n or f"unknown {fid}", u, d, kn, np.asarray(arr[f"f{i}"])))
177
+ pos += 1
178
+ return MprData(str(path), technique, n_points, rec, cols, notes)
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: rosetta-eclab
3
+ Version: 0.1.1
4
+ Summary: Name the columns of BioLogic EC-Lab .mpr files that your parser does not know - no language model, evidence for every answer
5
+ Author: Nataniel Ataseven
6
+ License-Expression: GPL-3.0-or-later
7
+ Project-URL: Repository, https://github.com/Nataniel0212/rosetta-eclab
8
+ Project-URL: Issues, https://github.com/Nataniel0212/rosetta-eclab/issues
9
+ Keywords: electrochemistry,biologic,ec-lab,mpr,battery,potentiostat,file-format
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: numpy>=2.0
20
+ Requires-Dist: yadg==7.0.1
21
+ Requires-Dist: galvani==0.5.0
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Dynamic: license-file
25
+
26
+ # Rosetta
27
+
28
+ **Rosetta names the columns of BioLogic EC-Lab `.mpr` files that your parser does not know. It shows the evidence for each answer, and it says so when the data cannot support a name.**
29
+
30
+ When the instrument vendor adds a column ID, open-source parsers such as [galvani](https://github.com/echemdata/galvani) stop reading the file until someone works out what the new column is. Rosetta reads the file anyway. It works out the byte width and storage type of each unknown column from the record size, and then identifies the physical quantity from the data. It uses no language model and no network.
31
+
32
+ ```
33
+ $ rosetta drift.mpr --self-check
34
+ drift.mpr
35
+ technique GCPL, 12057 rows, 79 bytes per record
36
+ 1 column ID(s) unknown to galvani: 950; widths solved from the record size (79 bytes)
37
+
38
+ column ID 950 (<f8): Q charge or discharge [C or mA·h] (confidence 0.533)
39
+ evidence: relation: I*dt summed within half cycle: r2 1.0000, scale 0.0002778
40
+ also considered: dQ, step time
41
+
42
+ self-check: hid each of 11 known columns in turn; named 11, 11 correctly, 0 wrong, the rest abstained
43
+ ```
44
+
45
+ *`drift.mpr` is `gcpl.mpr` from the yadg test suite, with one column ID changed to 950, an ID no parser knows (see `tests/test_cli.py`).*
46
+
47
+ ## Install
48
+
49
+ Python 3.10 or later:
50
+
51
+ ```sh
52
+ pip install rosetta-eclab
53
+ ```
54
+
55
+ To run the tests or reproduce the benchmark, clone the repository instead:
56
+
57
+ ```sh
58
+ git clone https://github.com/Nataniel0212/rosetta-eclab.git
59
+ cd rosetta-eclab
60
+ pip install ".[test]"
61
+ ```
62
+
63
+ ## Use
64
+
65
+ ```sh
66
+ rosetta FILE.mpr # name every column unknown to galvani (the default reference parser)
67
+ rosetta FILE.mpr --reference yadg # ... unknown to yadg instead
68
+ rosetta FILE.mpr --self-check # also hide each known column in turn and report how many come back right
69
+ rosetta FILE.mpr --json # machine-readable output
70
+ ```
71
+
72
+ Each unknown column gets one of three answers:
73
+ - **NAME:** an EC-Lab quantity, with its unit, a confidence and the evidence, for example the relation that holds and how well it fits.
74
+ - **DUPLICATE:** identical in every row to a known column.
75
+ - **ABSTAIN:** the data does not support a name. Rosetta prefers this to a guess.
76
+
77
+ `--self-check` shows how well Rosetta knows *your* kind of file. It hides each known column in turn and tries to name it.
78
+
79
+ ## How it works
80
+
81
+ 1. **Read the file** (`namer/mprfile.py`). Columns the reference parser knows are typed from its column map. For unknown IDs the byte width is solved from the record size, and the storage type is chosen by plausibility.
82
+ 2. **Code checks first.** Constant columns and exact copies of a known column are reported as such. Names that a family of siblings makes obvious are filled in, for example the missing harmonic `|Ewe h4|`.
83
+ 3. **Physics library** (`namer/library.py`). Every candidate quantity is tested against the file's other columns:
84
+ - **charges:** current × time, reset per half cycle, cycle or sequence
85
+ - **energies and power:** E × I and its integrals
86
+ - **impedance:** |Z| = √(Re² + Im²), the phase angle, Cs and Cp
87
+ - **clocks and step times**, and **counters** stepping by one
88
+ - **set points** that the measured potential or current holds to
89
+
90
+ Each test reports r² and the fitted scale, which must match the expected unit conversion. A potential can therefore not pass as a current. Fits are robust to the single glitch rows that real records have at step transitions.
91
+ 4. **Statistics fallback** (`namer/fallback.py`). A nearest-neighbour vote over column statistics from public files. It is used only for columns that are not noise, and it is vetoed when a defining relation of the guessed quantity fails in the file.
92
+ 5. **Abstain** when nothing above supports a name.
93
+
94
+ ## How accurate it is
95
+
96
+ The numbers come from five hold-out sets of public EC-Lab files from Zenodo, Battery Data Format and navani: 373 real columns and 95 decoys. None of these files were used during development. Each set was frozen before the namer version that ran on it, and each was run once (`results/open_runs.md`).
97
+
98
+ In every task one real column is hidden and must be named from the data. There are also decoys, where the right answer is not to name the column:
99
+ - noise and random integers
100
+ - *out-of-vocabulary* columns: real columns whose name the tool is not allowed to use, the way a brand-new quantity would look
101
+
102
+ | | Rosetta (no model) | Claude Opus 5.5 | best code-only baseline |
103
+ |---|---|---|---|
104
+ | Real columns named correctly | 350/373 (93.8 %) | 359/373 (96.2 %) | 77–100 % per hold-out¹ |
105
+ | Wrong names on real columns | **5** | 10 | – |
106
+ | Decoys named (should abstain) | **11/95** | 16/95 | 95/95 |
107
+ | Last hold-out (70 columns) | **70/70, 0 wrong, 1 decoy named** | 69/70, 1 wrong, 2 decoys named | 78.6 % |
108
+
109
+ ¹ There are two code-only baselines. A technique prior names the column that is typical for this technique and missing here. A nearest-neighbour vote compares the column with labelled columns from other files. Both are strong when files have standard column sets, but they name every decoy and cannot name a genuinely new column.
110
+
111
+ The namer improved between hold-outs, so the total includes earlier versions. The history, including the language-model experiments that came first, is in [`docs/history.md`](https://github.com/Nataniel0212/rosetta-eclab/blob/main/docs/history.md).
112
+
113
+ ### Limitations
114
+
115
+ - **Values EC-Lab computes after the fact** cannot be reconstructed reliably. For example, a capacitance is held per segment.
116
+ - **Noise-like DC currents** in impedance records, and **external analog inputs** with no sibling column, usually get ABSTAIN.
117
+ - **Two quantities with the same definition in a file** cannot be told apart from the data. In galvanostatic cycling, for example, `dQ` and `Q charge or discharge` reset at the same points. A prior over how common each name is for the technique decides between them, and the alternative is shown.
118
+ - **Supported formats:** EC-Lab data-module versions 2, 3, 10 and 11. Very old files (version 0) are not supported yet.
119
+
120
+ ## Reproduce the benchmark
121
+
122
+ ```sh
123
+ python scripts/fetch_testdata.py # development corpus (yadg + galvani test files)
124
+ python scripts/fetch_holdout.py holdout5 # any hold-out; see DATA_SOURCES.md
125
+ python -m spike2.tasks # v2 benchmark tasks (hashes in prereg2/tasks_manifest.json)
126
+ python -m holdout.build holdout5 # hold-out tasks (hash in holdout/manifest_holdout5.json)
127
+ python -m namer.evaluate --split holdout5 # run the namer
128
+ python -m spike2.baselines --split holdout5 # code-only baselines
129
+ python -m bench.claude_cli --split holdout5 --score rules # score (Claude runs need the Claude Code CLI)
130
+ python -m namer.golden check # regression check: every answer must match tests/golden
131
+ pytest tests
132
+ ```
133
+
134
+ ## Data and licence
135
+
136
+ Rosetta is free software under the **GNU General Public License v3.0 or later** (see `LICENSE`). It uses [yadg](https://github.com/dgbowl/yadg) and [galvani](https://github.com/echemdata/galvani), both GPL. Measurement files are downloaded at run time and never committed. Sources, licences and attributions are listed in [`DATA_SOURCES.md`](https://github.com/Nataniel0212/rosetta-eclab/blob/main/DATA_SOURCES.md). Rosetta is not affiliated with BioLogic.
@@ -0,0 +1,31 @@
1
+ rosetta_eclab/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ rosetta_eclab/cli.py,sha256=oe_hdjIQFOtHIkXqYqeVf2ijG1HJVHE-F5OJnqy5E6k,5087
3
+ rosetta_eclab/debug.py,sha256=ddsJm2aV-nUktFlIAOym7yXO0P-olMCv2T1aRObSzpk,3461
4
+ rosetta_eclab/engine.py,sha256=YVQQ4zAoxJn193212Qe5FGYFUvh8IjP8EjE9u9Md5hU,9006
5
+ rosetta_eclab/evaluate.py,sha256=ZoTh1LIrz-RJ5G1wiZ3flxU-ODPJXnV-BfCCKHkHaIM,9031
6
+ rosetta_eclab/fallback.py,sha256=enxXyy-IJGm7zwgWrn-jvZsRPvC6rvcuUQg7rw1E9LQ,5935
7
+ rosetta_eclab/golden.py,sha256=_oONIABpdWFHoHXW--0uEh-UwSi17OzEe5lawSJQZDU,2947
8
+ rosetta_eclab/library.py,sha256=3LG7fOJYXRNgEtsvxQv3WuzgKVJCe3YkzngPMqSpQkM,35677
9
+ rosetta_eclab/misses.py,sha256=XBw6yfCDFIkXV5GMtJNnyKqqqV7gESjjm1xIYd_qUKE,1550
10
+ rosetta_eclab/mprfile.py,sha256=dDEVZe3aPzzqEKH10q44Re2y3wYnEkUwzNkRoF-jifU,7471
11
+ rosetta_eclab/data/stats.json,sha256=MNp2xOiOZHCbmGfdACBDV5zWOoatIH7pKp3Be8rqtK8,28652
12
+ rosetta_eclab/data/stats_features.npy,sha256=-bCgLTO5DNiJLXy-mXFhuRpX_X9jY1eTYk8C-h1hfkE,73728
13
+ rosetta_eclab-0.1.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
14
+ spike2/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ spike2/agent.py,sha256=FtjyJbzynSidjfOsKit9fH-zWsbJ-Q2LwCX1SAVGPkE,17325
16
+ spike2/api_check.py,sha256=EkyVF6UJwK-_GIPRI6Zi113McMKhMGbtHBTobAwnCt4,5725
17
+ spike2/baselines.py,sha256=P6gpXGFdZVAQMQDBlgi01luTXUd63_bvlmI1d4n3Rgs,6530
18
+ spike2/corpus.py,sha256=iH-WIZf6mnjO1EHKDc2esurJTeIAu-J9L1wV3m_gUJo,6081
19
+ spike2/dsl.py,sha256=jlogDcHMFEuA3o-5ex-DhFXi55huIZ0hOOviNIeZ2Lw,6401
20
+ spike2/elim.py,sha256=hkPjy0xHEwELjpU4UpICuhEL2fOa5c6BRNnl5tPljXI,1971
21
+ spike2/features.py,sha256=BR_5zLGx85rMkWSoyRgnXjK4pC7VkM0fvhWkyHz8arY,4752
22
+ spike2/lock.py,sha256=tyg354VnCjPzw6zvluWDOq--MZa1YUS2X42z76HVe9s,2708
23
+ spike2/run.py,sha256=PtJszxAo2QiZfidjM6AtgQg5zG5bFPEPUiB4Pvup4cs,10407
24
+ spike2/score.py,sha256=ypoeiUXOSW_t9FZdQQtPa1t4zgMWDnh1FEXEJe02Va0,10869
25
+ spike2/tasks.py,sha256=bWm6QcEiUq5K8F4lDG4i4OKhw0H7RyLh6BikY6Ns_vQ,15805
26
+ spike2/vocab.py,sha256=MtcNoLo5aLS-uUXiUJoih74Dvy6CpOlKX0cqCckSuJ8,4537
27
+ rosetta_eclab-0.1.1.dist-info/METADATA,sha256=5Ti6e2N7nppDfmpFyRwM77zj4vMvZr3SqBfy9VWw3qw,8329
28
+ rosetta_eclab-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
29
+ rosetta_eclab-0.1.1.dist-info/entry_points.txt,sha256=3XKxa49VWnGYZBrFuzpqS0qMW5hKAkBgyIsk2wfgSxk,51
30
+ rosetta_eclab-0.1.1.dist-info/top_level.txt,sha256=8IvVzyW3bwuHVOAFu1n8uGLIn_M2F3jNORuvQH6p-1I,21
31
+ rosetta_eclab-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rosetta = rosetta_eclab.cli:main