Skip to content

Commit 3a88bc4

Browse files
committed
Implement auto-assign feature
1 parent 69703e9 commit 3a88bc4

File tree

2 files changed

+180
-7
lines changed

2 files changed

+180
-7
lines changed

redistricting/controllers/plan.py

+40-7
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
from qgis.core import (
3232
Qgis,
33+
QgsApplication,
3334
QgsProject,
3435
QgsVectorLayer
3536
)
@@ -70,6 +71,7 @@
7071
PlanStylerService
7172
)
7273
from ..services.actions import PlanAction
74+
from ..services.tasks.autoassign import AutoAssignUnassignedUnits
7375
from ..utils import tr
7476
from .base import BaseController
7577

@@ -121,7 +123,7 @@ def __init__(
121123

122124
self.planMenu: QMenu = None
123125
self.planActions: QActionGroup = None
124-
self.vectorSubMenu: QMenu = None
126+
self.vectorSubMenu: QAction = None
125127

126128
self.planManagerDlg: DlgSelectPlan = None
127129
self.planModel: PlanListModel = None
@@ -145,12 +147,9 @@ def load(self):
145147
self.planMenu = self.menu.addMenu(self.icon, tr('&Redistricting Plans'))
146148
self.planActions = QActionGroup(self.iface.mainWindow())
147149

148-
self.vectorSubMenu: QMenu = self.iface.vectorMenu().addMenu(self.menuName)
149-
self.vectorSubMenu.addMenu(self.menuButton.menu())
150+
self.vectorSubMenu: QAction = self.iface.vectorMenu().addMenu(self.menuButton.menu())
150151

151152
def unload(self):
152-
self.toolbar.removeAction(self.toolBtnAction)
153-
self.iface.vectorMenu().removeAction(self.vectorSubMenu.menuAction())
154153
if self.planManagerDlg is not None:
155154
self.planManagerDlg.close()
156155
self.planManagerDlg.deleteLater()
@@ -168,12 +167,12 @@ def unload(self):
168167
self.planModel = None
169168
self.toolbar.removeAction(self.toolBtnAction)
170169
self.toolBtnAction = None
170+
self.iface.vectorMenu().removeAction(self.vectorSubMenu)
171+
self.vectorSubMenu = None
171172
self.planActions.setParent(None)
172173
self.planActions = None
173174
self.planMenu.setParent(None)
174175
self.planMenu = None
175-
self.vectorSubMenu.setParent(None)
176-
self.vectorSubMenu = None
177176

178177
def createActions(self):
179178
self.actionShowPlanManager = self.actions.createAction(
@@ -280,6 +279,17 @@ def createActions(self):
280279
)
281280
self.actionDeletePlan.setEnabled(False)
282281

282+
self.actionAutoAssign = self.actions.createAction(
283+
"actionAutoAssign",
284+
QgsApplication.getThemeIcon('/algorithms/mAlgorithmVoronoi.svg'),
285+
tr('Auto-assign Units'),
286+
tooltip=tr('Attempt to automatically assign unassigned units to districts in the active plan'),
287+
callback=self.autoassign,
288+
parent=self.iface.mainWindow()
289+
)
290+
self.actionAutoAssign.setEnabled(False)
291+
self.menu.addAction(self.actionAutoAssign)
292+
283293
# slots
284294

285295
def planDistrictsUpdated(self, plan: RdsPlan, districts: Iterable[int]): # pylint: disable=unused-argument
@@ -298,6 +308,10 @@ def enableActivePlanActions(self, plan: Optional[RdsPlan]):
298308
plan is not None and plan.assignLayer is not None and plan.distLayer is not None
299309
)
300310
self.actionExportPlan.setTarget(plan)
311+
self.actionAutoAssign.setEnabled(
312+
plan is not None and plan.assignLayer is not None and plan.distField is not None
313+
and not plan.metrics.complete and len(plan.districts) > 1
314+
)
301315

