Skip to content

Commit b5c3513

Browse files
authored
Merge pull request #159 from easy-graph/hif
[Feat] Add support for Hypergraphs Interchange Format (HIF) import and export
2 parents 71c1a88 + ae334a8 commit b5c3513

4 files changed

Lines changed: 364 additions & 0 deletions

File tree

easygraph/functions/drawing/geometry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ def vlen(vector):
2525

2626
def common_tangent_radian(r1, r2, d):
2727
value = abs(r2 - r1) / d
28+
if value > 1.0: value = 1.0
29+
elif value < -1.0: value = -1.0
2830
alpha = math.acos(value)
2931
alpha = alpha if r1 > r2 else pi - alpha
3032
return alpha

easygraph/tests/test_hif.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import unittest
2+
import json
3+
import os
4+
import tempfile
5+
import easygraph as eg
6+
from pathlib import Path
7+
8+
MOCK_HIF_DATA = {
9+
"metadata": {
10+
"name": "test_organism",
11+
"description": "Simulation for unit test"
12+
},
13+
"network-type": "directed",
14+
"nodes": [
15+
{"node": "n1", "weight": 1.0, "attrs": {"name": "Node A"}},
16+
{"node": "n2", "weight": 1.0, "attrs": {"name": "Node B"}}
17+
],
18+
"edges": [
19+
{"edge": "e1", "weight": 1.0, "attrs": {"name": "Edge Alpha"}}
20+
],
21+
"incidences": [
22+
{"edge": "e1", "node": "n1", "weight": 1.0, "direction": "tail"},
23+
{"edge": "e1", "node": "n2", "weight": 1.0, "direction": "head"}
24+
]
25+
}
26+
27+
class HIFTest(unittest.TestCase):
28+
def setUp(self):
29+
self.temp_dir = tempfile.TemporaryDirectory()
30+
self.temp_dir_path = Path(self.temp_dir.name)
31+
32+
self.input_file = self.temp_dir_path / "input_mock.hif.json"
33+
self.output_file = self.temp_dir_path / "output_result.hif.json"
34+
35+
with open(self.input_file, "w", encoding="utf-8") as f:
36+
json.dump(MOCK_HIF_DATA, f)
37+
38+
def tearDown(self):
39+
self.temp_dir.cleanup()
40+
41+
def test_hif_roundtrip_preservation(self):
42+
"""
43+
Test that custom attributes are preserved AND the generated
44+
EasyGraph object is structurally valid.
45+
"""
46+
hg = eg.hif_to_hypergraph(filename=self.input_file)
47+
48+
self.assertEqual(hg.num_v, 2, "Loaded graph should have 2 nodes")
49+
self.assertEqual(hg.num_e, 1, "Loaded graph should have 1 edge")
50+
51+
node_names = [props.get('name') for props in hg.v_property]
52+
self.assertIn("n1", node_names, "Node ID 'n1' should be in v_property")
53+
54+
edges = hg.e[0]
55+
self.assertEqual(len(edges), 1, "Should have 1 edge group")
56+
self.assertEqual(len(edges[0]), 2, "Edge e1 should connect 2 nodes")
57+
58+
self.assertTrue(hasattr(hg, "custom_hif_incidences"), "Failed to attach custom incidences")
59+
self.assertTrue(hasattr(hg, "metadata"), "Failed to attach metadata")
60+
61+
eg.hypergraph_to_hif(hg, filename=self.output_file)
62+
63+
with open(self.output_file, 'r', encoding="utf-8") as f:
64+
res = json.load(f)
65+
66+
first_incidence = res["incidences"][0]
67+
self.assertIn("direction", first_incidence, "'direction' field lost in roundtrip")
68+
self.assertIn(first_incidence["direction"], ["tail", "head"])
69+
70+
self.assertNotIn("default_attrs", res["metadata"], "'default_attrs' was forced into metadata")
71+
self.assertEqual(res["metadata"]["name"], "test_organism")
72+
73+
def test_manual_graph_export(self):
74+
"""Test exporting a manually created Hypergraph (not loaded from file)."""
75+
hg = eg.Hypergraph(
76+
num_v=5,
77+
e_list=[(0, 1, 2), (2, 3), (2, 3), (0, 4)],
78+
merge_op="sum"
79+
)
80+
hg.metadata = {"created_by": "manual_test"}
81+
82+
eg.hypergraph_to_hif(hg, filename=self.output_file)
83+
84+
with open(self.output_file, 'r', encoding="utf-8") as f:
85+
data = json.load(f)
86+
self.assertEqual(len(data["nodes"]), 5)
87+
self.assertEqual(len(data["edges"]), 3)
88+
self.assertEqual(data["metadata"]["created_by"], "manual_test")
89+
90+
91+
weights = [e["weight"] for e in data["edges"]]
92+
self.assertIn(2.0, weights, "Merged edge weight should be 2.0")
93+
94+
if __name__ == "__main__":
95+
unittest.main()

