Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,13 @@ Shared setup blocks use:
<!-- docs-test: setup -->
```

A page whose blocks are all illustrative fragments can opt out wholesale, with the marker placed
just under the title:

```md
<!-- docs-test: skip-page -- placeholder names and signature-only stubs -->
```

Snippet failures report the original documentation filename and line number. The docs build fails
if a notebook cannot execute, and the test suite rejects notebooks containing committed outputs.

Expand Down
3 changes: 2 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"deflist", # definition lists inside {glossary} directives
"dollarmath",
]
myst_heading_anchors = 3
# 4 so the migration guide's level-4 subsections can be linked from its table of contents.
myst_heading_anchors = 4

# Tutorial notebooks are committed without outputs. The docs build executes them into this
# ignored cache before Sphinx runs, keeping generated images and animations out of Git.
Expand Down
138 changes: 137 additions & 1 deletion docs/generate_recom_assets.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Regenerate the static Gerrymandria plan and region assets used by the docs."""

import random
from io import BytesIO
from pathlib import Path
from collections.abc import Hashable, Mapping, Sequence
Expand All @@ -19,6 +20,9 @@
from gerrychain.examples import gerrymandria
from gerrychain.proposals import ReCom

Point = tuple[int, int]
Segment = tuple[Point, Point]

DOCS = Path(__file__).parent
IMAGES = DOCS / "user" / "images"
DISTRICTR_COLORS = (
Expand Down Expand Up @@ -51,7 +55,14 @@ def assignment_image(
graph: Graph,
assignment: Mapping[Hashable, Hashable],
show_labels: bool = False,
tree_edges: Sequence[Segment] | None = None,
cut_edge: Segment | None = None,
) -> Image.Image:
"""Render a plan as a colored grid, optionally overlaying a spanning tree and its cut edge.

``tree_edges`` and ``cut_edge`` are given in grid coordinates rather than node ids, because the
spanning tree is built on a subgraph whose node ids are renumbered from the parent graph's.
"""
grid_size = int(len(assignment) ** 0.5)
grid = np.empty((grid_size, grid_size))
labels = sorted(set(assignment.values()), key=int)
Expand Down Expand Up @@ -84,6 +95,25 @@ def assignment_image(
**LABEL_STYLE,
)

if tree_edges:
for (x1, y1), (x2, y2) in tree_edges:
ax.plot([x1, x2], [y1, y2], color="black", linewidth=2, zorder=2)
if cut_edge is not None:
# Drawn above the tree edges but below the nodes, so the cut reads as a severed
# connection between two nodes rather than a line laid over them.
(x1, y1), (x2, y2) = cut_edge
ax.plot([x1, x2], [y1, y2], color="white", linewidth=5, zorder=3)
if tree_edges:
ax.scatter(
[x for edge in tree_edges for x, _ in edge],
[y for edge in tree_edges for _, y in edge],
s=250,
c="#e6e6e6",
edgecolors="black",
linewidths=1,
zorder=4,
)

buffer = BytesIO()
fig.savefig(buffer, format="png", bbox_inches="tight", pad_inches=0)
plt.close(fig)
Expand Down Expand Up @@ -128,6 +158,90 @@ def save_district_dual_graph(graph: Graph) -> None:
plt.close(fig)


DemoFrame = tuple[dict[Hashable, Hashable], list[Segment] | None, Segment | None]


def _edges_between(
graph: Graph,
sources: set[Hashable],
targets: set[Hashable],
) -> list[tuple[Hashable, Hashable]]:
"""Every graph edge running from ``sources`` into ``targets``, in a stable order."""
pairs = ((node, n) for node in sources for n in graph.neighbors(node) if n in targets)
return sorted(pairs, key=str)


def _adjacent_district_pairs(
graph: Graph,
assignment: Mapping[Hashable, Hashable],
) -> list[tuple[Hashable, Hashable]]:
"""Every pair of districts that share a boundary, in a stable order."""
pairs = {
tuple(sorted((assignment[node], assignment[n]), key=str))
for node in assignment
for n in graph.neighbors(node)
if assignment[node] != assignment[n]
}
return sorted(pairs, key=str)


def _spanning_tree_edges(
graph: Graph,
nodes: set[Hashable],
rng: random.Random,
) -> list[tuple[Hashable, Hashable]]:
"""Draw a random spanning tree over an induced set of nodes, growing it one edge at a time."""
seen = {min(nodes, key=str)}
edges = []
while seen != nodes:
edge = rng.choice(_edges_between(graph, seen, nodes - seen))
edges.append(edge)
seen.add(edge[1])
return edges


def recom_demo_frames(
graph: Graph,
*,
seed: int,
total_steps: int,
) -> list[DemoFrame]:
"""Build frames showing how ReCom turns one pair of districts into the next."""
assignments = recom_assignments(graph, seed=seed, total_steps=total_steps)
rng = random.Random(seed)

def point(node_id: Hashable) -> Point:
data = graph.node_data(node_id)
return (data["x"], data["y"])

frames: list[DemoFrame] = [(assignments[0], None, None)]
for before, after in zip(assignments, assignments[1:]):
moved = next((node for node in before if before[node] != after[node]), None)
if moved is not None:
# A node that moved went from one district of the merged pair to the other, so its old
# and new labels name both of them.
label_a, label_b = sorted({before[moved], after[moved]}, key=str)
else:
# ReCom re-split a merged pair exactly as it was, so the plan is unchanged and nothing
# identifies which pair it merged. Any adjacent pair illustrates the step, because
# cutting the bridge between two districts' trees always restores those districts.
label_a, label_b = rng.choice(_adjacent_district_pairs(graph, after))
part_a = {node for node in after if after[node] == label_a}
part_b = {node for node in after if after[node] == label_b}
bridge = rng.choice(_edges_between(graph, part_a, part_b))
tree = [
*_spanning_tree_edges(graph, part_a, rng),
*_spanning_tree_edges(graph, part_b, rng),
bridge,
]

tree_edges = [(point(node1), point(node2)) for node1, node2 in tree]
frames.append((before, tree_edges, None))
frames.append((before, tree_edges, (point(bridge[0]), point(bridge[1]))))
frames.append((after, None, None))
return frames


def recom_assignments(
graph: Graph,
*,
Expand Down Expand Up @@ -172,6 +286,21 @@ def save_assignment_gif(
)


def save_demo_gif(graph: Graph, frames: Sequence[DemoFrame], filename: str) -> None:
"""Render the tree-and-cut demo frames as a looping GIF."""
images = [
assignment_image(graph, assignment, tree_edges=tree_edges, cut_edge=cut_edge)
for assignment, tree_edges, cut_edge in frames
]
images[0].save(
IMAGES / filename,
save_all=True,
append_images=images[1:],
duration=500,
loop=0,
)


def regenerate_gerrymandria() -> None:
graph = gerrymandria()
for filename, attribute in (
Expand All @@ -184,8 +313,15 @@ def regenerate_gerrymandria() -> None:
assignment_image(graph, assignment, show_labels=True).save(IMAGES / filename)
save_district_dual_graph(graph)

# The hero animation for the ReCom guide: unlike the plain ensemble gif below, this one shows
# the spanning tree and the cut edge that produce each new pair of districts.
save_demo_gif(
graph,
recom_demo_frames(graph, seed=42, total_steps=20),
"gerrychain_demo.gif",
)

simple_assignments = recom_assignments(graph, seed=2024, total_steps=40)
save_assignment_gif(graph, simple_assignments, "gerrychain_demo.gif")
save_assignment_gif(graph, simple_assignments, "gerrymandria_grid_ensemble.gif")

municipality_assignments = recom_assignments(
Expand Down
Loading
Loading