Add source cap cut to medial tire figures
This commit is contained in:
@@ -4,16 +4,18 @@ Paper-graphics companion to ``run_medial_tire_cut_experiment.py``: it imports
|
||||
``run_experiment`` from there, runs the pipeline on a random maximal planar
|
||||
graph, and emits TikZ. By default it draws one ``tikzpicture`` (walk-depth
|
||||
labels + cut slits) per recognised full medial tire graph, using ``to_tikz``
|
||||
from ``medial_tire_cut_labelling``. With ``--whole`` it instead draws the whole
|
||||
medial graph M(G) with every tire's cuts applied, on a Kamada--Kawai layout, the
|
||||
recognised tires highlighted and the rest of M(G) in grey.
|
||||
from ``medial_tire_cut_labelling``. With ``--whole`` it instead draws a
|
||||
two-panel Figure 3 graphic: the source graph with its source highlighted and
|
||||
the whole medial graph M(G) drawn with every medial vertex at the midpoint of
|
||||
its source edge and labelled by that source edge, with the full BFS-level chain
|
||||
shown and the currently computed walk-depth labels and cuts marked.
|
||||
|
||||
This script only renders; the experiment itself draws nothing. Run with the
|
||||
repo venv (networkx): ``.venv/bin/python``.
|
||||
|
||||
Examples:
|
||||
.venv/bin/python draw_medial_tire_cut.py -n 20 --seed 72 > panels.tex
|
||||
.venv/bin/python draw_medial_tire_cut.py -n 20 --seed 72 --whole > whole.tex
|
||||
.venv/bin/python draw_medial_tire_cut.py -n 20 --seed 59 > panels.tex
|
||||
.venv/bin/python draw_medial_tire_cut.py -n 20 --seed 59 --whole > whole.tex
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,18 +26,21 @@ import os
|
||||
import sys
|
||||
|
||||
import networkx as nx
|
||||
import numpy as np
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
from run_medial_tire_cut_experiment import run_experiment # noqa: E402
|
||||
from medial_tire_cut_labelling import to_tikz # noqa: E402
|
||||
from tire_realization_analysis import triangular_faces # noqa: E402
|
||||
|
||||
|
||||
def tikz_panels(n: int, seed: int, scale: float = 1.6) -> tuple[dict, list[str]]:
|
||||
def tikz_panels(n: int, seed: int, scale: float = 1.6,
|
||||
min_degree: int = 5, attempts: int = 1000) -> tuple[dict, list[str]]:
|
||||
"""Run the experiment and return ``(result, panels)``, one TikZ panel per
|
||||
recognised tread, each showing that tread's walk-depth labelling and cut."""
|
||||
result = run_experiment(n=n, seed=seed)
|
||||
result = run_experiment(n=n, seed=seed, min_degree=min_degree, attempts=attempts)
|
||||
panels = []
|
||||
for d in sorted(result["results"]):
|
||||
rec = result["results"][d]
|
||||
@@ -45,104 +50,228 @@ def tikz_panels(n: int, seed: int, scale: float = 1.6) -> tuple[dict, list[str]]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The whole medial graph: M(G) with all tire cuts applied.
|
||||
# Figure 3: the source graph and midpoint drawing of the whole medial graph.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _is_split(node) -> bool:
|
||||
return isinstance(node, tuple) and len(node) == 3 and node[1] in ("A", "B")
|
||||
def _source_layout(G: nx.Graph) -> dict[int, tuple[float, float]]:
|
||||
"""Straight-line planar layout for the source graph, normalised to the unit
|
||||
box and reused by the medial drawing."""
|
||||
faces, _ = triangular_faces(G)
|
||||
outer = list(faces[0])
|
||||
outer_set = set(outer)
|
||||
raw = {}
|
||||
for i, v in enumerate(outer):
|
||||
angle = math.radians(90.0 - i * 360.0 / len(outer))
|
||||
raw[v] = np.array([math.cos(angle), math.sin(angle)], dtype=float)
|
||||
|
||||
inner = [v for v in sorted(G.nodes()) if v not in outer_set]
|
||||
if inner:
|
||||
idx = {v: i for i, v in enumerate(inner)}
|
||||
n = len(inner)
|
||||
A = np.zeros((n, n))
|
||||
bx = np.zeros(n)
|
||||
by = np.zeros(n)
|
||||
for i, v in enumerate(inner):
|
||||
nbrs = list(G.neighbors(v))
|
||||
A[i, i] = 1.0
|
||||
for w in nbrs:
|
||||
if w in idx:
|
||||
A[i, idx[w]] -= 1.0 / len(nbrs)
|
||||
else:
|
||||
bx[i] += raw[w][0] / len(nbrs)
|
||||
by[i] += raw[w][1] / len(nbrs)
|
||||
xs = np.linalg.solve(A, bx)
|
||||
ys = np.linalg.solve(A, by)
|
||||
for v in inner:
|
||||
raw[v] = np.array([xs[idx[v]], ys[idx[v]]], dtype=float)
|
||||
|
||||
pts = np.array([raw[v] for v in G.nodes()], dtype=float)
|
||||
center = 0.5 * (pts.max(axis=0) + pts.min(axis=0))
|
||||
span = float(max(*(pts.max(axis=0) - pts.min(axis=0)), 1.0))
|
||||
return {
|
||||
v: tuple((raw[v] - center) / span)
|
||||
for v in G.nodes()
|
||||
}
|
||||
|
||||
|
||||
def _medial_layout(H: nx.Graph) -> dict:
|
||||
"""A Kamada--Kawai layout of the (planar) cut graph, normalised to the unit
|
||||
box. The two copies of a cut vertex have different neighbours, so the layout
|
||||
separates them automatically, showing the slit."""
|
||||
pos = nx.kamada_kawai_layout(H)
|
||||
xs = [p[0] for p in pos.values()]
|
||||
ys = [p[1] for p in pos.values()]
|
||||
cx, cy = 0.5 * (max(xs) + min(xs)), 0.5 * (max(ys) + min(ys))
|
||||
span = max(max(xs) - min(xs), max(ys) - min(ys)) or 1.0
|
||||
return {v: ((p[0] - cx) / span, (p[1] - cy) / span) for v, p in pos.items()}
|
||||
def _edge_midpoint(pos: dict, edge) -> tuple[float, float]:
|
||||
u, v = edge
|
||||
ux, uy = pos[u]
|
||||
vx, vy = pos[v]
|
||||
return (0.5 * (ux + vx), 0.5 * (uy + vy))
|
||||
|
||||
|
||||
def medial_tikz(result: dict, scale: float = 9.0) -> str:
|
||||
"""A TikZ ``tikzpicture`` of the whole medial graph M(G) with every tire's
|
||||
cuts applied. Tire teeth are coloured and carry their walk depth; annular
|
||||
medial vertices are black; medial vertices outside any recognised tire are
|
||||
grey; cut (split) vertices are drawn as separated copies."""
|
||||
H = result["cut_graph"]
|
||||
pos = _medial_layout(H)
|
||||
def _edge_label(edge) -> str:
|
||||
u, v = edge
|
||||
return f"${u}\\!{{-}}\\!{v}$"
|
||||
|
||||
# role of each medial vertex: annular / up / down / bite, and walk depth.
|
||||
annular = set()
|
||||
|
||||
def _source_graph_tikz(result: dict, pos: dict, scale: float) -> str:
|
||||
G, source = result["G"], result["source"]
|
||||
L = []
|
||||
A = L.append
|
||||
A(f"\\begin{{tikzpicture}}[scale={scale},")
|
||||
A(" sedge/.style={black!50, line width=0.35pt},")
|
||||
A(" sv/.style={circle, draw=black!60, fill=white, inner sep=1.1pt},")
|
||||
A(" srcv/.style={circle, draw=blue!75!black, fill=blue!18, line width=0.7pt, inner sep=1.8pt}]")
|
||||
|
||||
def pt(v):
|
||||
x, y = pos[v]
|
||||
return f"({x:.3f},{y:.3f})"
|
||||
|
||||
for u, v in sorted(G.edges()):
|
||||
A(f"\\draw[sedge] {pt(u)}--{pt(v)};")
|
||||
for v in sorted(G.nodes()):
|
||||
style = "srcv" if v == source else "sv"
|
||||
A(f"\\node[{style}] at {pt(v)} {{}};")
|
||||
sx, sy = pos[source]
|
||||
A(f"\\node[font=\\scriptsize, text=blue!70!black] at ({sx:.3f},{sy - 0.085:.3f}) {{source {source}}};")
|
||||
A("\\end{tikzpicture}")
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
def _medial_midpoint_tikz(result: dict, pos: dict, scale: float) -> str:
|
||||
"""Draw M(G) with each medial vertex at the midpoint of its source edge.
|
||||
Every medial vertex is labelled by its source edge; same-level source edges
|
||||
show the BFS level-chain tooth layers, and interlevel source edges show the
|
||||
annular layers. Currently computed tire walk-depth labels and cut labels
|
||||
are overlaid without moving the medial vertices away from their source
|
||||
edges."""
|
||||
G, M = result["G"], result["M"]
|
||||
levels = nx.single_source_shortest_path_length(G, result["source"])
|
||||
medial_pos = {edge: _edge_midpoint(pos, edge) for edge in M.nodes()}
|
||||
|
||||
apex_roles = {}
|
||||
apex_walks = {}
|
||||
for r in result["labels"]:
|
||||
apex_roles[r["apex"]] = r["role"]
|
||||
apex_walks.setdefault(r["apex"], []).append(r["walk"])
|
||||
|
||||
cut_records = []
|
||||
cut_number = 1
|
||||
for c in result.get("cap_cuts", []):
|
||||
cut_records.append((cut_number, c["medial_vertex"], "cap", c))
|
||||
cut_number += 1
|
||||
for d in sorted(result["results"]):
|
||||
g, bij = result["results"][d]["g"], result["results"][d]["bij"]
|
||||
annular.update(bij[f"a{k}"] for k in range(g.n))
|
||||
apex = {r["apex"]: (r["role"], r["walk"]) for r in result["labels"]}
|
||||
|
||||
def edge_of(node):
|
||||
return node[0] if _is_split(node) else node
|
||||
rec = result["results"][d]
|
||||
g, bij = rec["g"], rec["bij"]
|
||||
for c in rec["cuts"]:
|
||||
if c.vertex is None:
|
||||
continue
|
||||
cut_records.append((cut_number, bij[f"a{c.vertex}"], d, c))
|
||||
cut_number += 1
|
||||
|
||||
L = []
|
||||
A = L.append
|
||||
A(f"\\begin{{tikzpicture}}[scale={scale},")
|
||||
A(" med/.style={black!30, line width=0.3pt},")
|
||||
A(" grey/.style={circle, draw=black!45, fill=black!8, inner sep=0.9pt},")
|
||||
A(" ann/.style={circle, fill=black, inner sep=1.0pt},")
|
||||
A(" cutv/.style={circle, draw=red!75!black, fill=red!12, inner sep=1.0pt},")
|
||||
A(" upv/.style={circle, draw=blue!70!black, fill=blue!15, inner sep=1.3pt},")
|
||||
A(" downv/.style={circle, draw=red!70!black, fill=red!15, inner sep=1.3pt},")
|
||||
A(" bitev/.style={circle, draw=red!70!black, fill=red!35, inner sep=1.6pt},")
|
||||
A(" dlbl/.style={font=\\tiny\\bfseries, text=black, inner sep=0.5pt}]")
|
||||
A(" base/.style={black!12, line width=0.25pt},")
|
||||
A(" med/.style={black!38, line width=0.32pt},")
|
||||
A(" annv/.style={circle, draw=black!70, fill=black!18, inner sep=1.0pt},")
|
||||
A(" levone/.style={circle, draw=orange!75!black, fill=orange!20, inner sep=1.2pt},")
|
||||
A(" levtwo/.style={circle, draw=violet!70!black, fill=violet!18, inner sep=1.2pt},")
|
||||
A(" levthree/.style={circle, draw=teal!70!black, fill=teal!18, inner sep=1.2pt},")
|
||||
A(" knownv/.style={circle, draw=red!70!black, fill=red!24, inner sep=1.5pt},")
|
||||
A(" elbl/.style={font=\\tiny, text=black!70, inner sep=0.2pt},")
|
||||
A(" dlbl/.style={font=\\tiny\\bfseries, text=black, inner sep=0.5pt},")
|
||||
A(" cut/.style={red!80!black, line width=1.0pt},")
|
||||
A(" cutlbl/.style={font=\\tiny, text=red!75!black}]")
|
||||
|
||||
def pt(node):
|
||||
x, y = pos[node]
|
||||
def pt_med(edge):
|
||||
x, y = medial_pos[edge]
|
||||
return f"({x:.3f},{y:.3f})"
|
||||
|
||||
for u, v in H.edges():
|
||||
A(f"\\draw[med] {pt(u)}--{pt(v)};")
|
||||
for node in H.nodes():
|
||||
mv = edge_of(node)
|
||||
if mv in apex:
|
||||
role, _ = apex[mv]
|
||||
style = {"up": "upv", "down": "downv", "bite": "bitev"}[role]
|
||||
elif mv in annular:
|
||||
style = "cutv" if _is_split(node) else "ann"
|
||||
else:
|
||||
style = "grey"
|
||||
A(f"\\node[{style}] at {pt(node)} {{}};")
|
||||
for node in H.nodes():
|
||||
mv = edge_of(node)
|
||||
if _is_split(node) or mv not in apex:
|
||||
continue
|
||||
x, y = pos[node]
|
||||
A(f"\\node[dlbl] at ({x:.3f},{y:.3f}) [yshift=4.5pt] {{{apex[mv][1]}}};")
|
||||
def pt_src(v):
|
||||
x, y = pos[v]
|
||||
return f"({x:.3f},{y:.3f})"
|
||||
|
||||
for u, v in sorted(result["G"].edges()):
|
||||
A(f"\\draw[base] {pt_src(u)}--{pt_src(v)};")
|
||||
for u, v in M.edges():
|
||||
A(f"\\draw[med] {pt_med(u)}--{pt_med(v)};")
|
||||
|
||||
def chain_style(edge):
|
||||
u, v = edge
|
||||
lu, lv = levels[u], levels[v]
|
||||
if lu != lv:
|
||||
return "annv"
|
||||
if edge in apex_roles:
|
||||
return "knownv"
|
||||
return {1: "levone", 2: "levtwo", 3: "levthree"}.get(lu, "annv")
|
||||
|
||||
for mv in sorted(M.nodes()):
|
||||
A(f"\\node[{chain_style(mv)}] at {pt_med(mv)} {{}};")
|
||||
for mv in sorted(M.nodes()):
|
||||
x, y = medial_pos[mv]
|
||||
A(f"\\node[elbl] at ({x:.3f},{y:.3f}) [yshift=-4.8pt] {{{_edge_label(mv)}}};")
|
||||
for mv in sorted(apex_walks):
|
||||
x, y = medial_pos[mv]
|
||||
label = ",".join(str(w) for w in sorted(apex_walks[mv]))
|
||||
A(f"\\node[dlbl] at ({x:.3f},{y:.3f}) [yshift=5.0pt] {{{label}}};")
|
||||
for number, mv, _d, _cut in cut_records:
|
||||
u, v = mv
|
||||
ux, uy = pos[u]
|
||||
vx, vy = pos[v]
|
||||
mx, my = medial_pos[mv]
|
||||
ex, ey = vx - ux, vy - uy
|
||||
length = math.hypot(ex, ey) or 1.0
|
||||
dx, dy = -0.035 * ey / length, 0.035 * ex / length
|
||||
A(f"\\draw[cut] ({mx - dx:.3f},{my - dy:.3f})--({mx + dx:.3f},{my + dy:.3f});")
|
||||
A(f"\\node[cutlbl] at ({mx + 2.4 * dx:.3f},{my + 2.4 * dy:.3f}) {{cut {number}}};")
|
||||
A("\\end{tikzpicture}")
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
def medial_tikz(result: dict, scale: float = 7.0) -> str:
|
||||
"""Two-panel TikZ for Figure 3: the source graph and the midpoint drawing of
|
||||
its medial graph with all medial vertices labelled, plus the tire
|
||||
walk-depth labels and cuts."""
|
||||
pos = _source_layout(result["G"])
|
||||
source = _source_graph_tikz(result, pos, scale=0.58 * scale)
|
||||
medial = _medial_midpoint_tikz(result, pos, scale=scale)
|
||||
return "\n".join([
|
||||
"\\begin{tabular}{c}",
|
||||
source,
|
||||
"\\\\[-0.25ex]",
|
||||
"{\\scriptsize source graph $G$}",
|
||||
"\\\\[1.0ex]",
|
||||
medial,
|
||||
"\\\\[-0.25ex]",
|
||||
"{\\scriptsize medial graph $M(G)$ at edge midpoints}",
|
||||
"\\end{tabular}",
|
||||
])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("-n", type=int, default=20)
|
||||
parser.add_argument("--seed", type=int, default=72)
|
||||
parser.add_argument("--scale", type=float, default=1.6)
|
||||
parser.add_argument("--min-degree", type=int, default=5,
|
||||
help="reject random graphs below this minimum degree")
|
||||
parser.add_argument("--attempts", type=int, default=1000,
|
||||
help="number of consecutive seeds to try for --min-degree")
|
||||
parser.add_argument("--whole", action="store_true",
|
||||
help="draw the whole medial graph M(G) with all cuts, "
|
||||
"instead of one panel per tread")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.whole:
|
||||
result = run_experiment(n=args.n, seed=args.seed)
|
||||
result = run_experiment(n=args.n, seed=args.seed,
|
||||
min_degree=args.min_degree, attempts=args.attempts)
|
||||
treads = sorted(result["results"])
|
||||
print(f"% whole medial graph: n={args.n} seed={args.seed} "
|
||||
f"graph_seed={result['graph_seed']} min_degree={result['min_degree']} "
|
||||
f"source={result['source']} recognised treads={treads} "
|
||||
f"|M(G)|={result['M'].number_of_nodes()}")
|
||||
print(medial_tikz(result, scale=args.scale if args.scale != 1.6 else 9.0))
|
||||
print(medial_tikz(result, scale=args.scale if args.scale != 1.6 else 7.0))
|
||||
return
|
||||
|
||||
result, panels = tikz_panels(args.n, args.seed, scale=args.scale)
|
||||
result, panels = tikz_panels(args.n, args.seed, scale=args.scale,
|
||||
min_degree=args.min_degree, attempts=args.attempts)
|
||||
treads = sorted(result["results"])
|
||||
print(f"% medial tire cut: n={args.n} seed={args.seed} "
|
||||
f"graph_seed={result['graph_seed']} min_degree={result['min_degree']} "
|
||||
f"source={result['source']} recognised treads={treads}")
|
||||
if not panels:
|
||||
print("% (no recognised full medial tire graphs for this graph)")
|
||||
|
||||
@@ -4,7 +4,8 @@ End-to-end experiment for the *Medial Tire Cuts* paper:
|
||||
|
||||
1. Generate a random maximal planar graph G on n vertices (stacked seed plus
|
||||
random diagonal flips; ``random_maximal_planar`` from the medial tire
|
||||
decompositions experiments).
|
||||
decompositions experiments), optionally rejecting samples below a requested
|
||||
minimum degree.
|
||||
2. Build its medial graph M(G).
|
||||
3. Take the nested tire decomposition at one random vertex level source: the
|
||||
BFS-level treads, each realized as a FullMedialTireGraph.
|
||||
@@ -24,6 +25,9 @@ Chaining rule (walk depths across the tire tree).
|
||||
tooth and the child up tooth glued to it across the shared level cycle are
|
||||
the same medial vertex of M(G). The entry up tooth's walk depth is that
|
||||
parent down tooth's depth + 1, and the walk increments locally from there.
|
||||
* The source cap contributes one additional cut. It is placed at the
|
||||
counter-clockwise source edge incident to the cap down tooth whose apex is
|
||||
the root tread's entry up tooth.
|
||||
|
||||
Run with the repo venv (networkx + scipy): ``.venv/bin/python``.
|
||||
"""
|
||||
@@ -34,6 +38,7 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import networkx as nx
|
||||
@@ -48,8 +53,8 @@ sys.path.insert(0, _MTD)
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
from tire_realization_analysis import ( # noqa: E402
|
||||
extract_tread, medial_graph, medial_tire_facemodel, random_maximal_planar,
|
||||
recognise, triangular_faces,
|
||||
ekey, extract_tread, medial_graph, medial_tire_facemodel,
|
||||
random_maximal_planar, recognise, triangular_faces,
|
||||
)
|
||||
from medial_tire_cut_labelling import door_bite, label_and_cut # noqa: E402
|
||||
|
||||
@@ -90,11 +95,59 @@ def _label_treads(treads, results):
|
||||
"start_depth": start_depth, "depth": depth, "cuts": cuts}
|
||||
|
||||
|
||||
def _cap_cut(G, emb, source, levels, results):
|
||||
"""The source-cap cut determined by the first recognised tread's entry.
|
||||
|
||||
If the root tread enters at an up tooth whose apex is the level-1 edge
|
||||
``xy``, then ``xy`` is a down tooth of the source cap. Cut the
|
||||
counter-clockwise source edge incident to that down tooth. The returned
|
||||
record also stores the local neighbour split used to unzip the medial
|
||||
vertex in the whole medial graph.
|
||||
"""
|
||||
if not results:
|
||||
return []
|
||||
|
||||
root_depth = min(results)
|
||||
rec = results[root_depth]
|
||||
g, bij = rec["g"], rec["bij"]
|
||||
x, y = _apex_vertex(g, bij, rec["entry_edge"])
|
||||
if levels.get(x) != 1 or levels.get(y) != 1:
|
||||
return []
|
||||
|
||||
order = list(emb.neighbors_cw_order(source))
|
||||
if x not in order or y not in order:
|
||||
return []
|
||||
|
||||
next_cw = {v: order[(i + 1) % len(order)] for i, v in enumerate(order)}
|
||||
prev_cw = {v: order[(i - 1) % len(order)] for i, v in enumerate(order)}
|
||||
if next_cw[x] == y:
|
||||
cut_endpoint, other_endpoint = x, y
|
||||
elif next_cw[y] == x:
|
||||
cut_endpoint, other_endpoint = y, x
|
||||
else:
|
||||
return []
|
||||
|
||||
other_cap_endpoint = prev_cw[cut_endpoint]
|
||||
mv = ekey(source, cut_endpoint)
|
||||
return [{
|
||||
"medial_vertex": mv,
|
||||
"down_tooth": ekey(cut_endpoint, other_endpoint),
|
||||
"neighbours_a": [
|
||||
ekey(source, other_endpoint),
|
||||
ekey(cut_endpoint, other_endpoint),
|
||||
],
|
||||
"neighbours_b": [
|
||||
ekey(source, other_cap_endpoint),
|
||||
ekey(cut_endpoint, other_cap_endpoint),
|
||||
],
|
||||
}]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Assemble one final cut graph of M(G) with a global label map.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _assemble_cut_graph(M, results):
|
||||
def _assemble_cut_graph(M, results, cap_cuts=None):
|
||||
"""Apply every tread's cuts to M(G).
|
||||
|
||||
Each cut duplicates an annular medial vertex, splitting its four incident
|
||||
@@ -109,6 +162,17 @@ def _assemble_cut_graph(M, results):
|
||||
# Per cut annular vertex: map each original neighbour -> which copy keeps it.
|
||||
split = {} # medial_vertex -> {neighbour_medial_vertex: copy_node}
|
||||
warnings = []
|
||||
for i, c in enumerate(cap_cuts or []):
|
||||
mv = c["medial_vertex"]
|
||||
if mv in split:
|
||||
warnings.append(f"cap cut at {mv} was already cut; skipped")
|
||||
continue
|
||||
copy_a = (mv, "A", f"cap{i}")
|
||||
copy_b = (mv, "B", f"cap{i}")
|
||||
split[mv] = {
|
||||
**{v: copy_a for v in c["neighbours_a"]},
|
||||
**{v: copy_b for v in c["neighbours_b"]},
|
||||
}
|
||||
for d in sorted(results):
|
||||
g, bij = results[d]["g"], results[d]["bij"]
|
||||
n = g.n
|
||||
@@ -158,7 +222,39 @@ def _assemble_cut_graph(M, results):
|
||||
# Driver.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def run_experiment(n: int = 12, seed: int = 0, flips: int = 400) -> dict:
|
||||
def random_maximal_planar_min_degree(n: int, seed: int, flips: int = 400,
|
||||
min_degree: int = 0,
|
||||
attempts: int = 1000) -> tuple[nx.Graph, int]:
|
||||
"""Generate a random maximal planar graph with minimum degree at least
|
||||
``min_degree``. The returned seed is the actual sample seed used."""
|
||||
if min_degree <= 0:
|
||||
return random_maximal_planar(n, seed, flips=flips), seed
|
||||
|
||||
if min_degree >= 5:
|
||||
plantri = os.path.normpath(os.path.join(_HERE, "..", "..", "..",
|
||||
"plantri", "plantri"))
|
||||
if os.path.exists(plantri):
|
||||
data = subprocess.check_output(
|
||||
[plantri, f"-m{min_degree}", "-g", str(n)],
|
||||
stderr=subprocess.DEVNULL)
|
||||
graphs = [line for line in data.splitlines() if line]
|
||||
if graphs:
|
||||
G = nx.from_graph6_bytes(graphs[seed % len(graphs)])
|
||||
return nx.convert_node_labels_to_integers(G), seed
|
||||
|
||||
for offset in range(attempts):
|
||||
sample_seed = seed + offset
|
||||
G = random_maximal_planar(n, sample_seed, flips=flips)
|
||||
if min(dict(G.degree()).values()) >= min_degree:
|
||||
return G, sample_seed
|
||||
raise RuntimeError(
|
||||
f"no random maximal planar graph on {n} vertices with "
|
||||
f"minimum degree >= {min_degree} found in {attempts} attempts "
|
||||
f"starting at seed {seed}")
|
||||
|
||||
|
||||
def run_experiment(n: int = 12, seed: int = 0, flips: int = 400,
|
||||
min_degree: int = 5, attempts: int = 1000) -> dict:
|
||||
"""Run the full pipeline and return a structured result.
|
||||
|
||||
Result keys: ``n, seed, G, M, source, treads`` (dict depth -> (g, bij)),
|
||||
@@ -166,10 +262,11 @@ def run_experiment(n: int = 12, seed: int = 0, flips: int = 400) -> dict:
|
||||
(depth, reason)), ``cut_graph`` (networkx graph), ``labels`` (list of tooth
|
||||
records), ``warnings``.
|
||||
"""
|
||||
G = random_maximal_planar(n, seed, flips=flips)
|
||||
faces, _ = triangular_faces(G)
|
||||
G, graph_seed = random_maximal_planar_min_degree(
|
||||
n, seed, flips=flips, min_degree=min_degree, attempts=attempts)
|
||||
faces, emb = triangular_faces(G)
|
||||
M = medial_graph(G)
|
||||
source = random.Random(f"source-{seed}").choice(sorted(G.nodes()))
|
||||
source = random.Random(f"source-{graph_seed}").choice(sorted(G.nodes()))
|
||||
levels = nx.single_source_shortest_path_length(G, source)
|
||||
|
||||
treads, skipped = {}, []
|
||||
@@ -189,10 +286,14 @@ def run_experiment(n: int = 12, seed: int = 0, flips: int = 400) -> dict:
|
||||
|
||||
results = {}
|
||||
_label_treads(treads, results)
|
||||
cut_graph, labels, warnings = _assemble_cut_graph(M, results)
|
||||
cap_cuts = _cap_cut(G, emb, source, levels, results)
|
||||
cut_graph, labels, warnings = _assemble_cut_graph(M, results, cap_cuts=cap_cuts)
|
||||
return {
|
||||
"n": n, "seed": seed, "G": G, "M": M, "source": source,
|
||||
"treads": treads, "results": results, "skipped": skipped,
|
||||
"n": n, "seed": seed, "graph_seed": graph_seed,
|
||||
"min_degree": min(dict(G.degree()).values()),
|
||||
"G": G, "M": M, "source": source,
|
||||
"treads": treads, "results": results, "cap_cuts": cap_cuts,
|
||||
"skipped": skipped,
|
||||
"cut_graph": cut_graph, "labels": labels, "warnings": warnings,
|
||||
}
|
||||
|
||||
@@ -235,10 +336,16 @@ def to_json(result: dict) -> dict:
|
||||
})
|
||||
H = result["cut_graph"]
|
||||
return {
|
||||
"n": result["n"], "seed": result["seed"], "source": result["source"],
|
||||
"n": result["n"], "seed": result["seed"],
|
||||
"graph_seed": result["graph_seed"], "min_degree": result["min_degree"],
|
||||
"source": result["source"],
|
||||
"graph_edges": sorted([int(u), int(v)] for u, v in result["G"].edges()),
|
||||
"medial_vertices": result["M"].number_of_nodes(),
|
||||
"skipped": [[d, why] for d, why in result["skipped"]],
|
||||
"cap_cuts": [{
|
||||
"medial_vertex": _vname(c["medial_vertex"]),
|
||||
"down_tooth": _vname(c["down_tooth"]),
|
||||
} for c in result["cap_cuts"]],
|
||||
"treads": treads_out,
|
||||
"cut_graph": {
|
||||
"nodes": sorted(_vname(v) for v in H.nodes()),
|
||||
@@ -255,8 +362,9 @@ def to_json(result: dict) -> dict:
|
||||
def summary(result: dict) -> str:
|
||||
H, res = result["cut_graph"], result["results"]
|
||||
lines = [
|
||||
f"random maximal planar graph: n={result['n']} seed={result['seed']} "
|
||||
f"({result['G'].number_of_edges()} edges)",
|
||||
f"random maximal planar graph: n={result['n']} requested seed={result['seed']} "
|
||||
f"graph seed={result['graph_seed']} "
|
||||
f"({result['G'].number_of_edges()} edges, min degree {result['min_degree']})",
|
||||
f"medial graph M(G): {result['M'].number_of_nodes()} vertices",
|
||||
f"level source: vertex {result['source']}",
|
||||
f"recognised treads: {sorted(res)}",
|
||||
@@ -272,7 +380,7 @@ def summary(result: dict) -> str:
|
||||
lines.append(
|
||||
f"final cut graph: {H.number_of_nodes()} vertices, "
|
||||
f"{H.number_of_edges()} edges, "
|
||||
f"{sum(len(r['cuts']) for r in res.values())} cuts total")
|
||||
f"{len(result['cap_cuts']) + sum(len(r['cuts']) for r in res.values())} cuts total")
|
||||
if result["warnings"]:
|
||||
lines.append("warnings: " + "; ".join(result["warnings"]))
|
||||
return "\n".join(lines)
|
||||
@@ -285,11 +393,16 @@ def main() -> None:
|
||||
parser.add_argument("--seed", type=int, default=0)
|
||||
parser.add_argument("--flips", type=int, default=400,
|
||||
help="number of random diagonal flips when building G")
|
||||
parser.add_argument("--min-degree", type=int, default=5,
|
||||
help="reject random graphs below this minimum degree")
|
||||
parser.add_argument("--attempts", type=int, default=1000,
|
||||
help="number of consecutive seeds to try for --min-degree")
|
||||
parser.add_argument("--json", metavar="PATH",
|
||||
help="write the full result as JSON to PATH")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = run_experiment(n=args.n, seed=args.seed, flips=args.flips)
|
||||
result = run_experiment(n=args.n, seed=args.seed, flips=args.flips,
|
||||
min_degree=args.min_degree, attempts=args.attempts)
|
||||
print(summary(result))
|
||||
if args.json:
|
||||
with open(args.json, "w") as fh:
|
||||
|
||||
Reference in New Issue
Block a user