easygraph/utils/HIF.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import json
2+
import requests
3+
import fastjsonschema
4+
from copy import deepcopy
5+
from typing import Optional, Union, List, Dict, Any
6+
from pathlib import Path
7+
from easygraph.classes.hypergraph import Hypergraph
8+
9+
schema_url = "https://raw.githubusercontent.com/pszufe/HIF_validators/main/schemas/hif_schema_v0.1.0.json"
10+
11+
class EasyGraphHIFError(Exception):
12+
"""Custom exception for HIF conversion errors."""
13+
pass
14+
15+
_hif_validator = None
16+
17+
def _get_hif_validator():
18+
global _hif_validator
19+
if _hif_validator is None:
20+
try:
21+
resp = requests.get(schema_url, timeout=5)
22+
if resp.status_code == 200:
23+
schema = json.loads(resp.text)
24+
_hif_validator = fastjsonschema.compile(schema)
25+
except Exception:
26+
print("Warning: HIF Schema could not be fetched. Validation skipped.")
27+
_hif_validator = lambda x: True
28+
29+
return _hif_validator if _hif_validator else (lambda x: True)
30+
31+
def hypergraph_to_hif(
32+
hg: Hypergraph,
33+
filename: Optional[Union[str, Path]] = None,
34+
node_label: str = "name",
35+
edge_label: str = "name",
36+
) -> dict:
37+
"""
38+
Converts an EasyGraph Hypergraph to HIF JSON.
39+
Correctly handles hg.e tuple structure ((edges), (weights), (props)).
40+
"""
41+
42+
if hasattr(hg, "custom_hif_nodes"):
43+
nodj = hg.custom_hif_nodes
44+
else:
45+
nodj = []
46+
num_v = hg.num_v if hasattr(hg, "num_v") else len(hg.v_property) if hasattr(hg, "v_property") else 0
47+
v_props = getattr(hg, "v_property", [{} for _ in range(num_v)])
48+
if not v_props and num_v > 0: v_props = [{} for _ in range(num_v)]
49+
50+
for i in range(num_v):
51+
props = v_props[i] if i < len(v_props) and isinstance(v_props[i], dict) else {}
52+
p = props.copy()
53+
weight = p.pop("weight", 1.0)
54+
if node_label in p:
55+
node_id = str(p.get(node_label))
56+
if node_label == "name":
57+
p.pop("name", None)
58+
else:
59+
node_id = p.pop("name", str(i))
60+
nodj.append({"node": node_id, "weight": weight, "attrs": p})
61+
62+
e_structure = []
63+
e_weights = []
64+
e_props = []
65+
66+
if hasattr(hg, "e") and isinstance(hg.e, tuple) and len(hg.e) == 3 and \
67+
isinstance(hg.e[0], (list, tuple)) and isinstance(hg.e[1], (list, tuple)):
68+
e_structure = hg.e[0]
69+
e_weights = hg.e[1]
70+
e_props = hg.e[2]
71+
72+
elif hasattr(hg, "e_list") and hg.e_list:
73+
e_structure = hg.e_list
74+
e_weights = getattr(hg, "e_weight", [1.0] * len(e_structure))
75+
e_props = getattr(hg, "e_property_full", [{} for _ in range(len(e_structure))])
76+
77+
elif hasattr(hg, "e") and isinstance(hg.e, (list, tuple)):
78+
e_structure = hg.e
79+
e_weights = getattr(hg, "e_weight", [1.0] * len(e_structure))
80+
e_props = getattr(hg, "e_property_full", [{} for _ in range(len(e_structure))])
81+
82+
num_e = len(e_structure)
83+
84+
if len(e_weights) < num_e: e_weights = [1.0] * num_e
85+
if len(e_props) < num_e: e_props = [{} for _ in range(num_e)]
86+
87+
if hasattr(hg, "custom_hif_edges"):
88+
edgj = hg.custom_hif_edges
89+
else:
90+
edgj = []
91+
for i in range(num_e):
92+
props = e_props[i].copy() if isinstance(e_props[i], dict) else {}
93+
# edge_id = props.pop("name", str(i))
94+
weight = e_weights[i]
95+
props.pop("weight", None)
96+
if edge_label in props:
97+
edge_id = str(props.get(edge_label))
98+
if edge_label == "name":
99+
props.pop("name", None)
100+
else:
101+
edge_id = props.pop("name", str(i))
102+
edgj.append({"edge": edge_id, "weight": weight, "attrs": props})
103+
104+
if hasattr(hg, "custom_hif_incidences"):
105+
incj = hg.custom_hif_incidences
106+
else:
107+
incj = []
108+
node_id_list = [n["node"] for n in nodj]
109+
edge_id_list = [e["edge"] for e in edgj]
110+
111+
for e_idx, nodes_in_edge in enumerate(e_structure):
112+
if e_idx >= len(edge_id_list): break
113+
edge_name = edge_id_list[e_idx]
114+
115+
flat_nodes = []
116+
if isinstance(nodes_in_edge, (list, tuple)):
117+
for item in nodes_in_edge:
118+
if isinstance(item, (list, tuple)):
119+
flat_nodes.extend(item)
120+
else:
121+
flat_nodes.append(item)
122+
else:
123+
flat_nodes = [nodes_in_edge]
124+
125+
for n_idx in flat_nodes:
126+
try:
127+
n_idx_int = int(n_idx)
128+
if 0 <= n_idx_int < len(node_id_list):
129+
incj.append({
130+
"edge": edge_name,
131+
"node": node_id_list[n_idx_int],
132+
"weight": 1.0,
133+
})
134+
except (ValueError, TypeError):
135+
continue
136+
137+
metadata = getattr(hg, "metadata", {})
138+
network_type = getattr(hg, "network_type", "undirected")
139+
140+
hif = {
141+
"nodes": nodj,
142+
"edges": edgj,
143+
"incidences": incj,
144+
"network-type": network_type,
145+
"metadata": metadata
146+
}
147+
148+
try:
149+
validator = _get_hif_validator()
150+
validator(hif)
151+
except Exception as e:
152+
print(f"Validation Warning: {e}")
153+
154+
if filename:
155+
with open(filename, "w", encoding='utf-8') as f:
156+
json.dump(hif, f, indent=4, ensure_ascii=False)
157+
158+
return hif
159+
160+
161+
def hif_to_hypergraph(
162+
hif: dict = None,
163+
filename: Optional[Union[str, Path]] = None,
164+
node_label: str = "name",
165+
edge_label: str = "name",
166+
):
167+
"""
168+
Reads HIF JSON and returns an EasyGraph Hypergraph.
169+
Attaches original JSON parts to 'custom_hif_*' attributes to preserve
170+
structure during round-trips.
171+
"""
172+
if hif is None:
173+
if filename is None:
174+
raise EasyGraphHIFError("No HIF data or filename provided.")
175+
try:
176+
with open(filename, "r", encoding='utf-8') as f:
177+
hif = json.load(f)
178+
except Exception as e:
179+
raise EasyGraphHIFError(f"Failed to load HIF file {filename}: {e}")
180+
181+
nodes_list = hif.get("nodes", [])
182+
node_name_to_idx = {rec["node"]: i for i, rec in enumerate(nodes_list)}
183+
num_v = len(nodes_list)
184+
185+
edges_list = hif.get("edges", [])
186+
edge_name_to_idx = {rec["edge"]: i for i, rec in enumerate(edges_list)}
187+
num_e = len(edges_list)
188+
189+
v_property = [{} for _ in range(num_v)]
190+
for rec in nodes_list:
191+
idx = node_name_to_idx.get(rec["node"])
192+
if idx is not None:
193+
194+
prop = rec.get("attrs", {}).copy()
195+
if node_label in prop:
196+
prop["name"] = str(prop[node_label])
197+
else:
198+
prop["name"] = rec["node"]
199+
prop["weight"] = rec.get("weight", 1.0)
200+
v_property[idx] = prop
201+
202+
e_property_full = [{} for _ in range(num_e)]
203+
e_weight = [1.0] * num_e
204+
205+
for rec in edges_list:
206+
idx = edge_name_to_idx.get(rec["edge"])
207+
if idx is not None:
208+
prop = rec.get("attrs", {}).copy()
209+
# if "name" not in prop:
210+
# prop["name"] = rec["edge"]
211+
if edge_label in prop:
212+
prop["name"] = str(prop[edge_label])
213+
else:
214+
prop["name"] = rec["edge"]
215+
prop["weight"] = rec.get("weight", 1.0)
216+
e_property_full[idx] = prop
217+
e_weight[idx] = prop["weight"]
218+
219+
raw_groups = [[] for _ in range(num_e)]
220+
221+
incidences_list = hif.get("incidences", [])
222+
223+
for inc in incidences_list:
224+
e_name = inc.get("edge")
225+
n_name = inc.get("node")
226+
227+
e_idx = edge_name_to_idx.get(e_name)
228+
n_idx = node_name_to_idx.get(n_name)
229+
230+
if e_idx is not None and n_idx is not None:
231+
raw_groups[e_idx].append(n_idx)
232+
233+
hg = Hypergraph(
234+
num_v=num_v,
235+
e_list=raw_groups,
236+
e_weight=e_weight,
237+
v_property=v_property
238+
)
239+
240+
hg.node_label_index = {}
241+
for i in range(num_v):
242+
name = v_property[i].get("name")
243+
if name:
244+
hg.node_label_index[name] = i
245+
246+
hg.edge_label_index = {}
247+
for i in range(num_e):
248+
name = e_property_full[i].get("name")
249+
if name:
250+
hg.edge_label_index[name] = i
251+
252+
hg.custom_hif_nodes = deepcopy(nodes_list)
253+
hg.custom_hif_edges = deepcopy(edges_list)
254+
hg.custom_hif_incidences = deepcopy(incidences_list)
255+
256+
if "metadata" in hif:
257+
hg.metadata = deepcopy(hif["metadata"])
258+
else:
259+
hg.metadata = {}
260+
261+
if "network-type" in hif:
262+
hg.network_type = hif["network-type"]
263+
264+
hg.e_property_full = e_property_full
265+
266+
return hg

easygraph/utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@
1111
from easygraph.utils.relabel import *
1212
from easygraph.utils.sparse import *
1313
from easygraph.utils.type_change import *
14+
from easygraph.utils.HIF import *

0 commit comments

Comments
 (0)