Development site screening
Screen dozens or hundreds of sites at once against zoning, flood planning areas, TOD precincts, sales history and constraint layers, instead of running one-lot lookups by hand.
Live run
A published agent run. One prompt narrows NSW industrial land to 145 eligible lots, then to a ranked top 20 by drive-time catchment — with the map, the code and the gaps left in.
This is one run, on 2 September 2026, published the way it came out of the app. The prompt is the one on the homepage. The table, the map, the code and the wording of the answer are the agent's own; nothing has been re-run to look better, and nothing that came back empty has been quietly dropped.
Shortlist sites for a last-mile depot: E4 or E3 lots of 3,000–10,000 m² within 30 minutes' truck drive of Sydney Airport, less than 5 minutes from a motorway ramp, with at least 2 million residents inside a 30-minute drive, no residential zoning within 200 m, outside flood planning areas and not on the EPA contaminated-land record. Rank by catchment population, show the last sale on each lot, and map the top 20.
drive_timehazard_flood_extentsparcel_profilepopulationproperty_salesEight constraints in one sentence, then three things to produce, drawing on the five datasets listed above. Zoning is a state planning layer, lot area is cadastre, truck minutes have to be routed, the catchment needs census counts pushed through a road graph, flood planning is per-council, the contaminated-land record is an EPA register, and sales sit behind a separate licence. Assembling that by hand for a single lot is a morning of browser tabs, and the drive-time catchment is not a thing you can do by hand at all. This ran against every industrial parcel in New South Wales.
Five tool calls, thirty-three seconds of analysis, one python cell.
It started by reading rather than querying: the dataset documentation, then its site-selection expertise, which is where the zoning vocabulary, the drive-time caveats and the lot-and-plan sales join live. That is the step that turns a brief into the right columns and the right warnings, and it is why the answer names the zones it used rather than assuming the reader and the agent mean the same thing by "industrial".
Then one shell call, and everything happened inside it. The cell pulled the candidate pool out of the parcel table, loaded the road graph, ran a shortest-path sweep from every candidate to build its own population catchment, and finished with a single SQL statement that scored, ranked, matched sales and wrote the result. Thirty-three seconds. The last two calls rendered the table and the map below.
The map block from the run itself, live: hover a lot for its rank, lot and plan, zone, area, catchment and flood status, and click one to pin that readout, ring the point and highlight its row in the table below. Picking a rank in the table does the same in reverse. Shading is by residents within a 30-minute modelled car drive, cut into five equal-count classes. The grey lines are the NSW classified network the agent routed on, read straight from the same published road tiles; the ringed dot is Sydney Airport, the freight anchor the truck minutes are measured to.
The geography is the finding. Twenty lots, five suburbs, five councils — eight at Padstow, eight at Silverwater, two at Moorebank, one each at Auburn and Homebush West. Ranking by catchment population pulls the shortlist west, into the middle of the basin, because that is where the residents inside a half-hour drive are. None of the twenty is next to the airport. All twenty are inside thirty truck minutes of it.
| Rank | Lot / plan | Suburb | LGA | Zone | Area m² | Truck min, airport | Truck min, ramp | 30-min catchment | Flood planning | Last sale |
|---|---|---|---|---|---|---|---|---|---|---|
| 100 / DP836293 | Auburn | Cumberland | E3 | 6,452 | 28.2 | 0.5 | 2,834,287 | Unassessed | No matched sale | |
| 1 / DP1106248 | Padstow | Canterbury-Bankstown | E4 | 3,484 | 16.3 | 0.3 | 2,741,691 | Unassessed | No matched sale | |
| 24 / DP225456 | Homebush West | Strathfield | E3 | 4,805 | 21.5 | 0.0 | 2,734,450 | Unassessed | No matched sale | |
| 52 / DP1307605 | Moorebank | Liverpool | E4 | 3,221 | 23.0 | 0.0 | 2,722,333 | Outside | No matched sale | |
| 51 / DP1064349 | Padstow | Canterbury-Bankstown | E4 | 4,910 | 16.3 | 0.3 | 2,686,461 | Unassessed | No matched sale | |
| 1 / DP1308192 | Moorebank | Liverpool | E4 | 8,917 | 22.8 | 0.0 | 2,641,287 | Outside | No matched sale | |
| 1 / DP255489 | Silverwater | Parramatta | E4 | 4,120 | 28.2 | 1.8 | 2,629,999 | Unassessed | No matched sale | |
| 2 / DP255489 | Silverwater | Parramatta | E4 | 4,069 | 28.2 | 1.8 | 2,629,999 | Unassessed | No matched sale | |
| 3 / DP371357 | Padstow | Canterbury-Bankstown | E4 | 8,798 | 16.1 | 0.0 | 2,619,715 | Unassessed | No matched sale | |
| 41 / DP1098183 | Padstow | Canterbury-Bankstown | E4 | 3,924 | 16.3 | 0.3 | 2,619,715 | Unassessed | No matched sale | |
| 2 / DP873418 | Silverwater | Parramatta | E4 | 3,047 | 28.2 | 1.8 | 2,578,898 | Unassessed | No matched sale | |
| 100 / DP1233539 | Padstow | Canterbury-Bankstown | E4 | 4,420 | 17.8 | 1.8 | 2,571,109 | Unassessed | No matched sale | |
| 1 / DP1256170 | Padstow | Canterbury-Bankstown | E4 | 4,098 | 17.8 | 1.8 | 2,567,788 | Unassessed | No matched sale | |
| 2 / DP508708 | Padstow | Canterbury-Bankstown | E4 | 4,113 | 17.8 | 1.8 | 2,567,788 | Unassessed | No matched sale | |
| 1 / DP1231557 | Silverwater | Parramatta | E4 | 8,308 | 28.2 | 1.8 | 2,564,056 | Unassessed | No matched sale | |
| 45 / DP258567 | Silverwater | Parramatta | E4 | 3,460 | 28.2 | 1.8 | 2,564,056 | Unassessed | No matched sale | |
| 4 / DP236583 | Silverwater | Parramatta | E4 | 3,563 | 28.7 | 2.3 | 2,549,721 | Unassessed | No matched sale | |
| 678 / DP737044 | Silverwater | Parramatta | E4 | 4,821 | 28.7 | 2.3 | 2,549,721 | Unassessed | 23 Dec 2024 · $13,700,000 | |
| 100 / DP1319665 | Silverwater | Parramatta | E4 | 7,724 | 29.1 | 2.7 | 2,539,653 | Unassessed | No matched sale | |
| 12 / DP242730 | Padstow | Canterbury-Bankstown | E4 | 6,897 | 16.1 | 0.0 | 2,538,900 | Unassessed | No matched sale |
Eighteen of the twenty are E4 General Industrial and two are E3 Productivity Support. They run from 3,047 to 8,917 square metres, sit 16.1 to 29.1 modelled truck minutes from the airport, and are 0.0 to 2.7 minutes from a motorway ramp. The catchments are close together — 2.54 to 2.83 million residents — which is the real shape of the answer. Below the top twenty, the ranking is separating sites that are, for this purpose, much the same.
The search produced 145 eligible NSW E3/E4 lots after applying the area, modelled truck-access, motorway-ramp, residential-ring and EPA record filters. 115 lots met the at-least-2-million-resident catchment threshold; the table ranks the top 20 by residents within a 30-minute modelled car drive, while truck minutes were used for Sydney Airport and motorway access.
The two flood-planning outcomes among the top 20 are 2 assessed outside the EPI flood-planning area and 18 unassessed because their LGAs are not covered by that EPI layer. Those 18 are in LGAs whose own flood mapping exists but is not openly licensed, so they are not confirmed flood-free. Last-sale fields are included where the legal lot/plan could be matched; the sales data is non-commercial licensed.
The search uses E4 General Industrial and E3 Productivity Support, not a “medium industrial” zone. Zones and constraints are inherited from the parcel-centroid cell, the 30-minute catchment is a node-sampled population floor based on ABS 2021 Census residents, and modelled travel times do not certify legal truck access.
Read the third paragraph again. Unprompted, the agent named the two zones it actually searched and pre-empted the label people usually reach for, said the constraint flags come from the cell rather than the lot, called its catchment a floor, and stated that its own travel times certify nothing about legal truck access. Nobody asked it for any of that.
One cell, written to the sandbox and run. It is long, and it is here in full because a shortlist you cannot audit is a shortlist you have to take on trust.
cat > /workspace/scratch/run_shortlist.py <<'EOF'
import numpy as np, pyarrow as pa, zn
from scipy.sparse import coo_matrix
from scipy.sparse.csgraph import dijkstra
con=zn.lake_con()
PP=zn.read('parcel_profile')
# Candidate pool: user criteria before catchment threshold
where="""zone_code IN ('E3','E4') AND area_m2 BETWEEN 3000 AND 10000 AND lot <> ''
AND minutes_truck_to_syd_airport <= 30 AND minutes_truck_to_motorway < 5
AND residential_within_200m IS FALSE AND flood_planning IS NOT TRUE
AND clm_record_site_in_cell = 0"""
cand=con.execute(f"SELECT parcel_id, lot, plan_label, suburb, lga_name, postcode, zone_code, zone_name, area_m2, lat, lng, h3_r9, minutes_truck_to_syd_airport, minutes_truck_to_motorway, minutes_truck_to_kfr, flood_planning, freight_minutes_from_neighbour_cell FROM {PP} WHERE {where} ORDER BY parcel_id").fetchall()
cols=['parcel_id','lot','plan_label','suburb','lga_name','postcode','zone_code','zone_name','area_m2','lat','lng','h3_r9','minutes_truck_to_syd_airport','minutes_truck_to_motorway','minutes_truck_to_kfr','flood_planning','freight_minutes_from_neighbour_cell']
print('candidate_pool',len(cand))
# ordered graph nodes, car profile for population catchment
n=con.execute(f"SELECT node_id, lat, lng, h3_r9 FROM {zn.read('drive_time',file='graph_nodes')} ORDER BY node_id").fetch_arrow_table()
e=con.execute(f"SELECT u,v,minutes_car AS w FROM {zn.read('drive_time',file='graph_edges')}").fetch_arrow_table()
assert int(n['node_id'][-1].as_py())==len(n)-1
u=e['u'].to_numpy(zero_copy_only=False); v=e['v'].to_numpy(zero_copy_only=False); w=e['w'].to_numpy(zero_copy_only=False)
graph=coo_matrix((np.r_[w,w],(np.r_[u,v],np.r_[v,u])),shape=(len(n),len(n))).tocsr()
lat=n['lat'].to_numpy(zero_copy_only=False); lng=n['lng'].to_numpy(zero_copy_only=False); cells=np.array(n['h3_r9'].to_pylist())
starts=[]; snaps=[]
for r in cand:
la=float(r[9]); lo=float(r[10]); c=np.cos(np.radians(la)); i=int(np.argmin((lat-la)**2+((lng-lo)*c)**2)); starts.append(i); snaps.append(111320*float(np.hypot(lat[i]-la,(lng[i]-lo)*c)))
rows=[]
for j in range(0,len(starts),40):
dist=dijkstra(graph,directed=True,indices=starts[j:j+40],limit=30)
for r,di,snap in zip(cand[j:j+40],dist,snaps[j:j+40]):
reach=np.unique(cells[np.isfinite(di)])
rows.append((r[0],len(reach),round(snap),reach))
# register unique entity cell list and metadata
con.register('catchment',pa.table({'parcel_id':pa.array([pid for pid,_,_,cs in rows for _ in cs]),'h3_r9':pa.array([c for _,_,_,cs in rows for c in cs])}))
con.register('cand_meta',pa.table({
'parcel_id':pa.array([r[0] for r in cand]),
'catchment_cells':pa.array([x[1] for x in rows],type=pa.int64()),
'snap_m':pa.array([x[2] for x in rows],type=pa.int64())}))
POP=zn.read('population',file='population_h3r9')
# full scored pool and thresholded top20, then join profile, roster and sales
# sales parser follows legal lot/plan normalization, latest date then highest price same date
S=zn.read('property_sales')
R=zn.read('hazard_flood_extents',file='flood_extents_roster')
sql=f"""
WITH scored AS (
SELECT m.parcel_id,m.catchment_cells,m.snap_m,round(sum(p.persons)) AS catchment_persons
FROM cand_meta m LEFT JOIN catchment c USING(parcel_id) LEFT JOIN {POP} p USING(h3_r9)
GROUP BY 1,2,3
), ranked AS (
SELECT *, row_number() OVER (ORDER BY catchment_persons DESC, parcel_id) AS rank
FROM scored WHERE catchment_persons >= 2000000
), tokens AS (
SELECT sale_id,sale_date,price,unnest(str_split(regexp_replace(upper(trim(dp_lot)), '\\s*,\\s*', ',', 'g'),' ')) AS token
FROM {S} WHERE dp_lot IS NOT NULL AND trim(dp_lot)<>'' AND sale_date IS NOT NULL
), split AS (SELECT sale_id,sale_date,price,str_split(token,'/') AS piece FROM tokens WHERE token<>''), shaped AS (
SELECT sale_id,sale_date,price,CASE WHEN len(piece) IN (2,3) THEN piece[1] END AS lots,CASE WHEN len(piece)=3 THEN piece[2] ELSE '' END AS section,CASE WHEN len(piece) IN (2,3) THEN piece[-1] END AS plan FROM split), expanded AS (
SELECT sale_id,sale_date,price,unnest(str_split(lots,',')) AS lot,section,plan FROM shaped WHERE lots IS NOT NULL
), matched AS (
SELECT DISTINCT sale_id,sale_date,price,lot||'/'||section||'/'||CASE WHEN regexp_matches(plan,'^[0-9]+$') THEN 'DP'||plan ELSE plan END AS parcel_id
FROM expanded WHERE regexp_matches(lot,'^[0-9]+[A-Z]?$|^[A-Z]$') AND regexp_matches(section,'^[0-9A-Z]*$') AND regexp_matches(plan,'^(DP|SP)?[0-9]+$')
), agg AS (
SELECT parcel_id,max(sale_date) AS last_sale_date,arg_max(price,(sale_date,price)) AS last_sale_price,count(*) AS sales_n FROM matched GROUP BY 1
), last_sale AS (
SELECT a.parcel_id,a.last_sale_date,a.last_sale_price,a.sales_n,count(*) AS same_day_sales FROM agg a JOIN matched m ON m.parcel_id=a.parcel_id AND m.sale_date=a.last_sale_date GROUP BY 1,2,3,4
), out AS (
SELECT r.rank,r.parcel_id,p.lot,p.plan_label,p.suburb,p.lga_name,p.postcode,p.zone_code,p.zone_name,round(p.area_m2) AS area_m2,p.lat,p.lng,
round(p.minutes_truck_to_syd_airport,1) AS truck_min_sydney_airport,round(p.minutes_truck_to_motorway,1) AS truck_min_motorway_ramp,round(p.minutes_truck_to_kfr,1) AS truck_min_key_freight_route,
r.catchment_persons,r.catchment_cells,r.snap_m, p.flood_planning,
CASE WHEN p.flood_planning IS FALSE THEN 'assessed: outside' ELSE coalesce('unassessed: EPI layer does not assess this LGA ('||fr.coverage_status||')','unassessed: EPI layer does not assess this LGA') END AS flood_status,
ls.last_sale_date,ls.last_sale_price,ls.sales_n,ls.same_day_sales,p.freight_minutes_from_neighbour_cell
FROM ranked r JOIN {PP} p USING(parcel_id) LEFT JOIN {R} fr ON fr.lga_name=p.lga_name LEFT JOIN last_sale ls USING(parcel_id)
WHERE r.rank<=20 ORDER BY r.rank
)
SELECT * FROM out
"""
zn.write_out(con,'depot_shortlist_top20',sql)
# summaries
print(con.execute(f"SELECT count(*) AS pool, count(*) FILTER (catchment_persons>=2000000) AS clears FROM (SELECT m.parcel_id,sum(p.persons) AS catchment_persons FROM cand_meta m LEFT JOIN catchment c USING(parcel_id) LEFT JOIN {POP} p USING(h3_r9) GROUP BY 1)").fetchall())
print(con.execute("SELECT flood_status,count(*) FROM read_csv_auto('/workspace/out/depot_shortlist_top20.csv') GROUP BY 1").fetchall())
EOF
python3 /workspace/scratch/run_shortlist.py
The interesting part is the middle. There is no drive-time table that answers "residents within thirty minutes of this lot" — that catchment does not exist until someone asks for it. So the agent loaded the road graph into memory, snapped each candidate to its nearest node, and ran a bounded shortest-path sweep in batches of forty, collecting the cells each lot can reach. Those cells then join to mesh-block population. The ranking is computed, not looked up.
The minutes are modelled, not legal. Travel times come from posted speeds and road class on an undirected graph, with no traffic and no time of day. They are not a statement that a heavy vehicle may use those roads. Australia's heavy-vehicle network map is the regulator's and is not openly licensed, so it is not in the lake and the run does not pretend otherwise.
The constraints are inherited from a cell, not read off the lot. Zoning, flood planning and the contaminated-land check are joined through the parcel centroid's H3 cell, roughly 350 metres across. For a 4,000 square metre lot that is usually right and occasionally not. It is a screen, not a planning certificate.
Eighteen of the twenty are unassessed for flood, not clear of it. Two lots sit in councils covered by the state flood-planning layer and fall outside it. The other eighteen are in councils whose flood mapping exists but is not published under a licence that allows republication, so the run reports them as unassessed. That is the honest answer and it is deliberately not folded into a low-risk bucket.
One lot in twenty has a matched sale. Sales are matched by normalising the legal lot and plan, and most of these lots have not traded under a matchable identifier. A blank is a blank, not a site that has never sold.
The catchment is a floor. It sums residents in the cells the road network can reach within thirty minutes by car, using 2021 Census counts, so it undercounts wherever the graph is sparse. Candidates were snapped between 39 and 188 metres to the nearest road node.
The pool was 145 lots and the map shows 20. One hundred and fifteen cleared the two-million threshold. The other ninety-five were scored and ranked but not returned, because the prompt asked for a map of the top twenty.
The criteria here are one afternoon's version of a depot brief. Swap the zone list, the lot size, the anchor, the catchment threshold or the exclusion set and it is the same query. If you want to see it against yours, request access — or read how the same joins are used for development site screening and across property and development.
Screen dozens or hundreds of sites at once against zoning, flood planning areas, TOD precincts, sales history and constraint layers, instead of running one-lot lookups by hand.
For acquisition and feasibility teams screening many sites at once — zoning, flood planning areas, TOD precincts, constraints and sales history joined to the parcel, at LGA or portfolio scale.
Zenancy is in private preview with Group 2 reporters and their advisers.
Request access