302316
if plan is not None:
303317
action = self.planActions.findChild(QAction, plan.name)
@@ -668,3 +682,22 @@ def buildError(builder: PlanBuilder):
668682
def triggerUpdate(self, plan: RdsPlan):
669683
self.endProgress()
670684
self.updateService.updateDistricts(plan, needDemographics=True, needGeometry=True, needSplits=True)
685+
686+
def autoassign(self):
687+
def assignComplete():
688+
self.iface.messageBar().pushInfo(
689+
"Success!", f"Auto-assign completed succesfully: {len(task.update)} units were assigned to districts; {len(task.indeterminate)} units could not be assigned."
690+
)
691+
692+
def assignError():
693+
if task.isCanceled():
694+
return
695+
696+
self.iface.messageBar().pushWarning(
697+
"Error assigning units to districts", str(task.exception)
698+
)
699+
700+
task = AutoAssignUnassignedUnits(self.activePlan.assignLayer, self.activePlan.distField)
701+
task.taskCompleted.connect(assignComplete)
702+
task.taskTerminated.connect(assignError)
703+
QgsApplication.taskManager().addTask(task)
+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
2+
from typing import NamedTuple
3+
4+
from qgis.core import (
5+
QgsTask,
6+
QgsVectorLayer
7+
)
8+
9+
from ... import CanceledError
10+
from ...utils import (
11+
LayerReader,
12+
SqlAccess,
13+
tr
14+
)
15+
from ._debug import debug_thread
16+
17+
18+
class Row(NamedTuple):
19+
fid: int
20+
geoid: str
21+
district: int
22+
geometry: str = None
23+
24+
25+
class AutoAssignUnassignedUnits(SqlAccess, QgsTask):
26+
def __init__(self, assignments: QgsVectorLayer, distField: str):
27+
super().__init__(tr("Auto assign unassigned units"))
28+
self.assignments = assignments
29+
self.distField = distField
30+
self.update: dict[int, int] = None
31+
self.indeterminate: list[Row] = None
32+
self.retry: list[Row] = None
33+
self.exception: Exception = None
34+
35+
def run(self):
36+
debug_thread()
37+
38+
try:
39+
reader = LayerReader(self.assignments, self)
40+
missing = reader.read_layer(columns=["fid", "geoid", self.distField],
41+
filt={self.distField: 0}, read_geometry=True, fid_as_index=True) \
42+
.reset_index() \
43+
.rename(columns={self.distField: "district"})
44+
45+
if len(missing) == self.assignments.featureCount():
46+
# No units are assigned -- don't waste our time
47+
raise RuntimeError("Can't infer districts for unassigned units when no units are assigned")
48+
49+
# find polygons that are adjacent by more than a point
50+
sql = f"""SELECT fid, geoid, {self.distField} AS district FROM assignments
51+
WHERE ST_relate(geometry, GeomFromText(:geometry), 'F***1****')
52+
AND fid IN (
53+
SELECT id FROM rtree_assignments_geometry r
54+
WHERE r.minx < st_maxx(GeomFromText(:geometry))
55+
AND r.maxx >= st_minx(GeomFromText(:geometry))
56+
AND r.miny < st_maxy(GeomFromText(:geometry))
57+
AND r.maxy >= st_miny(GeomFromText(:geometry))
58+
)"""
59+
60+
with self._connectSqlOgrSqlite(self.assignments.dataProvider()) as db:
61+
db.row_factory = lambda c, r: Row(*r)
62+
update: dict[int, int] = {}
63+
retry: list[Row] = []
64+
indeterminate: list[Row] = []
65+
count = 0
66+
total = len(missing)
67+
for g in missing.to_wkt().itertuples(index=False, name="Row"):
68+
neighbors: list[Row] = db.execute(sql, (g.geometry,)).fetchall()
69+
dists = set(r.district for r in neighbors)
70+
71+
# is the unassigned unit surrounded by units from the same district or
72+
# unassigned units (but not entirely by unassigned units)
73+
if len(dists) == 1 and 0 in dists:
74+
retry.append(g)
75+
elif len(dists) == 1 or (len(dists) == 2 and 0 in dists):
76+
newdist = max(dists)
77+
if g.fid in update:
78+
print("oops")
79+
update[g.fid] = newdist
80+
else:
81+
# multiple adjacent districts
82+
indeterminate.append(g)
83+
84+
if self.isCanceled():
85+
raise CanceledError()
86+
87+
count += 1
88+
self.setProgress(count/total)
89+
90+
# TODO: there's probably a better way to to find the surrounding assigned units than continually looping
91+
while retry:
92+
retry_count = len(retry)
93+
newretry: list[Row] = []
94+
for g in retry:
95+
neighbors: list[Row] = db.execute(sql, (g.geometry,)).fetchall()
96+
dists = set(update.get(r.fid, r.district) for r in neighbors)
97+
98+
# is the unassigned unit surrounded by units from the same district or
99+
# unassigned units (but not entirely by unassigned units)
100+
if len(dists) == 1 and 0 in dists:
101+
newretry.append(g)
102+
elif len(dists) == 1 or (len(dists) == 2 and 0 in dists):
103+
newdist = max(dists)
104+
if g.fid in update:
105+
print("oops")
106+
update[g.fid] = newdist
107+
else:
108+
# multiple adjacent districts
109+
indeterminate.append(g)
110+
111+
if self.isCanceled():
112+
raise CanceledError()
113+
114+
retry = newretry
115+
if len(retry) == retry_count:
116+
# if we were unable to assign any of the as-yet unassigned units, give up
117+
break
118+
119+
self.update = update
120+
self.indeterminate = indeterminate
121+
self.retry = retry
122+
except CanceledError:
123+
return False
124+
except Exception as e: # pylint: disable=broad-except
125+
self.exception = e
126+
return False
127+
128+
return True
129+
130+
def finished(self, result):
131+
if not result:
132+
return
133+
134+
if self.update:
135+
i = self.assignments.fields().indexFromName(self.distField)
136+
self.assignments.startEditing()
137+
self.assignments.beginEditCommand("Auto-assign")
138+
for fid, dist in self.update.items():
139+
self.assignments.changeAttributeValue(fid, i, dist, 0)
140+
self.assignments.endEditCommand()

0 commit comments

Comments
 (0)