A generator asked for four thousand points with a minimum separation of twelve metres runs for an hour and produces three thousand eight hundred. Nothing errors, nothing warns, and the job is eventually killed.
Part of Point Process Simulation Models: hard-core processes are the family used whenever generated features have a physical footprint — sensors, premises, trees, parking bays — and they are the only family in that area whose sampler can fail to terminate.
Every other point process in this area answers “where do points go” and always has an answer. A hard-core process asks something different: can this many non-overlapping discs fit in this window at all, and beyond a density threshold the answer is no.
The threshold is not obvious, and it is much lower than intuition suggests. The densest possible packing of equal discs covers about ninety-one per cent of the plane, but that is the crystalline packing — a perfect hexagonal lattice. Random sequential adsorption, which is what dart-throwing produces, saturates at about fifty-five per cent coverage and cannot be pushed further no matter how long the sampler runs.
That gives a hard ceiling. For n points of inhibition radius r in an area A, the coverage fraction is n·π(r/2)²/A, and once that approaches 0.55 the acceptance rate collapses toward zero — smoothly, so the sampler slows rather than failing, which is why the symptom is a hung job rather than an exception.
Measured acceptance rate against packing fraction: the collapse is smooth, which is exactly why the sampler hangs instead of failing.
import math
RSA_SATURATION =0.547# random sequential adsorption limit for equal discs
PRACTICAL_CEILING =0.38# where acceptance is still workable in finite timedefpacking_fraction(n:int, radius_m:float, area_m2:float)->float:"""Fraction of the window covered by the exclusion discs."""return n * math.pi *(radius_m /2)**2/ area_m2
deffeasibility(n:int, radius_m:float, area_m2:float)->dict:
phi = packing_fraction(n, radius_m, area_m2)return{"packing_fraction": phi,"verdict":("infeasible"if phi >= RSA_SATURATION else"slow"if phi > PRACTICAL_CEILING else"fine"),"max_n_practical":int(PRACTICAL_CEILING * area_m2 /(math.pi *(radius_m /2)**2)),"max_radius_practical":2* math.sqrt(PRACTICAL_CEILING * area_m2 /(math.pi * n)),}
Running this before the sampler is the whole fix for the reported symptom. It costs microseconds, and it turns “the job hung” into “this request needs either four hundred fewer points or a radius of nine metres instead of twelve” — which is a decision somebody can make.
The two ceilings are worth distinguishing. Above RSA_SATURATION the request is impossible; between the practical ceiling and saturation it is possible and slow, and whether that is acceptable depends on how long the caller is willing to wait.
defdart_throwing(rng, n, radius, bounds, index_cell=None):"""Reject any candidate within `radius` of an accepted point.
The naive version is quadratic; a grid index makes it linear, and the grid cell
should equal the radius so exactly nine cells need checking.
"""
cell = index_cell or radius
grid:dict={}
pts =[]
attempts =0
max_attempts = n *200whilelen(pts)< n and attempts < max_attempts:
attempts +=1
x = rng.uniform(bounds[0], bounds[2])
y = rng.uniform(bounds[1], bounds[3])
gx, gy =int(x // cell),int(y // cell)
near =(p for dx in(-1,0,1)for dy in(-1,0,1)for p in grid.get((gx + dx, gy + dy),()))ifany((x - a)**2+(y - b)**2< radius * radius for a, b in near):continue
pts.append((x, y))
grid.setdefault((gx, gy),[]).append((x, y))iflen(pts)< n:raise RuntimeError(f"dart-throwing reached {len(pts)}/{n} in {attempts} attempts — "f"packing fraction {packing_fraction(n, radius, area_of(bounds)):.3f}")return pts
The attempt cap is not a safety net, it is the contract. A sampler with no cap is one that hangs, and the exception’s message carries the packing fraction so the caller knows immediately whether the request or the implementation is the problem.
Bridson’s algorithm generates candidates around existing points rather than uniformly, which keeps the acceptance rate high well past the point where dart-throwing collapses:
python
defpoisson_disc(rng, radius, bounds, k=30):"""Bridson: sample in an annulus around an active point, so candidates land where
there is room rather than uniformly over an increasingly full window."""
cell = radius / math.sqrt(2)
grid, active, pts ={},[],[]defemit(p):
pts.append(p)
active.append(p)
grid[(int(p[0]// cell),int(p[1]// cell))]= p
emit((rng.uniform(bounds[0], bounds[2]), rng.uniform(bounds[1], bounds[3])))while active:
i = rng._next()%len(active)
base = active[i]for _ inrange(k):
theta = rng.uniform(0,2* math.pi)
d = radius *(1+ rng.uniform())# annulus r … 2r
cand =(base[0]+ d * math.cos(theta), base[1]+ d * math.sin(theta))if in_bounds(cand, bounds)and far_enough(cand, grid, cell, radius):
emit(cand)breakelse:
active.pop(i)return pts
The trade is that Poisson-disc produces a maximal set — as many points as fit — rather than a requested count, so it answers “fill this window at this separation” rather than “place exactly n points”. Where the count is the requirement, generate maximally and thin to the target; where the separation is the requirement, take what it gives.
If neither can move, the remaining option is to place n points anywhere and then push them apart until the constraint holds:
python
defrelax(pts, radius, bounds, iterations=60, step=0.35):"""Lloyd-style repulsion: move each point away from any neighbour that is too close."""for _ inrange(iterations):
moved =0for i,(x, y)inenumerate(pts):
dx = dy =0.0for a, b in neighbours(pts, i, radius):
sep = math.hypot(x - a, y - b)or1e-9if sep < radius:
push =(radius - sep)/ sep
dx +=(x - a)* push
dy +=(y - b)* push
if dx or dy:
moved +=1
pts[i]= clamp((x + dx * step, y + dy * step), bounds)ifnot moved:breakreturn pts
Relaxation always terminates and does not always succeed — above saturation it converges to a configuration that still has violations, which the verification step below catches. It also destroys the process’s statistical properties: the result is more regular than a hard-core process should be, closer to a lattice, which matters if anything downstream measures spatial regularity.
Three samplers by packing fraction, with what each guarantees and what each gives up — the choice is determined by the feasibility check rather than by preference.
deftest_minimum_separation_holds(points, radius):"""This one is exact, not statistical — a single violation is a defect."""
tree = build_index(points)
worst =min(tree.nearest_distance(p)for p in points)assert worst >= radius,f"minimum separation {worst:.3f} < {radius}"deftest_count_matches_request(points, n):assertlen(points)== n,f"{len(points)} of {n} placed"deftest_feasibility_was_checked(run_log, request):assert"packing_fraction"in run_log,("the sampler ran without a feasibility check — a hang is now possible")deftest_regularity_is_not_excessive(points, radius, area):"""Relaxation over-regularises; catch it before a consumer does."""
ratio = mean_nearest_neighbour(points)/(0.5/ math.sqrt(len(points)/ area))assert ratio <1.9,f"NN ratio {ratio:.2f} — this looks like a lattice, not a process"
The last test is the one that catches a silent quality failure rather than a hang. Relaxation produces a valid configuration that is too regular, and nothing else in the suite notices — the separation holds, the count is right, and the pattern is wrong.
An irregular window. The area in the packing fraction must be the usable area, not the bounding box. A coastal district whose bounding box is half water will look feasible and behave as though it were at twice the packing fraction.
Measured over the same feature set: placing the largest first succeeds almost completely, and placing the smallest first fails well short.
Variable radii. Sensors of different footprints, premises of different sizes. The packing fraction generalises by summing individual disc areas, and the sampler should place the largest first — placing large discs into a window already crowded with small ones fails far earlier than the reverse.
Points near the boundary. Whether the separation applies across the window edge depends on whether the window is a real boundary, which is the same question as in edge-effect correction. Decide it explicitly: a hard-core process generated with a guard region and clipped will have a slightly denser boundary than one generated in place.
A caller who retries on failure. The most common operational consequence of an uncapped sampler is a retry loop around it, which converts one hang into an infinite one. The exception message carrying the packing fraction is what breaks that cycle, because it makes the failure obviously not transient.
Most non-termination reports trace back to a request that over-specified. A caller asks for a
count and a radius and a window, which is three constraints on two degrees of freedom, and
one of them has to give.
The useful move is to ask which of the three is actually the requirement, and the answer is
usually clear once somebody asks:
The count is the requirement when the release is calibrated to a known population — a
number of premises, a fleet size, a sensor count from an inventory. The radius is then a
plausibility constraint and can be relaxed, and the right sampler generates maximally and
thins.
The radius is the requirement when it represents a physical footprint — a vehicle, a
building, a parking bay. Two features cannot overlap and that is not negotiable, so the count
becomes whatever fits, and the caller needs to be told what that was.
The window is the requirement far less often than it appears, and it is the constraint most
worth questioning. A window drawn as a bounding box when the usable area is half water will make
a perfectly reasonable request look infeasible.
Recording which of the three was fixed alongside the release is worth doing for the same reason
as everything else in this area: a later reader looking at a layer with slightly fewer features
than the inventory says has no way to tell a defect from a deliberate consequence of a packing
constraint, unless somebody wrote it down.