Working record · Auckland · September 2026
The output behind the study — what each tool actually produced, what it established, and which parts could not be run.
Data boundary. I work in a Foodstuffs banner store. No store data of any kind — sales, SAP extracts, loyalty records, reports — was used, viewed for this purpose or exported, in raw, aggregated or anonymised form. Everything below is built from Stats NZ Census 2023, the Household Economic Survey, OpenStreetMap and Foodstuffs North Island's published accounts. The boundary is why one section on this page has no results in it.
01 · Segmentation
Three scripts take the raw census export to a named shortlist: build the feature table, cluster it, then overlay the store network. 606 catchments, 516,585 households, seven features, no missing values.
Silhouette favours three clusters. I chose five.
| k | Silhouette | Outcome |
|---|---|---|
| 3 | 0.353 | Metric optimum — inner-city absorbed into a broader urban group |
| 4 | 0.351 | Marginally lower, same absorption |
| 5 | 0.326 | Chosen — inner-city apartments separate |
| 6 | 0.318 | Splits suburban without adding interpretation |
At k = 3 the inner-city apartment catchments vanish into a broader urban group. That group is only 15 areas and 15,156 households, but it is the most behaviourally distinct segment in Auckland — 45% living alone, 52% without a car, five times the household density of anywhere else. Collapsing it produces a tidier model that cannot see the finding.
An analyst who reports only the silhouette-optimal k has let the metric make a business decision.
| Archetype | Areas | Households | One-person | No vehicle | Median income | Crowded |
|---|---|---|---|---|---|---|
| Inner-city apartments | 15 | 15,156 | 44.9% | 51.9% | $67,900 | 8.8% |
| Town-centre mixed | 114 | 104,505 | 28.5% | 9.5% | $97,400 | 7.4% |
| South Auckland multi-family | 129 | 95,169 | 11.8% | 7.3% | $107,700 | 21.2% |
| Suburban mainstream | 282 | 267,453 | 15.6% | 3.7% | $132,600 | 5.9% |
| Rural fringe | 66 | 34,302 | 15.5% | 1.6% | $127,700 | 5.2% |
The most likely criticism, so it was tested. Principal component analysis says no: household structure is the dominant axis, and density — the obvious confound — does not appear until the third component.
PC1 45.5% avg_residents, pct_one_person, pct_multi_family PC2 29.5% pct_crowded, pct_no_vehicle, median_income (inverse) PC3 14.1% log_density, median_income
Store locations reprojected to NZTM2000, nearest-store distance computed per banner per catchment.
| Archetype | Four Square | New World | Pak'nSave | Woolworths |
|---|---|---|---|---|
| Inner-city apartments | 1.05 | 0.90 | 6.33 | 0.57 |
| Town-centre mixed | 2.75 | 1.76 | 2.83 | 1.04 |
| South Auckland multi-family | 3.24 | 2.43 | 2.22 | 1.63 |
| Suburban mainstream | 2.99 | 2.10 | 2.91 | 1.49 |
| Rural fringe | 7.18 | 7.36 | 7.27 | 6.47 |
Suppression coded as −999. Caught because the household total increased after dropping small areas, which is arithmetically impossible. The ≥100-household filter happened to remove every suppressed row. The analysis was never contaminated — but that was luck, not design, and a filter on any other column would have corrupted every derived percentage.
Projected, not geographic, coordinates. The export ships NZTM2000 eastings and northings in metres. An initial pass assuming degrees returned zero rows.
Unstable cluster labels. K-means label numbers are arbitrary and move when the input changes. Names hardcoded to label numbers silently attached to the wrong clusters — Eden Terrace came out as "South Auckland multi-family". Names are now derived from each cluster's own profile and verified against four known areas.
Bounding-box region filter. An initial coordinate box leaked into Waikato, putting Thames and Coromandel in the results at 56 km from a store. Recalibrated against known centroids: Waiheke East marks the eastern edge at x = 1,791,870.
02 · Validation
The segmentation was built in Python, so it was validated in something else. Four tests, the console output as it ran.
=====================================================================
STATISTICAL VALIDATION - Auckland catchment segmentation
=====================================================================
Catchments: 606 Features: 7
1. GAP STATISTIC
k log(Wk) E[log(Wk)] Gap s(k)
3 7.744 9.366 1.623 0.013
4 7.544 9.282 1.738 0.013
5 7.333 9.227 1.893 0.015
10 6.745 9.009 2.264 0.013
Gap statistic (1-SE rule) selects k = NA
Gap continues rising to k = 10 (max gap)
2. BOOTSTRAP CLUSTER STABILITY (100 resamples, Jaccard)
cluster n mean Jaccard verdict
1 128 0.850 stable
2 66 0.936 stable
3 113 0.784 stable
4 284 0.870 stable
5 15 0.872 stable
overall mean Jaccard: 0.862
3. MANOVA (Pillai's trace)
Df Pillai approx F num Df den Df Pr(>F)
df$seg 4 2.7116 179.8 28 2392 < 2.2e-16 ***
4. TUKEY HSD - no-vehicle share, pairwise
All 10 pairwise comparisons significant at p < 0.001
Highest no-vehicle segment: cluster 5 at 51.9%
Next highest: 9.5% - a gap of 42.4 percentage points
The clusters are defined by these features, so the significance tests are close to tautological. They are reported for effect size, not for the p-values, and anyone reading them as vindication has misread them.
The two informative results are the ones not guaranteed by construction: the gap statistic, which is an independent criterion for k — and which never satisfies the 1-SE rule, rising all the way to k = 10, so it does not support my choice of five — and the bootstrap Jaccard scores, which test whether the segments survive resampling. At a mean of 0.862, with every segment above Hennig's 0.75 threshold, they do.
Reporting the gap statistic result is the honest half of this section. It disagrees with me.
03 · The test that has no data
Everything above rests on one assumption: that household composition predicts shopper mission. It is grounded in retail logic, not in observed behaviour — because observing behaviour requires transaction data, and this analysis has none.
So the queries exist and the findings do not. These are the five I would run in week one with access to FSNI data. The first is the one that decides whether the project continues.
What could be done from outside is prove the SQL is real. The documented schema was built as empty tables in DuckDB and the file executed against it. Every query parses, every join resolves, every window function is legal, and each returns a result set of the right shape with zero rows in it.
/* If household composition predicts mission, inner-city apartment
catchments should show HIGHER trip frequency and LOWER basket
value than suburban mainstream. If they do not, the model's core
assumption is wrong and the project should stop. */
WITH customer_activity AS (
SELECT
c.customer_sk,
c.home_sa2_code,
COUNT(DISTINCT t.basket_id) AS trips,
COUNT(DISTINCT d.iso_week) AS active_weeks,
SUM(t.txn_value_incl_gst) AS total_spend,
AVG(t.txn_value_incl_gst) AS avg_basket,
AVG(t.item_count) AS avg_items
FROM fact_transaction t
...
=====================================================================
SQL EXECUTION AGAINST THE ASSUMED SCHEMA
=====================================================================
Engine : DuckDB 1.5.5
Tables : 7 created, ALL EMPTY — by design
DIALECT SHIM APPLIED
DATEADD(year, -n, CURRENT_DATE) -> (CURRENT_DATE - INTERVAL n YEAR) (5)
DATEPART(quarter, col) -> EXTRACT(quarter FROM col) (2)
STDDEV() -> stddev_samp() (1)
QUERY 1 — THE CENTRAL TEST
status : OK — parsed, planned, executed
returns : 9 columns, 0 rows
columns : segment, customers, trips_per_week, avg_basket, avg_items,
annual_spend, basket_stddev, trips_index, basket_index
QUERY 2 — WHERE DO THE EIGHT CATCHMENTS ACTUALLY SHOP?
status : OK returns : 8 columns, 0 rows
QUERY 3 — SIZE THE LEAK
status : OK returns : 8 columns, 0 rows
QUERY 4 — RANGE FIT IN EXISTING STORES
status : OK returns : 8 columns, 0 rows
QUERY 5 — MONITORING
status : OK returns : 7 columns, 0 rows
=====================================================================
RESULT: 5 of 5 queries executed, 0 failed
=====================================================================
Query 4 did not bind. It computed a banner benchmark as AVG(SUM(band_sales)) OVER (PARTITION BY banner) — an aggregate nested inside a window function, in a query with no GROUP BY to support it. Invalid SQL that reads perfectly well on the page. Rewritten to take the average of per-store shares, which also fixes the statistic: the original was a ratio of averages, so a single high-turnover store could set the benchmark for its whole banner. Now every store counts once.
Query 4 referenced a column the schema block did not declare. ref_catchment.avg_residents_proxy was used but never documented. Building the tables straight from the documented block is what surfaced it. The block now declares it, and the correction is dated in the file rather than quietly patched.
Both were found the first time the file was executed, and neither would have been found by reading it.
I do not know FSNI's warehouse schema. The table and column names are a documented assumption, defined in a block at the top of the file so the logic can be reviewed independently of the naming. Adapting these to the real schema is a rename, not a rewrite.
The file is written for Snowflake, BigQuery or SQL Server. Running it on DuckDB needed three scalar-function substitutions, listed in the log above — which is what "minimal change between dialects" should mean in practice, stated as a count rather than as a claim.
Zero rows is the correct result, not a disappointing one. No synthetic transactions were generated. Filling these tables with invented baskets would produce output that looks like a finding, proves nothing, and to a reader skimming quickly would imply access I do not have.
What the five queries establish
Query 2 alone would convert every illustrative figure in the commercial model into an actual one. That is roughly a week of work for someone inside the business, and it is the reason this study stops where it does. The queries are ready; only the data is missing.
04 · Commercial model
Built so that a commercial reader can change an assumption and watch every downstream figure move — not a static table of numbers I calculated elsewhere and typed in.
| Sheet | Contents |
|---|---|
| README | Colour convention, sources, what the model does and does not claim |
| Assumptions | Every input in one place, each tagged with its source or marked unsupported |
| Scenario Model | Addressable spend by catchment group at 5% / 10% / 20% recapture |
| Sensitivity | Recapture rate against FSNI EBIT, wholesale ratio applied |
| Segments | The five archetype profiles, linked through to the scenarios |
| Gap Shortlist | The 22 flagged catchments with households and distances |
| Data Dictionary | Every field, its definition, its source file and its units |
Three cell colours, stated on the README sheet and used consistently:
| Colour | Meaning |
|---|---|
| Blue | Input — change this |
| Black | Formula — do not type over it |
| Yellow | Assumption with no evidential basis — the recapture rate is the main one |
The yellow cells matter more than the blue ones. A commercial reader will test any figure within a minute, and marking the ones I could not support is faster and more credible than waiting to be asked.
$171.6m of annual grocery spend sits in the 22 flagged catchments. Against FSNI's published FY24 accounts — $9.2bn revenue, $292m EBIT, a 3.17% margin and a $3.2m statutory loss — recapturing 10% of it is worth roughly $0.46m of EBIT. About 0.16%.
That is modest, and the model says so rather than hiding it. The same workbook shows a 5% improvement in the allocation of FSNI's $254m annual capex is worth around $12.7m — twenty-seven times the leverage. The catchment gaps are not the prize. They are the evidence the method works.
05 · Dashboard
Not yet built
The extract is published and the build is specified sheet by sheet, including calculated fields, the map projection and the colour rules. The workbook itself is not finished, and this page says so rather than showing the specification as though it were a result.
Specified — five sheets into one dashboard
This section gets filled in when the workbook is built.
The output
Each archetype has a mission the network should serve and a distance beyond which it does not. The thresholds are judgment, informed by each segment's mobility profile, and they are stated explicitly so they can be challenged and re-run.
| Catchment | Archetype | Households | Nearest FS banner | Nearest Woolworths |
|---|---|---|---|---|
| Victoria Park | Inner-city | 1,566 | 1.08 | 0.57 |
| Symonds Street West | Inner-city | 1,482 | 1.05 | 0.92 |
| Manly East | Town-centre | 1,359 | 2.81 | 1.51 |
| Symonds Street North West | Inner-city | 1,308 | 1.05 | 0.60 |
| Unsworth Heights East | Town-centre | 1,296 | 3.22 | 1.95 |
| Helensville | Town-centre | 1,137 | 2.27 | 0.27 |
| Queen Street South West | Inner-city | 1,065 | 1.18 | 0.67 |
| Ōtāhuhu South | Town-centre | 1,035 | 3.47 | 0.91 |
| Māngere Bridge | Town-centre | 972 | 2.65 | 2.28 |
| Albany West | Town-centre | 915 | 2.07 | 3.17 |
| Surfdale | Town-centre | 756 | 2.75 | 1.61 |
| Ostend | Town-centre | 729 | 2.75 | 0.30 |
| Henderson Central | Inner-city | 729 | 2.68 | 0.54 |
| Glendowie South East | Town-centre | 726 | 2.49 | 2.89 |
| Hobson Ridge South | Inner-city | 705 | 1.28 | 0.88 |
| Windsor Park | Town-centre | 651 | 2.55 | 1.57 |
| Symonds Street East | Inner-city | 600 | 1.26 | 0.85 |
| Oneroa East–Palm Beach | Town-centre | 480 | 3.00 | 2.15 |
| Manukau Central | Town-centre | 411 | 3.14 | 2.91 |
| Piha | Town-centre | 381 | 16.54 | 16.84 |
| Ōtāhuhu Central | Town-centre | 342 | 3.37 | 0.26 |
| Panmure West | Inner-city | 126 | 3.49 | 1.62 |
South Auckland's 129 multi-family catchments — the segment with the largest baskets and the tightest budgets — are well served, at a median 2.22 km to a Pak'nSave. That is closer than any other segment sits to its matching value format.
I expected to find a gap there. There isn't one, and it appears on this page at the same size as the findings that support the argument. A model that only ever confirms expectations is not being read honestly.
Distances are straight-line, not walking or driving. In the CBD, with motorways and a harbour edge, real walking distance is longer — which widens these gaps rather than narrowing them.
Store positions come from OpenStreetMap. Querying supermarkets alone returned 7 Four Squares for the whole region, an obvious undercount; including convenience-tagged stores gives 24, against 25 reported for the Auckland region as at April 2025. Coverage is effectively complete — but the eight inner-city flags should still be confirmed against the official store finder before any decision rests on them.