agi-page-network-map 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.
- agi_page_network_map-0.1.0/PKG-INFO +15 -0
- agi_page_network_map-0.1.0/pyproject.toml +23 -0
- agi_page_network_map-0.1.0/setup.cfg +4 -0
- agi_page_network_map-0.1.0/src/agi_page_network_map.egg-info/PKG-INFO +15 -0
- agi_page_network_map-0.1.0/src/agi_page_network_map.egg-info/SOURCES.txt +12 -0
- agi_page_network_map-0.1.0/src/agi_page_network_map.egg-info/dependency_links.txt +1 -0
- agi_page_network_map-0.1.0/src/agi_page_network_map.egg-info/entry_points.txt +2 -0
- agi_page_network_map-0.1.0/src/agi_page_network_map.egg-info/requires.txt +7 -0
- agi_page_network_map-0.1.0/src/agi_page_network_map.egg-info/top_level.txt +1 -0
- agi_page_network_map-0.1.0/src/view_maps_network/__init__.py +7 -0
- agi_page_network_map-0.1.0/src/view_maps_network/edge_selection.py +99 -0
- agi_page_network_map-0.1.0/src/view_maps_network/maps_network_graph.py +14 -0
- agi_page_network_map-0.1.0/src/view_maps_network/notebook_inline.py +455 -0
- agi_page_network_map-0.1.0/src/view_maps_network/view_maps_network.py +4956 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agi-page-network-map
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AGILAB page bundle for network-aware geospatial analysis.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: agi-gui<2027.0,>=2026.05.12.post3
|
|
8
|
+
Requires-Dist: agi-node<2027.0,>=2026.05.12.post3
|
|
9
|
+
Requires-Dist: ipython
|
|
10
|
+
Requires-Dist: networkx
|
|
11
|
+
Requires-Dist: plotly
|
|
12
|
+
Requires-Dist: matplotlib
|
|
13
|
+
Requires-Dist: sqlalchemy>=2.0.43
|
|
14
|
+
|
|
15
|
+
AGILAB page bundle for network-aware geospatial analysis.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agi-page-network-map"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "AGILAB page bundle for network-aware geospatial analysis."
|
|
5
|
+
readme = { text = "AGILAB page bundle for network-aware geospatial analysis.", content-type = "text/markdown" }
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"agi-gui>=2026.05.12.post3,<2027.0",
|
|
9
|
+
"agi-node>=2026.05.12.post3,<2027.0",
|
|
10
|
+
"ipython",
|
|
11
|
+
"networkx",
|
|
12
|
+
"plotly",
|
|
13
|
+
"matplotlib",
|
|
14
|
+
"sqlalchemy>=2.0.43",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.entry-points."agilab.pages"]
|
|
18
|
+
view_maps_network = "view_maps_network:bundle_root"
|
|
19
|
+
|
|
20
|
+
[tool.uv.sources]
|
|
21
|
+
agi-gui = { path = "../../lib/agi-gui", editable = true }
|
|
22
|
+
agi-env = { path = "../../core/agi-env", editable = true }
|
|
23
|
+
agi-node = { path = "../../core/agi-node", editable = true }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agi-page-network-map
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AGILAB page bundle for network-aware geospatial analysis.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: agi-gui<2027.0,>=2026.05.12.post3
|
|
8
|
+
Requires-Dist: agi-node<2027.0,>=2026.05.12.post3
|
|
9
|
+
Requires-Dist: ipython
|
|
10
|
+
Requires-Dist: networkx
|
|
11
|
+
Requires-Dist: plotly
|
|
12
|
+
Requires-Dist: matplotlib
|
|
13
|
+
Requires-Dist: sqlalchemy>=2.0.43
|
|
14
|
+
|
|
15
|
+
AGILAB page bundle for network-aware geospatial analysis.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/agi_page_network_map.egg-info/PKG-INFO
|
|
3
|
+
src/agi_page_network_map.egg-info/SOURCES.txt
|
|
4
|
+
src/agi_page_network_map.egg-info/dependency_links.txt
|
|
5
|
+
src/agi_page_network_map.egg-info/entry_points.txt
|
|
6
|
+
src/agi_page_network_map.egg-info/requires.txt
|
|
7
|
+
src/agi_page_network_map.egg-info/top_level.txt
|
|
8
|
+
src/view_maps_network/__init__.py
|
|
9
|
+
src/view_maps_network/edge_selection.py
|
|
10
|
+
src/view_maps_network/maps_network_graph.py
|
|
11
|
+
src/view_maps_network/notebook_inline.py
|
|
12
|
+
src/view_maps_network/view_maps_network.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
view_maps_network
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
NONE_OPTION = "(none)"
|
|
8
|
+
CUSTOM_OPTION = "(custom path…)"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class EdgesPickerState:
|
|
13
|
+
picker_options: list[str]
|
|
14
|
+
choice: str
|
|
15
|
+
custom_value: str
|
|
16
|
+
edges_clean: str
|
|
17
|
+
recovered_from_missing: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _path_exists(value: str) -> bool:
|
|
21
|
+
if not value.strip():
|
|
22
|
+
return False
|
|
23
|
+
try:
|
|
24
|
+
return Path(value).expanduser().exists()
|
|
25
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _preferred_recovery_candidate(edges_prev: str, edges_candidates: Sequence[str]) -> str | None:
|
|
30
|
+
if not edges_candidates:
|
|
31
|
+
return None
|
|
32
|
+
prev_name = Path(edges_prev).name.lower()
|
|
33
|
+
tokens: list[str] = []
|
|
34
|
+
if "topology" in prev_name:
|
|
35
|
+
tokens.extend(["topology", "ilp_topology"])
|
|
36
|
+
if "routing_edges" in prev_name:
|
|
37
|
+
tokens.append("routing_edges")
|
|
38
|
+
elif "edges" in prev_name:
|
|
39
|
+
tokens.append("edges")
|
|
40
|
+
for token in tokens:
|
|
41
|
+
for candidate in edges_candidates:
|
|
42
|
+
if token in Path(candidate).name.lower():
|
|
43
|
+
return candidate
|
|
44
|
+
return edges_candidates[0]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_edges_picker_state(
|
|
48
|
+
edges_prev: str,
|
|
49
|
+
edges_candidates: Sequence[str],
|
|
50
|
+
*,
|
|
51
|
+
current_choice: str | None = None,
|
|
52
|
+
current_custom: str | None = None,
|
|
53
|
+
) -> EdgesPickerState:
|
|
54
|
+
picker_options = [NONE_OPTION, *edges_candidates, CUSTOM_OPTION]
|
|
55
|
+
choice = current_choice or ""
|
|
56
|
+
custom_value = current_custom or ""
|
|
57
|
+
recovered_from_missing = False
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
choice == CUSTOM_OPTION
|
|
61
|
+
and custom_value.strip()
|
|
62
|
+
and not _path_exists(custom_value)
|
|
63
|
+
and edges_candidates
|
|
64
|
+
):
|
|
65
|
+
choice = _preferred_recovery_candidate(custom_value, edges_candidates) or NONE_OPTION
|
|
66
|
+
custom_value = ""
|
|
67
|
+
recovered_from_missing = bool(choice and choice != NONE_OPTION)
|
|
68
|
+
|
|
69
|
+
if choice not in picker_options:
|
|
70
|
+
if edges_prev and edges_prev in edges_candidates:
|
|
71
|
+
choice = edges_prev
|
|
72
|
+
elif edges_prev and _path_exists(edges_prev):
|
|
73
|
+
choice = CUSTOM_OPTION
|
|
74
|
+
if not custom_value:
|
|
75
|
+
custom_value = edges_prev
|
|
76
|
+
elif edges_prev and edges_candidates:
|
|
77
|
+
choice = _preferred_recovery_candidate(edges_prev, edges_candidates) or NONE_OPTION
|
|
78
|
+
recovered_from_missing = bool(choice and choice != NONE_OPTION)
|
|
79
|
+
elif edges_prev:
|
|
80
|
+
choice = CUSTOM_OPTION
|
|
81
|
+
if not custom_value:
|
|
82
|
+
custom_value = edges_prev
|
|
83
|
+
else:
|
|
84
|
+
choice = edges_candidates[0] if edges_candidates else NONE_OPTION
|
|
85
|
+
|
|
86
|
+
if choice == CUSTOM_OPTION:
|
|
87
|
+
edges_clean = custom_value.strip()
|
|
88
|
+
elif choice == NONE_OPTION:
|
|
89
|
+
edges_clean = ""
|
|
90
|
+
else:
|
|
91
|
+
edges_clean = choice.strip()
|
|
92
|
+
|
|
93
|
+
return EdgesPickerState(
|
|
94
|
+
picker_options=picker_options,
|
|
95
|
+
choice=choice,
|
|
96
|
+
custom_value=custom_value,
|
|
97
|
+
edges_clean=edges_clean,
|
|
98
|
+
recovered_from_missing=recovered_from_missing,
|
|
99
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Support module for the maps network Streamlit page."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from .view_maps_network import * # type: ignore # noqa: F401,F403
|
|
10
|
+
except ImportError: # pragma: no cover
|
|
11
|
+
_HERE = Path(__file__).resolve().parent
|
|
12
|
+
if str(_HERE) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(_HERE))
|
|
14
|
+
from view_maps_network import * # type: ignore # noqa: F401,F403
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import networkx as nx
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import plotly.graph_objects as go
|
|
11
|
+
from IPython.display import Markdown
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _coerce_str_list(value: Any) -> list[str]:
|
|
15
|
+
if value is None:
|
|
16
|
+
return []
|
|
17
|
+
if isinstance(value, str):
|
|
18
|
+
raw_items = value.replace(";", ",").replace("\n", ",").split(",")
|
|
19
|
+
elif isinstance(value, (list, tuple, set)):
|
|
20
|
+
raw_items = [str(item) for item in value]
|
|
21
|
+
else:
|
|
22
|
+
raw_items = [str(value)]
|
|
23
|
+
items: list[str] = []
|
|
24
|
+
seen: set[str] = set()
|
|
25
|
+
for item in raw_items:
|
|
26
|
+
cleaned = str(item).strip()
|
|
27
|
+
if not cleaned or cleaned in seen:
|
|
28
|
+
continue
|
|
29
|
+
seen.add(cleaned)
|
|
30
|
+
items.append(cleaned)
|
|
31
|
+
return items
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_toml_dict(path: str | Path | None) -> dict[str, Any]:
|
|
35
|
+
if not path:
|
|
36
|
+
return {}
|
|
37
|
+
try:
|
|
38
|
+
candidate = Path(path).expanduser()
|
|
39
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
40
|
+
return {}
|
|
41
|
+
if not candidate.exists():
|
|
42
|
+
return {}
|
|
43
|
+
try:
|
|
44
|
+
import tomllib
|
|
45
|
+
|
|
46
|
+
with open(candidate, "rb") as handle:
|
|
47
|
+
payload = tomllib.load(handle)
|
|
48
|
+
except (OSError, ValueError, tomllib.TOMLDecodeError):
|
|
49
|
+
return {}
|
|
50
|
+
return payload if isinstance(payload, dict) else {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _page_setting_sources(export_payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
54
|
+
page_key = "view_maps_network"
|
|
55
|
+
sources: list[dict[str, Any]] = []
|
|
56
|
+
for candidate in (
|
|
57
|
+
Path(export_payload.get("artifact_dir") or "") / "app_settings.toml",
|
|
58
|
+
export_payload.get("app_settings_file"),
|
|
59
|
+
):
|
|
60
|
+
payload = _read_toml_dict(candidate)
|
|
61
|
+
if not payload:
|
|
62
|
+
continue
|
|
63
|
+
direct = payload.get(page_key)
|
|
64
|
+
if isinstance(direct, dict):
|
|
65
|
+
sources.append(direct)
|
|
66
|
+
pages = payload.get("pages")
|
|
67
|
+
if isinstance(pages, dict):
|
|
68
|
+
nested = pages.get(page_key)
|
|
69
|
+
if isinstance(nested, dict):
|
|
70
|
+
sources.append(nested)
|
|
71
|
+
return sources
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _first_nonempty_setting(sources: list[dict[str, Any]], *keys: str) -> str:
|
|
75
|
+
for source in sources:
|
|
76
|
+
if not isinstance(source, dict):
|
|
77
|
+
continue
|
|
78
|
+
for key in keys:
|
|
79
|
+
value = source.get(key)
|
|
80
|
+
if isinstance(value, str) and value.strip():
|
|
81
|
+
return value.strip()
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _setting_list(sources: list[dict[str, Any]], *keys: str) -> list[str]:
|
|
86
|
+
items: list[str] = []
|
|
87
|
+
seen: set[str] = set()
|
|
88
|
+
for source in sources:
|
|
89
|
+
if not isinstance(source, dict):
|
|
90
|
+
continue
|
|
91
|
+
for key in keys:
|
|
92
|
+
for item in _coerce_str_list(source.get(key)):
|
|
93
|
+
if item in seen:
|
|
94
|
+
continue
|
|
95
|
+
seen.add(item)
|
|
96
|
+
items.append(item)
|
|
97
|
+
return items
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _candidate_base_dirs(export_payload: dict[str, Any], sources: list[dict[str, Any]]) -> list[Path]:
|
|
101
|
+
artifact_dir = Path(export_payload.get("artifact_dir") or ".").expanduser()
|
|
102
|
+
roots: list[Path] = [
|
|
103
|
+
artifact_dir,
|
|
104
|
+
artifact_dir / "pipeline",
|
|
105
|
+
Path.home() / "localshare",
|
|
106
|
+
Path.home() / "export",
|
|
107
|
+
]
|
|
108
|
+
subdirs = _setting_list(sources, "dataset_subpath", "datadir_rel")
|
|
109
|
+
for base in list(roots):
|
|
110
|
+
for subdir in subdirs:
|
|
111
|
+
roots.append(base / subdir)
|
|
112
|
+
unique: list[Path] = []
|
|
113
|
+
seen: set[Path] = set()
|
|
114
|
+
for root in roots:
|
|
115
|
+
try:
|
|
116
|
+
resolved = root.expanduser().resolve(strict=False)
|
|
117
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
118
|
+
resolved = root.expanduser()
|
|
119
|
+
if resolved in seen:
|
|
120
|
+
continue
|
|
121
|
+
seen.add(resolved)
|
|
122
|
+
unique.append(resolved)
|
|
123
|
+
return unique
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _resolve_declared_path(value: str, base_dirs: list[Path]) -> Path | None:
|
|
127
|
+
raw = str(value or "").strip()
|
|
128
|
+
if not raw:
|
|
129
|
+
return None
|
|
130
|
+
path = Path(raw).expanduser()
|
|
131
|
+
if path.is_absolute():
|
|
132
|
+
return path
|
|
133
|
+
for base in base_dirs:
|
|
134
|
+
candidate = (base / raw).expanduser()
|
|
135
|
+
if candidate.exists():
|
|
136
|
+
return candidate
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _expand_globs(patterns: list[str], base_dirs: list[Path]) -> list[Path]:
|
|
141
|
+
matches: list[Path] = []
|
|
142
|
+
seen: set[Path] = set()
|
|
143
|
+
for raw_pattern in patterns:
|
|
144
|
+
pattern = str(raw_pattern or "").strip()
|
|
145
|
+
if not pattern:
|
|
146
|
+
continue
|
|
147
|
+
path = Path(pattern).expanduser()
|
|
148
|
+
candidates = [str(path)] if path.is_absolute() else [str(base / pattern) for base in base_dirs]
|
|
149
|
+
for candidate in candidates:
|
|
150
|
+
for match in glob.glob(candidate, recursive=True):
|
|
151
|
+
path_match = Path(match).expanduser()
|
|
152
|
+
if not path_match.is_file():
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
resolved = path_match.resolve(strict=False)
|
|
156
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
157
|
+
resolved = path_match
|
|
158
|
+
if resolved in seen:
|
|
159
|
+
continue
|
|
160
|
+
seen.add(resolved)
|
|
161
|
+
matches.append(resolved)
|
|
162
|
+
matches.sort(key=lambda path: path.stat().st_mtime if path.exists() else 0.0, reverse=True)
|
|
163
|
+
return matches
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _discover_topology_path(sources: list[dict[str, Any]], base_dirs: list[Path]) -> Path | None:
|
|
167
|
+
declared = _first_nonempty_setting(sources, "edges_file")
|
|
168
|
+
resolved = _resolve_declared_path(declared, base_dirs)
|
|
169
|
+
if resolved and resolved.exists():
|
|
170
|
+
return resolved
|
|
171
|
+
patterns = [
|
|
172
|
+
"pipeline/topology.gml",
|
|
173
|
+
"pipeline/ilp_topology.gml",
|
|
174
|
+
"pipeline/topology.json",
|
|
175
|
+
"network_sim/pipeline/topology.gml",
|
|
176
|
+
"network_sim/pipeline/ilp_topology.gml",
|
|
177
|
+
"network_sim/pipeline/topology.json",
|
|
178
|
+
]
|
|
179
|
+
matches = _expand_globs(patterns, base_dirs)
|
|
180
|
+
return matches[0] if matches else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _discover_trajectory_paths(sources: list[dict[str, Any]], base_dirs: list[Path]) -> list[Path]:
|
|
184
|
+
patterns = _setting_list(sources, "traj_glob", "default_traj_globs")
|
|
185
|
+
if not patterns:
|
|
186
|
+
patterns = [
|
|
187
|
+
"flight_trajectory/pipeline/*.csv",
|
|
188
|
+
"flight_trajectory/pipeline/*.parquet",
|
|
189
|
+
"*trajectory*/pipeline/*.csv",
|
|
190
|
+
"*trajectory*/pipeline/*.parquet",
|
|
191
|
+
]
|
|
192
|
+
return _expand_globs(patterns, base_dirs)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _load_graph(path: Path | None) -> nx.Graph | None:
|
|
196
|
+
if path is None or not path.exists():
|
|
197
|
+
return None
|
|
198
|
+
try:
|
|
199
|
+
return nx.read_gml(path)
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
try:
|
|
203
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
204
|
+
except Exception:
|
|
205
|
+
return None
|
|
206
|
+
graph = nx.Graph()
|
|
207
|
+
if isinstance(payload, dict):
|
|
208
|
+
for node in payload.get("nodes", []) or []:
|
|
209
|
+
if isinstance(node, dict):
|
|
210
|
+
node_id = str(node.get("id", "") or "").strip()
|
|
211
|
+
if node_id:
|
|
212
|
+
graph.add_node(node_id, **{k: v for k, v in node.items() if k != "id"})
|
|
213
|
+
elif node is not None:
|
|
214
|
+
graph.add_node(str(node))
|
|
215
|
+
for edge in payload.get("edges", []) or []:
|
|
216
|
+
if isinstance(edge, dict):
|
|
217
|
+
source = str(edge.get("source", "") or "").strip()
|
|
218
|
+
target = str(edge.get("target", "") or "").strip()
|
|
219
|
+
if source and target:
|
|
220
|
+
graph.add_edge(source, target, **{k: v for k, v in edge.items() if k not in {"source", "target"}})
|
|
221
|
+
elif isinstance(edge, (list, tuple)) and len(edge) >= 2:
|
|
222
|
+
graph.add_edge(str(edge[0]), str(edge[1]))
|
|
223
|
+
elif isinstance(payload, list):
|
|
224
|
+
for edge in payload:
|
|
225
|
+
if isinstance(edge, (list, tuple)) and len(edge) >= 2:
|
|
226
|
+
graph.add_edge(str(edge[0]), str(edge[1]))
|
|
227
|
+
return graph if graph.number_of_nodes() else None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _load_frame(path: Path) -> pd.DataFrame:
|
|
231
|
+
if path.suffix.lower() in {".parquet", ".pq", ".parq"}:
|
|
232
|
+
return pd.read_parquet(path)
|
|
233
|
+
return pd.read_csv(path)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _first_column(columns: dict[str, str], *names: str) -> str:
|
|
237
|
+
for name in names:
|
|
238
|
+
candidate = columns.get(name)
|
|
239
|
+
if candidate:
|
|
240
|
+
return candidate
|
|
241
|
+
return ""
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _best_id_column(df: pd.DataFrame) -> str:
|
|
245
|
+
lowered = {column.lower(): column for column in df.columns}
|
|
246
|
+
for candidate in (
|
|
247
|
+
"plane_id",
|
|
248
|
+
"trajectory_id",
|
|
249
|
+
"node_id",
|
|
250
|
+
"flight_id",
|
|
251
|
+
"id",
|
|
252
|
+
"plane_label",
|
|
253
|
+
"stable_flight_id",
|
|
254
|
+
"sat_name",
|
|
255
|
+
"name",
|
|
256
|
+
"callsign",
|
|
257
|
+
"call_sign",
|
|
258
|
+
):
|
|
259
|
+
column = lowered.get(candidate)
|
|
260
|
+
if column:
|
|
261
|
+
return column
|
|
262
|
+
return ""
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _load_positions(paths: list[Path]) -> pd.DataFrame:
|
|
266
|
+
frames: list[pd.DataFrame] = []
|
|
267
|
+
for path in paths:
|
|
268
|
+
try:
|
|
269
|
+
df = _load_frame(path)
|
|
270
|
+
except Exception:
|
|
271
|
+
continue
|
|
272
|
+
lowered = {column.lower(): column for column in df.columns}
|
|
273
|
+
time_col = _first_column(lowered, "time_s", "t_now_s", "time", "t", "time_index")
|
|
274
|
+
lat_col = _first_column(lowered, "latitude", "lat")
|
|
275
|
+
lon_col = _first_column(lowered, "longitude", "lon", "long")
|
|
276
|
+
alt_col = _first_column(lowered, "alt_m", "altitude_m", "altitude", "alt")
|
|
277
|
+
id_col = _best_id_column(df)
|
|
278
|
+
if not (time_col and lat_col and lon_col and id_col):
|
|
279
|
+
continue
|
|
280
|
+
subset = pd.DataFrame(
|
|
281
|
+
{
|
|
282
|
+
"node_id": df[id_col].astype(str),
|
|
283
|
+
"time_value": pd.to_numeric(df[time_col], errors="coerce"),
|
|
284
|
+
"lat": pd.to_numeric(df[lat_col], errors="coerce"),
|
|
285
|
+
"lon": pd.to_numeric(df[lon_col], errors="coerce"),
|
|
286
|
+
"alt": pd.to_numeric(df[alt_col], errors="coerce") if alt_col else 0.0,
|
|
287
|
+
}
|
|
288
|
+
).dropna(subset=["node_id", "lat", "lon"])
|
|
289
|
+
if subset.empty:
|
|
290
|
+
continue
|
|
291
|
+
subset["source_file"] = str(path)
|
|
292
|
+
subset = subset.sort_values("time_value")
|
|
293
|
+
frames.append(subset.groupby("node_id", as_index=False).tail(1))
|
|
294
|
+
if not frames:
|
|
295
|
+
return pd.DataFrame(columns=["node_id", "lat", "lon", "alt", "source_file"])
|
|
296
|
+
result = pd.concat(frames, ignore_index=True)
|
|
297
|
+
return result.groupby("node_id", as_index=False).tail(1).reset_index(drop=True)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _geo_map_figure(graph: nx.Graph | None, positions: pd.DataFrame, *, title: str) -> go.Figure:
|
|
301
|
+
fig = go.Figure()
|
|
302
|
+
pos_index = positions.set_index("node_id", drop=False)
|
|
303
|
+
if graph is not None:
|
|
304
|
+
edge_lons: list[float | None] = []
|
|
305
|
+
edge_lats: list[float | None] = []
|
|
306
|
+
for source, target in graph.edges():
|
|
307
|
+
if str(source) not in pos_index.index or str(target) not in pos_index.index:
|
|
308
|
+
continue
|
|
309
|
+
src = pos_index.loc[str(source)]
|
|
310
|
+
dst = pos_index.loc[str(target)]
|
|
311
|
+
edge_lons.extend([float(src["lon"]), float(dst["lon"]), None])
|
|
312
|
+
edge_lats.extend([float(src["lat"]), float(dst["lat"]), None])
|
|
313
|
+
if edge_lons:
|
|
314
|
+
fig.add_trace(
|
|
315
|
+
go.Scattergeo(
|
|
316
|
+
lon=edge_lons,
|
|
317
|
+
lat=edge_lats,
|
|
318
|
+
mode="lines",
|
|
319
|
+
line=dict(width=1.5, color="#5b6c87"),
|
|
320
|
+
opacity=0.7,
|
|
321
|
+
name="Topology",
|
|
322
|
+
hoverinfo="skip",
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
fig.add_trace(
|
|
326
|
+
go.Scattergeo(
|
|
327
|
+
lon=positions["lon"],
|
|
328
|
+
lat=positions["lat"],
|
|
329
|
+
text=positions["node_id"],
|
|
330
|
+
mode="markers+text",
|
|
331
|
+
textposition="top center",
|
|
332
|
+
marker=dict(size=9, color="#1f77b4", line=dict(width=1, color="#ffffff")),
|
|
333
|
+
name="Nodes",
|
|
334
|
+
hovertemplate="Node: %{text}<br>Lat: %{lat}<br>Lon: %{lon}<extra></extra>",
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
fig.update_layout(
|
|
338
|
+
title=title,
|
|
339
|
+
height=620,
|
|
340
|
+
margin=dict(l=10, r=10, t=50, b=10),
|
|
341
|
+
geo=dict(
|
|
342
|
+
projection_type="equirectangular",
|
|
343
|
+
showland=True,
|
|
344
|
+
landcolor="#eef3f8",
|
|
345
|
+
showcountries=True,
|
|
346
|
+
countrycolor="#b9c4d0",
|
|
347
|
+
showocean=True,
|
|
348
|
+
oceancolor="#dbe8f4",
|
|
349
|
+
fitbounds="locations",
|
|
350
|
+
),
|
|
351
|
+
legend=dict(orientation="h"),
|
|
352
|
+
)
|
|
353
|
+
return fig
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _topology_figure(graph: nx.Graph, *, title: str) -> go.Figure:
|
|
357
|
+
pos = nx.spring_layout(graph, seed=42)
|
|
358
|
+
edge_x: list[float | None] = []
|
|
359
|
+
edge_y: list[float | None] = []
|
|
360
|
+
for source, target in graph.edges():
|
|
361
|
+
x0, y0 = pos[source]
|
|
362
|
+
x1, y1 = pos[target]
|
|
363
|
+
edge_x.extend([x0, x1, None])
|
|
364
|
+
edge_y.extend([y0, y1, None])
|
|
365
|
+
node_x = [pos[node][0] for node in graph.nodes()]
|
|
366
|
+
node_y = [pos[node][1] for node in graph.nodes()]
|
|
367
|
+
node_text = [str(node) for node in graph.nodes()]
|
|
368
|
+
fig = go.Figure(
|
|
369
|
+
data=[
|
|
370
|
+
go.Scatter(x=edge_x, y=edge_y, mode="lines", line=dict(width=1.2, color="#8fa0bc"), hoverinfo="skip"),
|
|
371
|
+
go.Scatter(
|
|
372
|
+
x=node_x,
|
|
373
|
+
y=node_y,
|
|
374
|
+
mode="markers+text",
|
|
375
|
+
text=node_text,
|
|
376
|
+
textposition="top center",
|
|
377
|
+
marker=dict(size=10, color="#1f77b4"),
|
|
378
|
+
hovertemplate="Node: %{text}<extra></extra>",
|
|
379
|
+
),
|
|
380
|
+
]
|
|
381
|
+
)
|
|
382
|
+
fig.update_layout(
|
|
383
|
+
title=title,
|
|
384
|
+
height=620,
|
|
385
|
+
margin=dict(l=10, r=10, t=50, b=10),
|
|
386
|
+
xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
|
|
387
|
+
yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
|
|
388
|
+
showlegend=False,
|
|
389
|
+
)
|
|
390
|
+
return fig
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _summary_markdown(
|
|
394
|
+
*,
|
|
395
|
+
title: str,
|
|
396
|
+
topology_path: Path | None,
|
|
397
|
+
trajectory_paths: list[Path],
|
|
398
|
+
graph: nx.Graph | None,
|
|
399
|
+
positions: pd.DataFrame,
|
|
400
|
+
expected_artifacts: list[str],
|
|
401
|
+
checked_roots: list[Path],
|
|
402
|
+
) -> Markdown:
|
|
403
|
+
lines = [f"#### {title}", ""]
|
|
404
|
+
if topology_path is not None:
|
|
405
|
+
lines.append(f"- Topology source: `{topology_path}`")
|
|
406
|
+
else:
|
|
407
|
+
lines.append("- Topology source: not found")
|
|
408
|
+
if trajectory_paths:
|
|
409
|
+
lines.append(f"- Trajectory files: {len(trajectory_paths)}")
|
|
410
|
+
lines.append(f" - First match: `{trajectory_paths[0]}`")
|
|
411
|
+
else:
|
|
412
|
+
lines.append("- Trajectory files: none found")
|
|
413
|
+
if graph is not None:
|
|
414
|
+
lines.append(f"- Graph nodes/edges: {graph.number_of_nodes()} / {graph.number_of_edges()}")
|
|
415
|
+
if not positions.empty:
|
|
416
|
+
lines.append(f"- Positioned nodes: {len(positions)}")
|
|
417
|
+
if graph is None and positions.empty:
|
|
418
|
+
lines.extend(
|
|
419
|
+
[
|
|
420
|
+
"",
|
|
421
|
+
"No notebook-native map could be rendered because no topology or trajectory artifacts were found.",
|
|
422
|
+
]
|
|
423
|
+
)
|
|
424
|
+
if expected_artifacts:
|
|
425
|
+
lines.append("- Expected artifacts:")
|
|
426
|
+
lines.extend(f" - `{artifact}`" for artifact in expected_artifacts)
|
|
427
|
+
lines.append("- Checked roots:")
|
|
428
|
+
lines.extend(f" - `{root}`" for root in checked_roots)
|
|
429
|
+
return Markdown("\n".join(lines))
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def render_inline(*, page: str, record: dict[str, Any], export_payload: dict[str, Any]) -> list[Any]:
|
|
433
|
+
sources = _page_setting_sources(export_payload)
|
|
434
|
+
base_dirs = _candidate_base_dirs(export_payload, sources)
|
|
435
|
+
topology_path = _discover_topology_path(sources, base_dirs)
|
|
436
|
+
trajectory_paths = _discover_trajectory_paths(sources, base_dirs)
|
|
437
|
+
graph = _load_graph(topology_path)
|
|
438
|
+
positions = _load_positions(trajectory_paths)
|
|
439
|
+
title = str(record.get("label") or page or "Maps Network")
|
|
440
|
+
outputs: list[Any] = [
|
|
441
|
+
_summary_markdown(
|
|
442
|
+
title=title,
|
|
443
|
+
topology_path=topology_path,
|
|
444
|
+
trajectory_paths=trajectory_paths,
|
|
445
|
+
graph=graph,
|
|
446
|
+
positions=positions,
|
|
447
|
+
expected_artifacts=[str(item) for item in record.get("artifacts", [])],
|
|
448
|
+
checked_roots=base_dirs,
|
|
449
|
+
)
|
|
450
|
+
]
|
|
451
|
+
if not positions.empty:
|
|
452
|
+
outputs.append(_geo_map_figure(graph, positions, title=title))
|
|
453
|
+
elif graph is not None:
|
|
454
|
+
outputs.append(_topology_figure(graph, title=title))
|
|
455
|
+
return outputs
|