Skip to content

Commit 5a43f37

Browse files
committed
Add measurelink sequence files
1 parent 409bdd0 commit 5a43f37

File tree

4 files changed

+203
-0
lines changed

4 files changed

+203
-0
lines changed

Stoner/formats/data/zip.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# -*- coding: utf-8 -*-
22
"""Loader for zip files."""
3+
import json
34
import pathlib
45
import zipfile as zf
56
from os import path
67
from traceback import format_exc
78

9+
import chardet
10+
811
from ...compat import path_types, str2bytes
912
from ...core.data import Data
1013
from ...core.exceptions import StonerLoadError
@@ -14,6 +17,8 @@
1417
from ..decorators import register_loader, register_saver
1518
from ..utils.zip import test_is_zip
1619

20+
from ...tools.json import flatten_json, find_paths, find_parent_dicts
21+
1722

1823
def _split_filename(filename: Filename, **kwargs: Kwargs) -> Filename:
1924
"""Try to get the member and filename parts."""
@@ -27,6 +32,48 @@ def _split_filename(filename: Filename, **kwargs: Kwargs) -> Filename:
2732
return filename
2833

2934

35+
@register_loader(patterns=(".mlseq", 16), mime_types=("application/zip", 16), name="MeasureLinkFile", what="Data")
36+
def load_measure_linkfile(new_data: Data, *args: Args, **kwargs: Kwargs) -> Data:
37+
"""Load a MeasureLink sequence file and assemble as a data object.
38+
39+
Args:
40+
new_data (Data):
41+
Data instance into whoch to load the new data.
42+
*args:
43+
Other positional arguments passed to get_filename.
44+
45+
Keyword Arguments:
46+
**kwargs:
47+
Other keyword arguments passed to get_filename.
48+
49+
Returns:
50+
(Data):
51+
Loaded Data instance.
52+
53+
Notes:
54+
`.mlseq` files are actually zip archives containing a collection of json files and a flat list of sub-folders
55+
The subfolders contain json for the node operations and optionally (if the key HasData is True) a csv file.
56+
"""
57+
filename, args, kwargs = get_filename(args, kwargs)
58+
with zf.ZipFile(filename, "r") as seq:
59+
if "FileInfo.json" not in seq.namelist():
60+
raise StonerLoadError("Missing the Measurelink Sequence FileInfo.json entry")
61+
with seq.open("FileInfo.json", "r") as fileinfo_json:
62+
fileinfo = fileinfo_json.read()
63+
fileinfo = fileinfo.decode(chardet.detect(fileinfo)["encoding"])
64+
fileinfo = json.loads(fileinfo)
65+
new_data.metadata.update(flatten_json(fileinfo))
66+
with seq.open("Model.json", "r") as model_json:
67+
model = model_json.read()
68+
model = model.decode(chardet.detect(model)["encoding"])
69+
has_data = find_paths(model, "HasData", True)
70+
new_data.model = model
71+
new_data.has_data = has_data
72+
73+
new_data.filename = filename
74+
return new_data
75+
76+
3077
@register_loader(patterns=(".zip", 16), mime_types=("application/zip", 16), name="ZippedFile", what="Data")
3178
def load_zipfile(new_data: Data, *args: Args, **kwargs: Kwargs) -> Data:
3279
"""Load a file from the zip file, opening it as necessary.

Stoner/tools/json.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# -*- coding: utf-8 -*-
2+
"""Tools for manipulating json."""
3+
4+
5+
def flatten_json(data, parent_key=""):
6+
"""Flatten a nested JSON-like structure into a dotted-key dictionary.
7+
8+
Args:
9+
data: The JSON-like structure to flatten. May contain dictionaries,
10+
lists, and scalar values.
11+
parent_key: The prefix to prepend to keys in the flattened output.
12+
Used internally during recursion.
13+
14+
Returns:
15+
dict: A flat dictionary mapping dotted/bracketed key paths to scalar
16+
values.
17+
18+
This function recursively flattens nested dictionaries and lists into a
19+
single-level dictionary where:
20+
21+
* Nested dictionary keys are joined using dot notation.
22+
* List indices are represented using bracket notation, e.g. "[0]".
23+
* Scalar values (str, int, float, bool, None) become the final values.
24+
25+
The function is pure: it does not mutate input data and does not rely on
26+
side effects. Each recursive call returns a new dictionary, and the caller
27+
merges results.
28+
29+
Examples:
30+
>>> flatten_json({"a": {"b": 1}, "c": [10, 20]})
31+
{'a.b': 1, 'c[0]': 10, 'c[1]': 20}
32+
33+
>>> flatten_json({"x": {"y": {"z": True}}})
34+
{'x.y.z': True}
35+
"""
36+
items = {}
37+
38+
match data:
39+
case dict():
40+
for key, value in data.items():
41+
new_key = f"{parent_key}.{key}" if parent_key else key
42+
items.update(flatten_json(value, new_key))
43+
44+
case list():
45+
for idx, value in enumerate(data):
46+
new_key = f"{parent_key}[{idx}]"
47+
items.update(flatten_json(value, new_key))
48+
49+
case _:
50+
items[parent_key] = data
51+
52+
return items
53+
54+
55+
def find_paths(data, target_key, target_value, path=None):
56+
"""Yield all paths leading to a key/value pair in a nested structure.
57+
58+
Args:
59+
data: The JSON-like structure to search. May contain dictionaries,
60+
lists, and scalar values.
61+
target_key: The dictionary key to match.
62+
target_value: The value that must be associated with `target_key`
63+
for a path to be considered a match.
64+
path: Internal recursion parameter. A list representing the path
65+
taken so far. Users should not supply this argument.
66+
67+
Yields:
68+
list[str]: A list of path components representing the full ancestry
69+
from the root to the matching key/value pair.
70+
71+
72+
This function recursively traverses a nested JSON-like structure
73+
(dictionaries, lists, and scalar values) and yields every path where
74+
`target_key` equals `target_value`. Paths are returned as lists of
75+
components, where dictionary keys are plain strings and list indices
76+
are represented as bracketed strings (e.g., "[0]").
77+
78+
Examples:
79+
>>> data = {"A": {"B": {"HasData": True}}}
80+
>>> list(find_paths(data, "HasData", True))
81+
[['A', 'B', 'HasData']]
82+
83+
>>> data = {"items": [{"HasData": True}, {"HasData": False}]}
84+
>>> list(find_paths(data, "HasData", True))
85+
[['items', '[0]', 'HasData']]
86+
"""
87+
if path is None:
88+
path = []
89+
90+
match data:
91+
case dict():
92+
for key, value in data.items():
93+
new_path = path + [key]
94+
if key == target_key and value == target_value:
95+
yield new_path
96+
yield from find_paths(value, target_key, target_value, new_path)
97+
98+
case list():
99+
for idx, value in enumerate(data):
100+
new_path = path + [idx]
101+
yield from find_paths(value, target_key, target_value, new_path)
102+
103+
case _:
104+
return
105+
106+
107+
def find_parent_dicts(data, target_key, target_value):
108+
"""Yield dictionaries that contain a matching key/value pair.
109+
110+
Args:
111+
data: The JSON-like structure to search. May contain dictionaries,
112+
lists, and scalar values.
113+
target_key: The dictionary key to match.
114+
target_value: The required value associated with `target_key`.
115+
116+
Yields:
117+
dict: A dictionary that contains the matching key/value pair.
118+
119+
This function recursively searches a nested JSON-like structure and
120+
yields every dictionary in which `target_key` exists and its value
121+
equals `target_value`. Unlike `find_paths`, this function returns the
122+
dictionary object itself, allowing callers to inspect sibling keys or
123+
modify the parent structure.
124+
125+
Examples:
126+
>>> data = {"A": {"B": {"HasData": True, "Other": 5}}}
127+
>>> list(find_parent_dicts(data, "HasData", True))
128+
[{'HasData': True, 'Other': 5}]
129+
130+
>>> data = {"items": [{"HasData": True}, {"HasData": False}]}
131+
>>> list(find_parent_dicts(data, "HasData", True))
132+
[{'HasData': True}]
133+
"""
134+
match data:
135+
case dict():
136+
if target_key in data and data[target_key] == target_value:
137+
yield data
138+
for value in data.values():
139+
yield from find_parent_dicts(value, target_key, target_value)
140+
141+
case list():
142+
for value in data:
143+
yield from find_parent_dicts(value, target_key, target_value)
144+
145+
case _:
146+
return
147+
148+
149+
if __name__ == "__main__":
150+
data = {
151+
"key1": {"subkey1": 1, "subkey2": 2},
152+
"key2": ["value2.1", {"subkey3": "value2.2.1", "subkey4": "value2.2.2", "HasData": True}],
153+
}
154+
output = flatten_json(data)
155+
output2 = [pth for pth in find_paths(data, "HasData", True)]
156+
output3 = [pth for pth in find_parent_dicts(data, "HasData", True)]

sample-data/Rxx_Rxy_v_T.mlseq

122 KB
Binary file not shown.

sample-data/Sequence~002.mlseq

39.6 KB
Binary file not shown.

0 commit comments

Comments
 (0)