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.
Shared 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 as 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, and nothing that came back empty has been removed.
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_sales5 tool calls33s analysis5 datasets
Eight constraints in one sentence, then three outputs, 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. Sales sit behind a separate licence. Assembling that by hand for a single lot takes a morning, and the drive-time catchment cannot be done by hand. This ran against every industrial parcel in New South Wales.
It started by reading rather than querying: an attached spreadsheet, then its site-selection expertise, which holds the zoning vocabulary, the drive-time caveats and the lot-and-plan sales join. That step turns a brief into the right columns and the right warnings. 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 did the analysis. 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 population catchment, and finished with a single SQL statement that scored, ranked, matched sales and wrote the result. The last two calls rendered the table and the map below.
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 | Lot area (m²) | Modelled truck minutes to Sydney Airport | Modelled truck minutes to motorway ramp | Residents within 30-minute car drive | Flood-planning status | Last sale date | Last sale price (A$) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 100 | DP836293 | Auburn (NSW) | Cumberland | E3 | 6,452 | 28.2 | 0.5 | 2,834,287 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 1 | DP1106248 | Padstow | Canterbury-Bankstown | E4 | 3,484 | 16.3 | 0.3 | 2,741,691 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 24 | DP225456 | Homebush West | Strathfield | E3 | 4,805 | 21.5 | 0 | 2,734,450 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 52 | DP1307605 | Moorebank | Liverpool | E4 | 3,221 | 23 | 0 | 2,722,333 | assessed: outside | — | — | |
| 51 | DP1064349 | Padstow | Canterbury-Bankstown | E4 | 4,910 | 16.3 | 0.3 | 2,686,461 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 1 | DP1308192 | Moorebank | Liverpool | E4 | 8,917 | 22.8 | 0 | 2,641,287 | assessed: outside | — | — | |
| 1 | DP255489 | Silverwater (Parramatta - NSW) | Parramatta | E4 | 4,120 | 28.2 | 1.8 | 2,629,999 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 2 | DP255489 | Silverwater (Parramatta - NSW) | Parramatta | E4 | 4,069 | 28.2 | 1.8 | 2,629,999 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 3 | DP371357 | Padstow | Canterbury-Bankstown | E4 | 8,798 | 16.1 | 0 | 2,619,715 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 41 | DP1098183 | Padstow | Canterbury-Bankstown | E4 | 3,924 | 16.3 | 0.3 | 2,619,715 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 2 | DP873418 | Silverwater (Parramatta - NSW) | Parramatta | E4 | 3,047 | 28.2 | 1.8 | 2,578,898 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 100 | DP1233539 | Padstow | Canterbury-Bankstown | E4 | 4,420 | 17.8 | 1.8 | 2,571,109 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 1 | DP1256170 | Padstow | Canterbury-Bankstown | E4 | 4,098 | 17.8 | 1.8 | 2,567,788 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 2 | DP508708 | Padstow | Canterbury-Bankstown | E4 | 4,113 | 17.8 | 1.8 | 2,567,788 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 1 | DP1231557 | Silverwater (Parramatta - NSW) | Parramatta | E4 | 8,308 | 28.2 | 1.8 | 2,564,056 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 45 | DP258567 | Silverwater (Parramatta - NSW) | Parramatta | E4 | 3,460 | 28.2 | 1.8 | 2,564,056 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 4 | DP236583 | Silverwater (Parramatta - NSW) | Parramatta | E4 | 3,563 | 28.7 | 2.3 | 2,549,721 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 678 | DP737044 | Silverwater (Parramatta - NSW) | Parramatta | E4 | 4,821 | 28.7 | 2.3 | 2,549,721 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | 2024-12-23 | 13,700,000 | |
| 100 | DP1319665 | Silverwater (Parramatta - NSW) | Parramatta | E4 | 7,724 | 29.1 | 2.7 | 2,539,653 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — | |
| 12 | DP242730 | Padstow | Canterbury-Bankstown | E4 | 6,897 | 16.1 | 0 | 2,538,900 | unassessed: EPI layer does not assess this LGA (exists_unlicensed) | — | — |
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, from 2.54 to 2.83 million residents. Below the top twenty, the ranking separates 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.
In the third paragraph, without being asked, the agent named the two zones it searched and pre-empted the label people usually use, said the constraint flags come from the cell rather than the lot, called its catchment a floor, and stated that its own travel times say nothing about legal truck access.
One cell, written to the sandbox and run. It is long, and it is here in full because a shortlist that cannot be audited has to be taken 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.pyThere is no drive-time table that answers "residents within thirty minutes of this lot". 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.
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. They are not placed in 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 means no matched sale, 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 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. To run it against yours, request access. Related pages: development site screening and 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