lorax-arg 0.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.
- lorax/buffer.py +43 -0
- lorax/cache/__init__.py +43 -0
- lorax/cache/csv_tree_graph.py +59 -0
- lorax/cache/disk.py +467 -0
- lorax/cache/file_cache.py +142 -0
- lorax/cache/file_context.py +72 -0
- lorax/cache/lru.py +90 -0
- lorax/cache/tree_graph.py +293 -0
- lorax/cli.py +312 -0
- lorax/cloud/__init__.py +0 -0
- lorax/cloud/gcs_utils.py +205 -0
- lorax/constants.py +66 -0
- lorax/context.py +80 -0
- lorax/csv/__init__.py +7 -0
- lorax/csv/config.py +250 -0
- lorax/csv/layout.py +182 -0
- lorax/csv/newick_tree.py +234 -0
- lorax/handlers.py +998 -0
- lorax/lineage.py +456 -0
- lorax/loaders/__init__.py +0 -0
- lorax/loaders/csv_loader.py +10 -0
- lorax/loaders/loader.py +31 -0
- lorax/loaders/tskit_loader.py +119 -0
- lorax/lorax_app.py +75 -0
- lorax/manager.py +58 -0
- lorax/metadata/__init__.py +0 -0
- lorax/metadata/loader.py +426 -0
- lorax/metadata/mutations.py +146 -0
- lorax/modes.py +190 -0
- lorax/pg.py +183 -0
- lorax/redis_utils.py +30 -0
- lorax/routes.py +137 -0
- lorax/session_manager.py +206 -0
- lorax/sockets/__init__.py +55 -0
- lorax/sockets/connection.py +99 -0
- lorax/sockets/debug.py +47 -0
- lorax/sockets/decorators.py +112 -0
- lorax/sockets/file_ops.py +200 -0
- lorax/sockets/lineage.py +307 -0
- lorax/sockets/metadata.py +232 -0
- lorax/sockets/mutations.py +154 -0
- lorax/sockets/node_search.py +535 -0
- lorax/sockets/tree_layout.py +117 -0
- lorax/sockets/utils.py +10 -0
- lorax/tree_graph/__init__.py +12 -0
- lorax/tree_graph/tree_graph.py +689 -0
- lorax/utils.py +124 -0
- lorax_app/__init__.py +4 -0
- lorax_app/app.py +159 -0
- lorax_app/cli.py +114 -0
- lorax_app/static/X.png +0 -0
- lorax_app/static/assets/index-BCEGlUFi.js +2361 -0
- lorax_app/static/assets/index-iKjzUpA9.css +1 -0
- lorax_app/static/assets/localBackendWorker-BaWwjSV_.js +2 -0
- lorax_app/static/assets/renderDataWorker-BKLdiU7J.js +2 -0
- lorax_app/static/gestures/gesture-flick.ogv +0 -0
- lorax_app/static/gestures/gesture-two-finger-scroll.ogv +0 -0
- lorax_app/static/index.html +14 -0
- lorax_app/static/logo.png +0 -0
- lorax_app/static/lorax-logo.png +0 -0
- lorax_app/static/vite.svg +1 -0
- lorax_arg-0.1.dist-info/METADATA +131 -0
- lorax_arg-0.1.dist-info/RECORD +66 -0
- lorax_arg-0.1.dist-info/WHEEL +5 -0
- lorax_arg-0.1.dist-info/entry_points.txt +4 -0
- lorax_arg-0.1.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Node search event handlers for Lorax Socket.IO.
|
|
3
|
+
|
|
4
|
+
Handles search_nodes and get_highlight_positions_event events.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
from lorax.context import tree_graph_cache, csv_tree_graph_cache
|
|
10
|
+
from lorax.constants import ERROR_NO_FILE_LOADED
|
|
11
|
+
from lorax.handlers import (
|
|
12
|
+
should_shift_csv_tips,
|
|
13
|
+
search_nodes_in_trees,
|
|
14
|
+
get_highlight_positions,
|
|
15
|
+
get_multi_value_highlight_positions,
|
|
16
|
+
)
|
|
17
|
+
from lorax.cache import get_file_context
|
|
18
|
+
from lorax.sockets.decorators import require_session
|
|
19
|
+
from lorax.sockets.utils import is_csv_session_file
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _get_or_parse_csv_tree_graph(ctx, session_id: str, tree_idx: int):
|
|
23
|
+
"""
|
|
24
|
+
Return a parsed CSV Newick tree graph, using CsvTreeGraphCache.
|
|
25
|
+
|
|
26
|
+
The cache is populated by the layout pipeline, but node search/highlight
|
|
27
|
+
should also be able to populate it on demand.
|
|
28
|
+
"""
|
|
29
|
+
cached = await csv_tree_graph_cache.get(session_id, int(tree_idx))
|
|
30
|
+
if cached is not None:
|
|
31
|
+
return cached
|
|
32
|
+
|
|
33
|
+
# Import lazily to avoid CSV dependencies on tskit paths.
|
|
34
|
+
import pandas as pd
|
|
35
|
+
from lorax.csv.newick_tree import parse_newick_to_tree
|
|
36
|
+
|
|
37
|
+
df = ctx.tree_sequence
|
|
38
|
+
if not isinstance(df, pd.DataFrame):
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
newick_str = df.iloc[int(tree_idx)].get("newick")
|
|
43
|
+
except Exception:
|
|
44
|
+
newick_str = None
|
|
45
|
+
if newick_str is None or pd.isna(newick_str):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
# Parse with same normalization settings as the layout pipeline.
|
|
49
|
+
times_values = ctx.config.get("times", {}).get("values", [0.0, 1.0])
|
|
50
|
+
max_branch_length = float(times_values[1]) if len(times_values) > 1 else 1.0
|
|
51
|
+
samples_order = ctx.config.get("samples") or []
|
|
52
|
+
shift_tips_to_one = should_shift_csv_tips(ctx.file_path)
|
|
53
|
+
tree_max_branch_length = None
|
|
54
|
+
if "max_branch_length" in df.columns:
|
|
55
|
+
try:
|
|
56
|
+
v = df.iloc[int(tree_idx)].get("max_branch_length")
|
|
57
|
+
if v is not None and not (isinstance(v, float) and pd.isna(v)) and str(v).strip() != "":
|
|
58
|
+
tree_max_branch_length = float(v)
|
|
59
|
+
except Exception:
|
|
60
|
+
tree_max_branch_length = None
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
graph = await asyncio.to_thread(
|
|
64
|
+
parse_newick_to_tree,
|
|
65
|
+
str(newick_str),
|
|
66
|
+
max_branch_length,
|
|
67
|
+
samples_order=samples_order,
|
|
68
|
+
tree_max_branch_length=tree_max_branch_length,
|
|
69
|
+
shift_tips_to_one=shift_tips_to_one,
|
|
70
|
+
)
|
|
71
|
+
except Exception:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
await csv_tree_graph_cache.set(session_id, int(tree_idx), graph)
|
|
75
|
+
return graph
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _find_node_index(graph, node_id: int):
|
|
79
|
+
"""Find the array index in a NewickTreeGraph for a given node_id."""
|
|
80
|
+
# node_id is stored as a numpy array; use vectorized equality.
|
|
81
|
+
try:
|
|
82
|
+
idxs = (graph.node_id == int(node_id)).nonzero()[0]
|
|
83
|
+
if idxs.size == 0:
|
|
84
|
+
return None
|
|
85
|
+
return int(idxs[0])
|
|
86
|
+
except Exception:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_node_id_to_index(graph):
|
|
91
|
+
"""Build a node_id -> array index dict for a NewickTreeGraph."""
|
|
92
|
+
try:
|
|
93
|
+
return {int(nid): i for i, nid in enumerate(graph.node_id)}
|
|
94
|
+
except Exception:
|
|
95
|
+
return {}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _compute_csv_lineage_path(graph, seed_node_id: int, node_id_to_index: dict):
|
|
99
|
+
"""
|
|
100
|
+
Compute ancestry path (tip -> root) for a CSV NewickTreeGraph.
|
|
101
|
+
|
|
102
|
+
Returns a list of node_ids starting at seed_node_id, following parent_id until -1.
|
|
103
|
+
Includes a cycle guard to avoid infinite loops on malformed graphs.
|
|
104
|
+
"""
|
|
105
|
+
path = []
|
|
106
|
+
current = int(seed_node_id)
|
|
107
|
+
visited = set()
|
|
108
|
+
|
|
109
|
+
while current != -1:
|
|
110
|
+
if current in visited:
|
|
111
|
+
break
|
|
112
|
+
visited.add(current)
|
|
113
|
+
path.append(current)
|
|
114
|
+
|
|
115
|
+
idx = node_id_to_index.get(current)
|
|
116
|
+
if idx is None:
|
|
117
|
+
break
|
|
118
|
+
parent = int(graph.parent_id[idx])
|
|
119
|
+
current = parent
|
|
120
|
+
|
|
121
|
+
return path
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def register_node_search_events(sio):
|
|
125
|
+
"""Register node search socket events."""
|
|
126
|
+
|
|
127
|
+
@sio.event
|
|
128
|
+
async def search_nodes(sid, data):
|
|
129
|
+
"""Socket event to search for nodes matching metadata values in trees.
|
|
130
|
+
|
|
131
|
+
This is used for highlighting nodes when searching/filtering by metadata.
|
|
132
|
+
Returns node_ids for matching samples in each tree, and optionally lineage paths.
|
|
133
|
+
Frontend computes positions using the post-order layout data.
|
|
134
|
+
|
|
135
|
+
data: {
|
|
136
|
+
lorax_sid: str,
|
|
137
|
+
sample_names: [str], # Sample names to search for
|
|
138
|
+
tree_indices: [int], # Tree indices to search in
|
|
139
|
+
show_lineages: bool, # Whether to compute lineage paths
|
|
140
|
+
sample_colors: dict # Optional {sample_name: [r,g,b,a]}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
Returns: {
|
|
144
|
+
highlights: {tree_idx: [{node_id, name}]},
|
|
145
|
+
lineage: {tree_idx: [{path_node_ids: [int], color}]}
|
|
146
|
+
}
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
lorax_sid = data.get("lorax_sid")
|
|
150
|
+
session = await require_session(lorax_sid, sid, sio)
|
|
151
|
+
if not session:
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
if not session.file_path:
|
|
155
|
+
print(f"⚠️ No file loaded for session {lorax_sid}")
|
|
156
|
+
await sio.emit("error", {
|
|
157
|
+
"code": ERROR_NO_FILE_LOADED,
|
|
158
|
+
"message": "No file loaded. Please load a file first."
|
|
159
|
+
}, to=sid)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if is_csv_session_file(session.file_path):
|
|
163
|
+
sample_names = data.get("sample_names", [])
|
|
164
|
+
tree_indices = data.get("tree_indices", [])
|
|
165
|
+
show_lineages = data.get("show_lineages", False)
|
|
166
|
+
|
|
167
|
+
# CSV mode currently supports only tip highlights (no lineage paths).
|
|
168
|
+
if show_lineages:
|
|
169
|
+
show_lineages = False
|
|
170
|
+
|
|
171
|
+
if not sample_names or not tree_indices:
|
|
172
|
+
await sio.emit("search-nodes-result", {"highlights": {}, "lineage": {}}, to=sid)
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
ctx = await get_file_context(session.file_path)
|
|
176
|
+
if ctx is None:
|
|
177
|
+
await sio.emit("search-nodes-result", {"error": "Failed to load CSV"}, to=sid)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
samples_order = ctx.config.get("samples") or []
|
|
181
|
+
sample_id_map = {str(name): idx for idx, name in enumerate(samples_order)}
|
|
182
|
+
|
|
183
|
+
highlights = {}
|
|
184
|
+
|
|
185
|
+
for tree_idx in tree_indices:
|
|
186
|
+
tree_idx = int(tree_idx)
|
|
187
|
+
graph = await _get_or_parse_csv_tree_graph(ctx, lorax_sid, tree_idx)
|
|
188
|
+
if graph is None:
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
hits = []
|
|
192
|
+
for name in sample_names:
|
|
193
|
+
node_id = sample_id_map.get(str(name))
|
|
194
|
+
if node_id is None:
|
|
195
|
+
continue
|
|
196
|
+
if _find_node_index(graph, node_id) is None:
|
|
197
|
+
continue
|
|
198
|
+
hits.append({"node_id": int(node_id), "name": str(name)})
|
|
199
|
+
|
|
200
|
+
if hits:
|
|
201
|
+
highlights[tree_idx] = hits
|
|
202
|
+
|
|
203
|
+
await sio.emit("search-nodes-result", {"highlights": highlights, "lineage": {}}, to=sid)
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
sample_names = data.get("sample_names", [])
|
|
207
|
+
tree_indices = data.get("tree_indices", [])
|
|
208
|
+
show_lineages = data.get("show_lineages", False)
|
|
209
|
+
sample_colors = data.get("sample_colors", {})
|
|
210
|
+
|
|
211
|
+
# Check cache and warn if trees not found (they should be cached from layout)
|
|
212
|
+
if tree_indices:
|
|
213
|
+
uncached = []
|
|
214
|
+
for tree_idx in tree_indices:
|
|
215
|
+
cached = await tree_graph_cache.get(lorax_sid, int(tree_idx))
|
|
216
|
+
if cached is None:
|
|
217
|
+
uncached.append(tree_idx)
|
|
218
|
+
if uncached:
|
|
219
|
+
print(f"⚠️ WARNING: Trees {uncached} not in cache for session {lorax_sid[:8]}... "
|
|
220
|
+
f"(expected from layout render)")
|
|
221
|
+
|
|
222
|
+
if not sample_names or not tree_indices:
|
|
223
|
+
await sio.emit("search-nodes-result", {
|
|
224
|
+
"highlights": {},
|
|
225
|
+
"lineage": {}
|
|
226
|
+
}, to=sid)
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
ctx = await get_file_context(session.file_path)
|
|
230
|
+
if ctx is None:
|
|
231
|
+
await sio.emit("search-nodes-result", {
|
|
232
|
+
"error": "Failed to load tree sequence"
|
|
233
|
+
}, to=sid)
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
ts = ctx.tree_sequence
|
|
237
|
+
|
|
238
|
+
result = await asyncio.to_thread(
|
|
239
|
+
search_nodes_in_trees,
|
|
240
|
+
ts,
|
|
241
|
+
sample_names,
|
|
242
|
+
tree_indices,
|
|
243
|
+
show_lineages,
|
|
244
|
+
sample_colors
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
await sio.emit("search-nodes-result", result, to=sid)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
print(f"❌ Search nodes error: {e}")
|
|
250
|
+
await sio.emit("search-nodes-result", {"error": str(e)}, to=sid)
|
|
251
|
+
|
|
252
|
+
@sio.event
|
|
253
|
+
async def get_highlight_positions_event(sid, data):
|
|
254
|
+
"""Socket event to get positions for all tip nodes matching a metadata value.
|
|
255
|
+
|
|
256
|
+
Returns positions for ALL matching nodes, ignoring sparsification.
|
|
257
|
+
Used for highlighting nodes that may not be currently rendered.
|
|
258
|
+
|
|
259
|
+
data: {
|
|
260
|
+
lorax_sid: str,
|
|
261
|
+
metadata_key: str, # Metadata key to filter by
|
|
262
|
+
metadata_value: str, # Metadata value to match
|
|
263
|
+
tree_indices: [int] # Tree indices to compute positions for
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
Returns: {
|
|
267
|
+
positions: [{node_id, tree_idx, x, y}, ...]
|
|
268
|
+
}
|
|
269
|
+
"""
|
|
270
|
+
try:
|
|
271
|
+
lorax_sid = data.get("lorax_sid")
|
|
272
|
+
session = await require_session(lorax_sid, sid, sio)
|
|
273
|
+
if not session:
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
if not session.file_path:
|
|
277
|
+
print(f"⚠️ No file loaded for session {lorax_sid}")
|
|
278
|
+
await sio.emit("error", {
|
|
279
|
+
"code": ERROR_NO_FILE_LOADED,
|
|
280
|
+
"message": "No file loaded. Please load a file first."
|
|
281
|
+
}, to=sid)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
if is_csv_session_file(session.file_path):
|
|
285
|
+
metadata_key = data.get("metadata_key")
|
|
286
|
+
metadata_value = data.get("metadata_value")
|
|
287
|
+
tree_indices = data.get("tree_indices", [])
|
|
288
|
+
|
|
289
|
+
if metadata_key != "sample":
|
|
290
|
+
await sio.emit("highlight-positions-result", {"positions": []}, to=sid)
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
if metadata_value is None or not tree_indices:
|
|
294
|
+
await sio.emit("highlight-positions-result", {"positions": []}, to=sid)
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
ctx = await get_file_context(session.file_path)
|
|
298
|
+
if ctx is None:
|
|
299
|
+
await sio.emit("highlight-positions-result", {"error": "Failed to load CSV"}, to=sid)
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
samples_order = ctx.config.get("samples") or []
|
|
303
|
+
sample_id_map = {str(name): idx for idx, name in enumerate(samples_order)}
|
|
304
|
+
target_node_id = sample_id_map.get(str(metadata_value))
|
|
305
|
+
if target_node_id is None:
|
|
306
|
+
await sio.emit("highlight-positions-result", {"positions": []}, to=sid)
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
positions = []
|
|
310
|
+
for tree_idx in tree_indices:
|
|
311
|
+
tree_idx = int(tree_idx)
|
|
312
|
+
graph = await _get_or_parse_csv_tree_graph(ctx, lorax_sid, tree_idx)
|
|
313
|
+
if graph is None:
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
arr_idx = _find_node_index(graph, target_node_id)
|
|
317
|
+
if arr_idx is None:
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
# Match the coordinate convention used by CSV layout buffer:
|
|
321
|
+
# build_csv_layout_response swaps (time->x, layout->y).
|
|
322
|
+
positions.append(
|
|
323
|
+
{
|
|
324
|
+
"node_id": int(target_node_id),
|
|
325
|
+
"tree_idx": int(tree_idx),
|
|
326
|
+
# Match tskit highlight convention: x=layout, y=time.
|
|
327
|
+
# (tskit highlight uses TreeGraph internal coords, not the swapped PyArrow coords)
|
|
328
|
+
"x": float(graph.x[arr_idx]),
|
|
329
|
+
"y": float(graph.y[arr_idx]),
|
|
330
|
+
}
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
await sio.emit("highlight-positions-result", {"positions": positions}, to=sid)
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
metadata_key = data.get("metadata_key")
|
|
337
|
+
metadata_value = data.get("metadata_value")
|
|
338
|
+
tree_indices = data.get("tree_indices", [])
|
|
339
|
+
|
|
340
|
+
if not metadata_key or metadata_value is None:
|
|
341
|
+
await sio.emit("highlight-positions-result", {
|
|
342
|
+
"error": "Missing metadata_key or metadata_value"
|
|
343
|
+
}, to=sid)
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
if not tree_indices:
|
|
347
|
+
await sio.emit("highlight-positions-result", {"positions": []}, to=sid)
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
ctx = await get_file_context(session.file_path)
|
|
351
|
+
if ctx is None:
|
|
352
|
+
await sio.emit("highlight-positions-result", {
|
|
353
|
+
"error": "Failed to load tree sequence"
|
|
354
|
+
}, to=sid)
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
ts = ctx.tree_sequence
|
|
358
|
+
|
|
359
|
+
result = await get_highlight_positions(
|
|
360
|
+
ts,
|
|
361
|
+
session.file_path,
|
|
362
|
+
metadata_key,
|
|
363
|
+
metadata_value,
|
|
364
|
+
tree_indices,
|
|
365
|
+
lorax_sid,
|
|
366
|
+
tree_graph_cache
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
await sio.emit("highlight-positions-result", result, to=sid)
|
|
370
|
+
except Exception as e:
|
|
371
|
+
print(f"❌ Get highlight positions error: {e}")
|
|
372
|
+
await sio.emit("highlight-positions-result", {"error": str(e)}, to=sid)
|
|
373
|
+
|
|
374
|
+
@sio.event
|
|
375
|
+
async def search_metadata_multi_event(sid, data):
|
|
376
|
+
"""Socket event for multi-value metadata search.
|
|
377
|
+
|
|
378
|
+
Returns positions for tip nodes matching ANY of the metadata values,
|
|
379
|
+
grouped by value for per-value coloring with OR logic.
|
|
380
|
+
|
|
381
|
+
data: {
|
|
382
|
+
lorax_sid: str,
|
|
383
|
+
metadata_key: str, # Metadata key to filter by
|
|
384
|
+
metadata_values: [str], # Array of values (OR logic)
|
|
385
|
+
tree_indices: [int], # Tree indices to compute positions for
|
|
386
|
+
show_lineages: bool # Whether to compute lineage paths
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
Emits: "search-metadata-multi-result" with:
|
|
390
|
+
{
|
|
391
|
+
positions_by_value: {"Africa": [{node_id, tree_idx, x, y}, ...], ...},
|
|
392
|
+
lineages: {"Africa": {tree_idx: [{path_node_ids, color}]}} if show_lineages,
|
|
393
|
+
total_count: int
|
|
394
|
+
}
|
|
395
|
+
"""
|
|
396
|
+
try:
|
|
397
|
+
lorax_sid = data.get("lorax_sid")
|
|
398
|
+
session = await require_session(lorax_sid, sid, sio)
|
|
399
|
+
if not session:
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
if not session.file_path:
|
|
403
|
+
print(f"⚠️ No file loaded for session {lorax_sid}")
|
|
404
|
+
await sio.emit("error", {
|
|
405
|
+
"code": ERROR_NO_FILE_LOADED,
|
|
406
|
+
"message": "No file loaded. Please load a file first."
|
|
407
|
+
}, to=sid)
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
if is_csv_session_file(session.file_path):
|
|
411
|
+
metadata_key = data.get("metadata_key")
|
|
412
|
+
metadata_values = data.get("metadata_values", [])
|
|
413
|
+
tree_indices = data.get("tree_indices", [])
|
|
414
|
+
show_lineages = bool(data.get("show_lineages", False))
|
|
415
|
+
|
|
416
|
+
if metadata_key != "sample":
|
|
417
|
+
await sio.emit(
|
|
418
|
+
"search-metadata-multi-result",
|
|
419
|
+
{"positions_by_value": {}, "lineages": {}, "total_count": 0},
|
|
420
|
+
to=sid,
|
|
421
|
+
)
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
if not metadata_values or not tree_indices:
|
|
425
|
+
await sio.emit(
|
|
426
|
+
"search-metadata-multi-result",
|
|
427
|
+
{"positions_by_value": {}, "lineages": {}, "total_count": 0},
|
|
428
|
+
to=sid,
|
|
429
|
+
)
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
ctx = await get_file_context(session.file_path)
|
|
433
|
+
if ctx is None:
|
|
434
|
+
await sio.emit("search-metadata-multi-result", {"error": "Failed to load CSV"}, to=sid)
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
samples_order = ctx.config.get("samples") or []
|
|
438
|
+
sample_id_map = {str(name): idx for idx, name in enumerate(samples_order)}
|
|
439
|
+
|
|
440
|
+
# Deduplicate and stringify values
|
|
441
|
+
unique_values = list({str(v) for v in metadata_values})
|
|
442
|
+
positions_by_value = {v: [] for v in unique_values}
|
|
443
|
+
lineages = {} if show_lineages else {}
|
|
444
|
+
total_count = 0
|
|
445
|
+
|
|
446
|
+
for value in unique_values:
|
|
447
|
+
node_id = sample_id_map.get(value)
|
|
448
|
+
if node_id is None:
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
value_lineages = {} if show_lineages else None
|
|
452
|
+
|
|
453
|
+
for tree_idx in tree_indices:
|
|
454
|
+
tree_idx = int(tree_idx)
|
|
455
|
+
graph = await _get_or_parse_csv_tree_graph(ctx, lorax_sid, tree_idx)
|
|
456
|
+
if graph is None:
|
|
457
|
+
continue
|
|
458
|
+
node_id_to_index = _build_node_id_to_index(graph) if show_lineages else None
|
|
459
|
+
arr_idx = _find_node_index(graph, node_id)
|
|
460
|
+
if arr_idx is None:
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
positions_by_value[value].append(
|
|
464
|
+
{
|
|
465
|
+
"node_id": int(node_id),
|
|
466
|
+
"tree_idx": int(tree_idx),
|
|
467
|
+
# Match tskit highlight convention: x=layout, y=time.
|
|
468
|
+
"x": float(graph.x[arr_idx]),
|
|
469
|
+
"y": float(graph.y[arr_idx]),
|
|
470
|
+
}
|
|
471
|
+
)
|
|
472
|
+
total_count += 1
|
|
473
|
+
|
|
474
|
+
if show_lineages:
|
|
475
|
+
path_node_ids = _compute_csv_lineage_path(graph, int(node_id), node_id_to_index)
|
|
476
|
+
if len(path_node_ids) > 1:
|
|
477
|
+
# Emit root -> tip to match frontend L-shape construction.
|
|
478
|
+
path_node_ids = list(reversed(path_node_ids))
|
|
479
|
+
value_lineages.setdefault(tree_idx, []).append(
|
|
480
|
+
{"path_node_ids": path_node_ids, "color": None}
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
if show_lineages and value_lineages:
|
|
484
|
+
lineages[value] = value_lineages
|
|
485
|
+
|
|
486
|
+
await sio.emit(
|
|
487
|
+
"search-metadata-multi-result",
|
|
488
|
+
{"positions_by_value": positions_by_value, "lineages": lineages, "total_count": total_count},
|
|
489
|
+
to=sid,
|
|
490
|
+
)
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
metadata_key = data.get("metadata_key")
|
|
494
|
+
metadata_values = data.get("metadata_values", [])
|
|
495
|
+
tree_indices = data.get("tree_indices", [])
|
|
496
|
+
show_lineages = data.get("show_lineages", False)
|
|
497
|
+
|
|
498
|
+
if not metadata_key:
|
|
499
|
+
await sio.emit("search-metadata-multi-result", {
|
|
500
|
+
"error": "Missing metadata_key"
|
|
501
|
+
}, to=sid)
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
if not metadata_values or not tree_indices:
|
|
505
|
+
await sio.emit("search-metadata-multi-result", {
|
|
506
|
+
"positions_by_value": {},
|
|
507
|
+
"lineages": {},
|
|
508
|
+
"total_count": 0
|
|
509
|
+
}, to=sid)
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
ctx = await get_file_context(session.file_path)
|
|
513
|
+
if ctx is None:
|
|
514
|
+
await sio.emit("search-metadata-multi-result", {
|
|
515
|
+
"error": "Failed to load tree sequence"
|
|
516
|
+
}, to=sid)
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
ts = ctx.tree_sequence
|
|
520
|
+
|
|
521
|
+
result = await get_multi_value_highlight_positions(
|
|
522
|
+
ts,
|
|
523
|
+
session.file_path,
|
|
524
|
+
metadata_key,
|
|
525
|
+
metadata_values,
|
|
526
|
+
tree_indices,
|
|
527
|
+
lorax_sid,
|
|
528
|
+
tree_graph_cache,
|
|
529
|
+
show_lineages
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
await sio.emit("search-metadata-multi-result", result, to=sid)
|
|
533
|
+
except Exception as e:
|
|
534
|
+
print(f"❌ Search metadata multi error: {e}")
|
|
535
|
+
await sio.emit("search-metadata-multi-result", {"error": str(e)}, to=sid)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tree layout event handlers for Lorax Socket.IO.
|
|
3
|
+
|
|
4
|
+
Handles process_postorder_layout and cache_trees events.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from lorax.context import tree_graph_cache, csv_tree_graph_cache
|
|
8
|
+
from lorax.handlers import handle_tree_graph_query, ensure_trees_cached
|
|
9
|
+
from lorax.sockets.decorators import require_session
|
|
10
|
+
from lorax.sockets.utils import is_csv_session_file
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register_tree_layout_events(sio):
|
|
14
|
+
"""Register tree layout socket events."""
|
|
15
|
+
|
|
16
|
+
@sio.event
|
|
17
|
+
async def process_postorder_layout(sid, data):
|
|
18
|
+
"""Socket event to get post-order tree traversal for efficient rendering.
|
|
19
|
+
|
|
20
|
+
Returns PyArrow IPC binary data with post-order node arrays.
|
|
21
|
+
Frontend computes layout using stack-based reconstruction.
|
|
22
|
+
|
|
23
|
+
Uses Socket.IO acknowledgement callback pattern - returns result directly
|
|
24
|
+
instead of emitting to ensure request-response correlation.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
lorax_sid = data.get("lorax_sid")
|
|
28
|
+
session = await require_session(lorax_sid, sid, sio)
|
|
29
|
+
if not session:
|
|
30
|
+
return {"error": "Session not found", "request_id": data.get("request_id")}
|
|
31
|
+
|
|
32
|
+
if not session.file_path:
|
|
33
|
+
print(f"⚠️ No file loaded for session {lorax_sid}")
|
|
34
|
+
return {"error": "No file loaded for session", "request_id": data.get("request_id")}
|
|
35
|
+
|
|
36
|
+
display_array = data.get("displayArray", [])
|
|
37
|
+
actual_display_array = data.get("actualDisplayArray", display_array)
|
|
38
|
+
sparsification = data.get("sparsification", False)
|
|
39
|
+
request_id = data.get("request_id")
|
|
40
|
+
|
|
41
|
+
# handle_tree_graph_query returns dict with PyArrow buffer (Numba-optimized)
|
|
42
|
+
# Pass session_id and tree_graph_cache for caching TreeGraph objects
|
|
43
|
+
# actual_display_array contains all visible trees for cache eviction
|
|
44
|
+
result = await handle_tree_graph_query(
|
|
45
|
+
session.file_path,
|
|
46
|
+
display_array,
|
|
47
|
+
sparsification=sparsification,
|
|
48
|
+
session_id=lorax_sid,
|
|
49
|
+
tree_graph_cache=tree_graph_cache,
|
|
50
|
+
csv_tree_graph_cache=csv_tree_graph_cache,
|
|
51
|
+
actual_display_array=actual_display_array
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if "error" in result:
|
|
55
|
+
return {"error": result["error"], "request_id": request_id}
|
|
56
|
+
else:
|
|
57
|
+
# Return result directly - Socket.IO sends as acknowledgement callback
|
|
58
|
+
return {
|
|
59
|
+
"buffer": result["buffer"], # Binary PyArrow IPC data
|
|
60
|
+
"global_min_time": result["global_min_time"],
|
|
61
|
+
"global_max_time": result["global_max_time"],
|
|
62
|
+
"tree_indices": result["tree_indices"],
|
|
63
|
+
"request_id": request_id
|
|
64
|
+
}
|
|
65
|
+
except Exception as e:
|
|
66
|
+
print(f"❌ Postorder layout query error: {e}")
|
|
67
|
+
return {"error": str(e), "request_id": data.get("request_id")}
|
|
68
|
+
|
|
69
|
+
@sio.event
|
|
70
|
+
async def cache_trees(sid, data):
|
|
71
|
+
"""Socket event to pre-cache TreeGraph objects for lineage operations.
|
|
72
|
+
|
|
73
|
+
Call this after process_postorder_layout to enable subsequent lineage queries.
|
|
74
|
+
|
|
75
|
+
data: {
|
|
76
|
+
lorax_sid: str,
|
|
77
|
+
tree_indices: [int] # Tree indices to cache
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
Returns: {
|
|
81
|
+
cached_count: int, # Number of trees newly cached
|
|
82
|
+
total_cached: int # Total trees now in cache for session
|
|
83
|
+
}
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
lorax_sid = data.get("lorax_sid")
|
|
87
|
+
session = await require_session(lorax_sid, sid, sio)
|
|
88
|
+
if not session:
|
|
89
|
+
return {"error": "Session not found", "cached_count": 0}
|
|
90
|
+
|
|
91
|
+
if not session.file_path:
|
|
92
|
+
return {"error": "No file loaded", "cached_count": 0}
|
|
93
|
+
|
|
94
|
+
if is_csv_session_file(session.file_path):
|
|
95
|
+
return {"error": "Lineage not supported for CSV", "cached_count": 0}
|
|
96
|
+
|
|
97
|
+
tree_indices = data.get("tree_indices", [])
|
|
98
|
+
if not tree_indices:
|
|
99
|
+
return {"cached_count": 0, "total_cached": 0}
|
|
100
|
+
|
|
101
|
+
newly_cached = await ensure_trees_cached(
|
|
102
|
+
session.file_path,
|
|
103
|
+
tree_indices,
|
|
104
|
+
lorax_sid,
|
|
105
|
+
tree_graph_cache
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Get total cached
|
|
109
|
+
all_cached = await tree_graph_cache.get_all_for_session(lorax_sid)
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
"cached_count": newly_cached,
|
|
113
|
+
"total_cached": len(all_cached)
|
|
114
|
+
}
|
|
115
|
+
except Exception as e:
|
|
116
|
+
print(f"❌ Cache trees error: {e}")
|
|
117
|
+
return {"error": str(e), "cached_count": 0}
|
lorax/sockets/utils.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Socket utility functions for Lorax.
|
|
3
|
+
|
|
4
|
+
Common helpers shared across socket event handlers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def is_csv_session_file(file_path: str | None) -> bool:
|
|
9
|
+
"""Check if the session file is a CSV file."""
|
|
10
|
+
return bool(file_path) and str(file_path).lower().endswith(".csv")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tree_graph - Optimized tree construction with Numba JIT compilation.
|
|
3
|
+
|
|
4
|
+
This module provides fast tree construction from tskit tables with:
|
|
5
|
+
- Numba-compiled post-order traversal (50-100x faster than Python)
|
|
6
|
+
- CSR format for efficient children access
|
|
7
|
+
- PyArrow serialization for frontend rendering
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .tree_graph import TreeGraph, construct_tree, construct_trees_batch
|
|
11
|
+
|
|
12
|
+
__all__ = ['TreeGraph', 'construct_tree', 'construct_trees_batch']
|