Tool output, end to end

    
  

Working record · Auckland · September 2026

Catchment Mission Fit

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.

Python
Feature build, K-means segmentation, spatial join across 606 catchments
Executed
R
Independent validation — gap statistic, bootstrap stability, MANOVA, Tukey HSD
Executed
SQL
Five queries that test the model's central assumption — all five execute, none can return a row
Runs · no data
Excel
Commercial model — 64 live formulas, scenario and sensitivity sheets
Executed
Tableau
Five-sheet dashboard, specified in full against a published extract
Not yet built

01 · Segmentation

Python

build_features.py · cluster.py · format_fit.py

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.

Choosing k — and going against the metric

Silhouette favours three clusters. I chose five.

Silhouette score by cluster count, 25 random initialisations at each k, fixed seed
kSilhouetteOutcome
30.353Metric optimum — inner-city absorbed into a broader urban group
40.351Marginally lower, same absorption
50.326Chosen — inner-city apartments separate
60.318Splits suburban without adding interpretation
The judgment

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.

The five archetypes

Segment profiles — Census 2023, household level
Archetype Areas Households One-person No vehicle Median income Crowded
Inner-city apartments1515,15644.9%51.9%$67,9008.8%
Town-centre mixed114104,50528.5%9.5%$97,4007.4%
South Auckland multi-family12995,16911.8%7.3%$107,70021.2%
Suburban mainstream282267,45315.6%3.7%$132,6005.9%
Rural fringe6634,30215.5%1.6%$127,7005.2%

Is this just income and density renamed?

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.

$ python3 cluster.py — PCA check
  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

Network overlay

Store locations reprojected to NZTM2000, nearest-store distance computed per banner per catchment.

Median straight-line distance to nearest store, km
ArchetypeFour SquareNew WorldPak'nSaveWoolworths
Inner-city apartments1.050.906.330.57
Town-centre mixed2.751.762.831.04
South Auckland multi-family3.242.432.221.63
Suburban mainstream2.992.102.911.49
Rural fringe7.187.367.276.47
Four defects found, four disclosed

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

R

validate.R · base R, no packages

The segmentation was built in Python, so it was validated in something else. Four tests, the console output as it ran.

$ Rscript validate.R
=====================================================================
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
Variance in each feature explained by segment membership (η²). No-vehicle share and density separate the segments most sharply; income least — which is the point the PCA also made.
00.250.500.751.0
What these results actually prove — and what they don't

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

SQL

validation_queries.sql · run_sql.py · DuckDB 1.5.5

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.

validation_queries.sql — Query 1, the central test
/* 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
    ...
$ python3 run_sql.py
=====================================================================
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
=====================================================================
Running it found two defects — which is the reason to run it

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.

On the schema and the dialect

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.

What this does not show

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

  1. Does trip frequency and basket size differ by archetype, as the model predicts?
  2. Where do the households in the eight flagged catchments currently shop?
  3. What share of each catchment's grocery spend already reaches a Foodstuffs banner?
  4. Does range mix differ by archetype in stores that serve more than one?
  5. Has mission mix moved since the last census — the cost-of-living drift test.

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

Excel

catchment_commercial_model.xlsx · 7 sheets, 64 live formulas

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.

Workbook structure
SheetContents
READMEColour convention, sources, what the model does and does not claim
AssumptionsEvery input in one place, each tagged with its source or marked unsupported
Scenario ModelAddressable spend by catchment group at 5% / 10% / 20% recapture
SensitivityRecapture rate against FSNI EBIT, wholesale ratio applied
SegmentsThe five archetype profiles, linked through to the scenarios
Gap ShortlistThe 22 flagged catchments with households and distances
Data DictionaryEvery field, its definition, its source file and its units

The colour convention is the argument

Three cell colours, stated on the README sheet and used consistently:

ColourMeaning
BlueInput — change this
BlackFormula — do not type over it
YellowAssumption 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.

Where the model ends up

$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

Tableau

tableau_spec.md · extract published, build outstanding

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

  1. Catchment map — 606 SA2 areas coloured by archetype, store network overlaid by banner
  2. Segment profile — the seven features per archetype, as a small-multiple comparison
  3. Distance by banner — median distance matrix, archetype against banner
  4. Gap shortlist — the 22 flagged catchments, sortable by households and by distance
  5. Commercial sensitivity — recapture rate against EBIT, driven by a parameter control

This section gets filled in when the workbook is built.

The output

22 catchments, 18,771 households

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.

Flagged catchments, ranked by households. "Nearest Woolworths" is shown because in every inner-city case a competitor sits between the household and the nearest Foodstuffs banner.
CatchmentArchetypeHouseholdsNearest FS bannerNearest Woolworths
Victoria ParkInner-city1,5661.080.57
Symonds Street WestInner-city1,4821.050.92
Manly EastTown-centre1,3592.811.51
Symonds Street North WestInner-city1,3081.050.60
Unsworth Heights EastTown-centre1,2963.221.95
HelensvilleTown-centre1,1372.270.27
Queen Street South WestInner-city1,0651.180.67
Ōtāhuhu SouthTown-centre1,0353.470.91
Māngere BridgeTown-centre9722.652.28
Albany WestTown-centre9152.073.17
SurfdaleTown-centre7562.751.61
OstendTown-centre7292.750.30
Henderson CentralInner-city7292.680.54
Glendowie South EastTown-centre7262.492.89
Hobson Ridge SouthInner-city7051.280.88
Windsor ParkTown-centre6512.551.57
Symonds Street EastInner-city6001.260.85
Oneroa East–Palm BeachTown-centre4803.002.15
Manukau CentralTown-centre4113.142.91
PihaTown-centre38116.5416.84
Ōtāhuhu CentralTown-centre3423.370.26
Panmure WestInner-city1263.491.62
The result that contradicts me

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.

Before anyone acts on this table

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.