stdraw 0.7.13__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.
- stdraw/__init__.py +32 -0
- stdraw/__main__.py +350 -0
- stdraw/_interactive_js.py +2030 -0
- stdraw/anchors.py +103 -0
- stdraw/bowtielayout.py +350 -0
- stdraw/check.py +651 -0
- stdraw/columnlayout.py +531 -0
- stdraw/comblayout.py +533 -0
- stdraw/crossing_exact.py +1004 -0
- stdraw/delivery.py +81 -0
- stdraw/dsl.py +99 -0
- stdraw/layout3d.py +167 -0
- stdraw/layout_auto.py +1827 -0
- stdraw/layoutdiff.py +424 -0
- stdraw/loops.py +145 -0
- stdraw/metrics.py +1225 -0
- stdraw/model.py +134 -0
- stdraw/placement.py +220 -0
- stdraw/planar.py +625 -0
- stdraw/recipes.py +68 -0
- stdraw/regularity.py +176 -0
- stdraw/render.py +361 -0
- stdraw/render3d.py +165 -0
- stdraw/rendercheck.py +90 -0
- stdraw/ringlayout.py +475 -0
- stdraw/routing.py +2630 -0
- stdraw/spinewinglayout.py +329 -0
- stdraw/symlayout.py +245 -0
- stdraw/textmetrics.py +97 -0
- stdraw/visibility.py +382 -0
- stdraw-0.7.13.dist-info/METADATA +383 -0
- stdraw-0.7.13.dist-info/RECORD +36 -0
- stdraw-0.7.13.dist-info/WHEEL +5 -0
- stdraw-0.7.13.dist-info/entry_points.txt +2 -0
- stdraw-0.7.13.dist-info/licenses/LICENSE +21 -0
- stdraw-0.7.13.dist-info/top_level.txt +1 -0
stdraw/model.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Graph data model: variables / polarity edges / loop tags / groups.
|
|
3
|
+
|
|
4
|
+
Handling the tension with "relations determine layout, zero manual intent":
|
|
5
|
+
groups are merely optional semantic annotations; the layout layer by default does **not** consume groups -- deriving groups automatically via community detection
|
|
6
|
+
belongs to later roadmap milestones; at the current stage groups only enter certificate metadata and the loop-audit context.
|
|
7
|
+
"""
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Node:
|
|
14
|
+
id: str
|
|
15
|
+
label: str
|
|
16
|
+
value: str = "" # optional: value text on the card's second line (affects card width estimation)
|
|
17
|
+
group: Optional[str] = None
|
|
18
|
+
extra_w: int = 0 # optional: extra measured margin for mixed-script titles (px, multiple of 2)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Edge:
|
|
23
|
+
a: str
|
|
24
|
+
b: str
|
|
25
|
+
polarity: str # "+" same-sign (reinforcing) / "-" opposite-sign (balancing)
|
|
26
|
+
loops: List[str] = field(default_factory=list) # tags of loops this edge participates in
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Loop:
|
|
31
|
+
id: str
|
|
32
|
+
label: str
|
|
33
|
+
nodes: List[str] # declared node sequence (closure verified against the sequence)
|
|
34
|
+
kind: str = "" # "R" reinforcing / "B" balancing (may be empty; checked against polarity parity at audit time)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Group:
|
|
39
|
+
id: str
|
|
40
|
+
label: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Graph:
|
|
45
|
+
nodes: List[Node]
|
|
46
|
+
edges: List[Edge]
|
|
47
|
+
loops: List[Loop] = field(default_factory=list)
|
|
48
|
+
groups: List[Group] = field(default_factory=list)
|
|
49
|
+
name: str = ""
|
|
50
|
+
|
|
51
|
+
# ── Convenience views ──
|
|
52
|
+
def node_ids(self) -> List[str]:
|
|
53
|
+
return [n.id for n in self.nodes]
|
|
54
|
+
|
|
55
|
+
def id2node(self) -> Dict[str, Node]:
|
|
56
|
+
return {n.id: n for n in self.nodes}
|
|
57
|
+
|
|
58
|
+
def adj_undirected(self) -> Dict[str, set]:
|
|
59
|
+
adj: Dict[str, set] = {n.id: set() for n in self.nodes}
|
|
60
|
+
for e in self.edges:
|
|
61
|
+
adj.setdefault(e.a, set()).add(e.b)
|
|
62
|
+
adj.setdefault(e.b, set()).add(e.a)
|
|
63
|
+
return adj
|
|
64
|
+
|
|
65
|
+
def degree0(self) -> List[str]:
|
|
66
|
+
adj = self.adj_undirected()
|
|
67
|
+
return [n.id for n in self.nodes if not adj[n.id]]
|
|
68
|
+
|
|
69
|
+
def core(self) -> List[str]:
|
|
70
|
+
adj = self.adj_undirected()
|
|
71
|
+
return [n.id for n in self.nodes if adj[n.id]]
|
|
72
|
+
|
|
73
|
+
def validate(self) -> List[str]:
|
|
74
|
+
"""Structural validation: returns a list of problems (empty = passed)."""
|
|
75
|
+
problems = []
|
|
76
|
+
ids = self.node_ids()
|
|
77
|
+
dup = {i for i in ids if ids.count(i) > 1}
|
|
78
|
+
if dup:
|
|
79
|
+
problems.append(f"duplicate node ids: {sorted(dup)}")
|
|
80
|
+
idset = set(ids)
|
|
81
|
+
for e in self.edges:
|
|
82
|
+
if e.a not in idset or e.b not in idset:
|
|
83
|
+
problems.append(f"edge endpoint not declared: {e.a}->{e.b}")
|
|
84
|
+
if e.polarity not in ("+", "-"):
|
|
85
|
+
problems.append(f"invalid polarity: {e.a}->{e.b} {e.polarity!r}")
|
|
86
|
+
id2loop = {lp.id: lp for lp in self.loops}
|
|
87
|
+
for e in self.edges:
|
|
88
|
+
for lid in e.loops:
|
|
89
|
+
if lid not in id2loop:
|
|
90
|
+
problems.append(f"edge {e.a}->{e.b} references undeclared loop {lid}")
|
|
91
|
+
for lp in self.loops:
|
|
92
|
+
for v in lp.nodes:
|
|
93
|
+
if v not in idset:
|
|
94
|
+
problems.append(f"loop {lp.id} references undeclared node {v}")
|
|
95
|
+
for n in self.nodes:
|
|
96
|
+
if n.group and n.group not in {g.id for g in self.groups}:
|
|
97
|
+
problems.append(f"node {n.id} references undeclared group {n.group}")
|
|
98
|
+
return problems
|
|
99
|
+
|
|
100
|
+
def to_dict(self) -> dict:
|
|
101
|
+
return {
|
|
102
|
+
"name": self.name,
|
|
103
|
+
"nodes": [
|
|
104
|
+
{"id": n.id, "label": n.label, "value": n.value,
|
|
105
|
+
"group": n.group, "extra_w": n.extra_w}
|
|
106
|
+
for n in self.nodes
|
|
107
|
+
],
|
|
108
|
+
"edges": [
|
|
109
|
+
{"a": e.a, "b": e.b, "polarity": e.polarity, "loops": e.loops}
|
|
110
|
+
for e in self.edges
|
|
111
|
+
],
|
|
112
|
+
"loops": [
|
|
113
|
+
{"id": lp.id, "label": lp.label, "nodes": lp.nodes, "kind": lp.kind}
|
|
114
|
+
for lp in self.loops
|
|
115
|
+
],
|
|
116
|
+
"groups": [{"id": g.id, "label": g.label} for g in self.groups],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def from_dict(cls, d: dict) -> "Graph":
|
|
121
|
+
return cls(
|
|
122
|
+
name=d.get("name", ""),
|
|
123
|
+
nodes=[Node(**{k: v for k, v in nd.items()
|
|
124
|
+
if k in Node.__dataclass_fields__})
|
|
125
|
+
for nd in d.get("nodes", [])],
|
|
126
|
+
edges=[Edge(a=e["a"], b=e["b"], polarity=e.get("polarity", "+"),
|
|
127
|
+
loops=list(e.get("loops", [])))
|
|
128
|
+
for e in d.get("edges", [])],
|
|
129
|
+
loops=[Loop(id=lp["id"], label=lp.get("label", ""),
|
|
130
|
+
nodes=list(lp.get("nodes", [])), kind=lp.get("kind", ""))
|
|
131
|
+
for lp in d.get("loops", [])],
|
|
132
|
+
groups=[Group(id=g["id"], label=g.get("label", ""))
|
|
133
|
+
for g in d.get("groups", [])],
|
|
134
|
+
)
|
stdraw/placement.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Slot-discretized placement layer (WS2 item 6): structure-detection-driven regular slots.
|
|
3
|
+
|
|
4
|
+
Design (same source as docs/crossing-exact.md §6):
|
|
5
|
+
|
|
6
|
+
- **Structure-driven slot shapes**:
|
|
7
|
+
cycle graph -> regular k-gon slots (angles evenly split as i*2pi/k;
|
|
8
|
+
cycle-order assignment puts all ring edges on the convex hull
|
|
9
|
+
-> crossings = 0 by construction; regular-polygon deviation = 0
|
|
10
|
+
by construction) — constructive, no solver needed;
|
|
11
|
+
chain graph -> collinear equidistant slots (straightness = 1 by
|
|
12
|
+
construction; geometric overlap of adjacent edges is resolved
|
|
13
|
+
downstream by the routing layer with arcs; the center-caliber
|
|
14
|
+
crossing/regularity metrics are unaffected) — constructive;
|
|
15
|
+
general -> layered row slots + CP-SAT (the only branch that needs a solver).
|
|
16
|
+
- **CP-SAT branch hard constraints** (violation = infeasible):
|
|
17
|
+
1) in-row card gap >= min_gap (headroom over the >=16px gate);
|
|
18
|
+
2) boxes must not press lines (with 1px headroom): the interpolated x of a
|
|
19
|
+
cross-row edge at intermediate rows stays >= CLEARANCE from unrelated
|
|
20
|
+
box edges (exact linearization for t in {1/2,1/3,2/3,3/4,1/4},
|
|
21
|
+
conservative two-sided constraints for the other interpolation factors);
|
|
22
|
+
3) inside the viewBox: slot-domain bounding.
|
|
23
|
+
- **CP-SAT objective**: min 60*sum(crossings) + sum(|dx|) (crossings primary,
|
|
24
|
+
in-row evenness secondary — isomorphic to the auto-v2 placement objective).
|
|
25
|
+
- **Determinism**: num_workers=1 + seed 42; no wall-clock limit (a wall-clock
|
|
26
|
+
limit drifts with load).
|
|
27
|
+
- **Delivery discipline**: after coordinates are returned the caller re-runs the
|
|
28
|
+
metrics/check hard gates; any violation
|
|
29
|
+
falls back to the auto-v2 constructive path — this layer never delivers
|
|
30
|
+
while injured.
|
|
31
|
+
- **Honest boundary**: "regularity-optimal" for the cycle/chain branches is
|
|
32
|
+
relative to the slot-model class
|
|
33
|
+
(regular-polygon slots + the declared cycle-order assignment), not a proof
|
|
34
|
+
of optimality over all permutations; the crossing objective of the general
|
|
35
|
+
branch is the conservative row-slot caliber (close proximity not counted),
|
|
36
|
+
and only solving to OPTIMAL makes it optimal for that caliber.
|
|
37
|
+
"""
|
|
38
|
+
import math
|
|
39
|
+
|
|
40
|
+
SLOT = 24
|
|
41
|
+
CLEARANCE = 16 + 1 # 16px hard gate + 1px headroom
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def has_solver():
|
|
45
|
+
try:
|
|
46
|
+
from ortools.sat.python import cp_model # noqa: F401
|
|
47
|
+
return True
|
|
48
|
+
except Exception:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def place(graph, widths, rows=None, card_h=44, pitch=80, top=92, margin=44,
|
|
53
|
+
min_gap=18, target_w=1280):
|
|
54
|
+
"""Slot placement entry point. Returns {x, y, viewBox, structure, engine} or None (fallback).
|
|
55
|
+
|
|
56
|
+
graph: stdraw.model.Graph; widths: {v: card width}; rows: layered rows (used by general).
|
|
57
|
+
"""
|
|
58
|
+
from . import regularity
|
|
59
|
+
nodes = graph.node_ids()
|
|
60
|
+
edges = [(e.a, e.b) for e in graph.edges]
|
|
61
|
+
st = regularity.detect_structure(graph)
|
|
62
|
+
n = len(nodes)
|
|
63
|
+
|
|
64
|
+
if st["kind"] == "cycle" and len(st["cycle_nodes"]) == 1 \
|
|
65
|
+
and n == len(st["cycle_nodes"][0]) and n >= 3:
|
|
66
|
+
return _place_cycle(st["cycle_nodes"][0], target_w)
|
|
67
|
+
if st["kind"] == "chain" and len(st["chain_nodes"]) == 1 \
|
|
68
|
+
and n == len(st["chain_nodes"][0]) and n >= 2:
|
|
69
|
+
return _place_chain(st["chain_nodes"][0], widths, margin)
|
|
70
|
+
if has_solver():
|
|
71
|
+
return _place_layered(graph, nodes, edges, widths, rows, pitch,
|
|
72
|
+
top, margin, min_gap, target_w)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _place_cycle(cyc, target_w):
|
|
77
|
+
"""Regular k-gon slots (angles evenly split, cycle-order assignment).
|
|
78
|
+
Crossings 0 and deviation 0 hold by construction.
|
|
79
|
+
|
|
80
|
+
Quantization uses a 2px grid (the delivered-coordinate caliber): coarse
|
|
81
|
+
24px quantization visibly distorts the regular polygon and inflates
|
|
82
|
+
radial/angular CV — regularity metrics run on delivered coordinates, so
|
|
83
|
+
the quantization must be fine enough."""
|
|
84
|
+
k = len(cyc)
|
|
85
|
+
radius = max(150, min(420, k * 34))
|
|
86
|
+
cx = max(k * 48, target_w) // 2
|
|
87
|
+
cy = radius + 90
|
|
88
|
+
x, y = {}, {}
|
|
89
|
+
for i, v in enumerate(cyc):
|
|
90
|
+
ang = 2 * math.pi * i / k - math.pi / 2
|
|
91
|
+
x[v] = int(round((cx + radius * math.cos(ang)) / 2) * 2)
|
|
92
|
+
y[v] = int(round((cy + radius * math.sin(ang)) / 2) * 2)
|
|
93
|
+
allv = list(x.values()) + list(y.values())
|
|
94
|
+
W = max(allv) + 48
|
|
95
|
+
H = max(y.values()) + 48
|
|
96
|
+
return {"x": x, "y": y, "viewBox": [0, 0, W, H],
|
|
97
|
+
"structure": "cycle", "engine": "construct-regular-polygon"}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _place_chain(chain, widths, margin):
|
|
101
|
+
"""Collinear equidistant slots (declared chain order). Straightness 1 holds by construction."""
|
|
102
|
+
cur = max(margin, SLOT * 2)
|
|
103
|
+
xs, ys = {}, {}
|
|
104
|
+
for v in chain:
|
|
105
|
+
xs[v] = cur
|
|
106
|
+
ys[v] = 120
|
|
107
|
+
cur += max(SLOT * 4, int((widths.get(v, 96) + 48) / SLOT) * SLOT)
|
|
108
|
+
return {"x": xs, "y": ys, "viewBox": [0, 0, cur + 48, 240],
|
|
109
|
+
"structure": "chain", "engine": "construct-collinear"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _place_layered(graph, nodes, edges, widths, rows, pitch, top, margin,
|
|
113
|
+
min_gap, target_w):
|
|
114
|
+
"""Layered row slots + CP-SAT (the only branch that needs a solver)."""
|
|
115
|
+
if not rows:
|
|
116
|
+
return None
|
|
117
|
+
from ortools.sat.python import cp_model
|
|
118
|
+
row_of = {v: r for r, vs in enumerate(rows) for v in vs}
|
|
119
|
+
n_slots = int(target_w // SLOT) + 1
|
|
120
|
+
m = cp_model.CpModel()
|
|
121
|
+
xs = {v: m.NewIntVar(margin // SLOT, n_slots - 1, f"sx_{v}") for v in nodes}
|
|
122
|
+
|
|
123
|
+
# Hard constraint 1: in-row card gap (slot scale)
|
|
124
|
+
for vs in rows:
|
|
125
|
+
for i in range(len(vs)):
|
|
126
|
+
for j in range(i + 1, len(vs)):
|
|
127
|
+
u, w = vs[i], vs[j]
|
|
128
|
+
need = int(math.ceil((widths[u] + widths[w]) / 2.0 + min_gap)
|
|
129
|
+
/ SLOT) + 1
|
|
130
|
+
d = m.NewIntVar(0, n_slots, "")
|
|
131
|
+
m.AddAbsEquality(d, xs[u] - xs[w])
|
|
132
|
+
m.Add(d >= need)
|
|
133
|
+
|
|
134
|
+
# Hard constraint 2: cross-row edges must not press unrelated boxes (with 1px headroom)
|
|
135
|
+
for (a, b) in edges:
|
|
136
|
+
ra, rb = row_of[a], row_of[b]
|
|
137
|
+
if ra == rb:
|
|
138
|
+
continue
|
|
139
|
+
lo, hi = min(ra, rb), max(ra, rb)
|
|
140
|
+
for v in nodes:
|
|
141
|
+
rv = row_of[v]
|
|
142
|
+
if rv <= lo or rv >= hi:
|
|
143
|
+
continue
|
|
144
|
+
t = (rv - ra) / (rb - ra)
|
|
145
|
+
xv = xs[v]
|
|
146
|
+
denom = None
|
|
147
|
+
for q in (2, 3, 4):
|
|
148
|
+
if abs(t * q - round(t * q)) < 1e-9:
|
|
149
|
+
denom, p_ = q, round(t * q)
|
|
150
|
+
break
|
|
151
|
+
slack = int(math.ceil(CLEARANCE / SLOT)) + 1
|
|
152
|
+
if denom:
|
|
153
|
+
# x_at = xa + (xb - xa)*p_/denom; multiply both sides of the constraint by denom to integerize
|
|
154
|
+
left = m.NewBoolVar("")
|
|
155
|
+
right = m.NewBoolVar("")
|
|
156
|
+
expr = denom * xs[a] + p_ * (xs[b] - xs[a])
|
|
157
|
+
m.Add(expr <= denom * (xv - slack)).OnlyEnforceIf(left)
|
|
158
|
+
m.Add(expr >= denom * (xv + slack)).OnlyEnforceIf(right)
|
|
159
|
+
m.AddBoolOr([left, right])
|
|
160
|
+
else:
|
|
161
|
+
left_a = m.NewBoolVar("")
|
|
162
|
+
right_a = m.NewBoolVar("")
|
|
163
|
+
m.Add(xs[a] <= xv - slack).OnlyEnforceIf(left_a)
|
|
164
|
+
m.Add(xs[a] >= xv + slack).OnlyEnforceIf(right_a)
|
|
165
|
+
m.AddBoolOr([left_a, right_a])
|
|
166
|
+
left_b = m.NewBoolVar("")
|
|
167
|
+
right_b = m.NewBoolVar("")
|
|
168
|
+
m.Add(xs[b] <= xv - slack).OnlyEnforceIf(left_b)
|
|
169
|
+
m.Add(xs[b] >= xv + slack).OnlyEnforceIf(right_b)
|
|
170
|
+
m.AddBoolOr([left_b, right_b])
|
|
171
|
+
|
|
172
|
+
# Objective: crossings (row-interval interleaving + reversed endpoint relative order, conservative, close proximity not counted) + sum(|dx|)
|
|
173
|
+
einfo = []
|
|
174
|
+
for (a, b) in edges:
|
|
175
|
+
ra, rb = row_of[a], row_of[b]
|
|
176
|
+
einfo.append((a, b, min(ra, rb), max(ra, rb)))
|
|
177
|
+
cross_terms = []
|
|
178
|
+
for i in range(len(einfo)):
|
|
179
|
+
a1, b1, l1, h1 = einfo[i]
|
|
180
|
+
for j in range(i + 1, len(einfo)):
|
|
181
|
+
a2, b2, l2, h2 = einfo[j]
|
|
182
|
+
if {a1, b1} & {a2, b2}:
|
|
183
|
+
continue
|
|
184
|
+
if min(h1, h2) - max(l1, l2) < 1:
|
|
185
|
+
continue
|
|
186
|
+
top_a = a1 if row_of[a1] < row_of[b1] else b1
|
|
187
|
+
bot_a = b1 if row_of[a1] < row_of[b1] else a1
|
|
188
|
+
top_b = a2 if row_of[a2] < row_of[b2] else b2
|
|
189
|
+
bot_b = b2 if row_of[a2] < row_of[b2] else a2
|
|
190
|
+
if row_of[top_a] == row_of[top_b] or row_of[bot_a] == row_of[bot_b]:
|
|
191
|
+
continue
|
|
192
|
+
p = m.NewBoolVar("")
|
|
193
|
+
q = m.NewBoolVar("")
|
|
194
|
+
m.Add(xs[top_a] <= xs[top_b] - 1).OnlyEnforceIf(p)
|
|
195
|
+
m.Add(xs[top_a] >= xs[top_b]).OnlyEnforceIf(p.Not())
|
|
196
|
+
m.Add(xs[bot_a] <= xs[bot_b] - 1).OnlyEnforceIf(q)
|
|
197
|
+
m.Add(xs[bot_a] >= xs[bot_b]).OnlyEnforceIf(q.Not())
|
|
198
|
+
c = m.NewBoolVar("")
|
|
199
|
+
m.Add(p + q == 1).OnlyEnforceIf(c)
|
|
200
|
+
m.Add(p + q != 1).OnlyEnforceIf(c.Not())
|
|
201
|
+
cross_terms.append(c)
|
|
202
|
+
lens = []
|
|
203
|
+
for (a, b) in edges:
|
|
204
|
+
d = m.NewIntVar(0, n_slots, "")
|
|
205
|
+
m.AddAbsEquality(d, xs[a] - xs[b])
|
|
206
|
+
lens.append(d)
|
|
207
|
+
m.Minimize(60 * sum(cross_terms) + sum(lens))
|
|
208
|
+
solver = cp_model.CpSolver()
|
|
209
|
+
solver.parameters.num_workers = 1 # determinism iron rule
|
|
210
|
+
solver.parameters.random_seed = 42 # no wall-clock limit: time limits drift with load
|
|
211
|
+
st = solver.Solve(m)
|
|
212
|
+
if st not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
|
213
|
+
return None
|
|
214
|
+
x = {v: int(solver.Value(xs[v])) * SLOT for v in nodes}
|
|
215
|
+
y = {v: top + row_of[v] * pitch for v in nodes}
|
|
216
|
+
W = max(x.values()) + max(widths.values()) // 2 + 48
|
|
217
|
+
H = top + (max(row_of.values()) + 1) * pitch + 48
|
|
218
|
+
return {"x": x, "y": y, "viewBox": [0, 0, W, H],
|
|
219
|
+
"structure": "layered", "engine": "cp-sat",
|
|
220
|
+
"status": solver.StatusName(st)}
|