Metadata-Version: 2.4
Name: retailgravity
Version: 0.1.1
Summary: Spatial interaction and retail catchment models for site analysis: Huff, MCI with log-centering calibration, Reilly/Converse breaking points, Wilson's constrained gravity models with Furness balancing, distance-decay calibration, trade areas and what-if scenarios.
Author-email: Yusuf Eminoğlu <geospacephilo@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/YusufEminoglu/retailgravity_sdk
Project-URL: Repository, https://github.com/YusufEminoglu/retailgravity_sdk
Project-URL: Issues, https://github.com/YusufEminoglu/retailgravity_sdk/issues
Project-URL: Changelog, https://github.com/YusufEminoglu/retailgravity_sdk/blob/main/CHANGELOG.md
Keywords: huff-model,retail-gravitation,spatial-interaction,gravity-model,mci,reilly,breaking-point,wilson,furness,trade-area,catchment,site-selection,market-share,cannibalization,gis,urban-planning
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Environment :: Console
Classifier: Topic :: Scientific/Engineering :: GIS
Classifier: Topic :: Scientific/Engineering :: Mathematics
Classifier: Topic :: Office/Business
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.21
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Dynamic: license-file

# retailgravity

[![PyPI version](https://img.shields.io/pypi/v/retailgravity.svg)](https://pypi.org/project/retailgravity/)
[![Python versions](https://img.shields.io/pypi/pyversions/retailgravity.svg)](https://pypi.org/project/retailgravity/)
[![CI](https://github.com/YusufEminoglu/retailgravity_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/retailgravity_sdk/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

**Spatial interaction and retail catchment models for site analysis, in Python.**

`retailgravity` collects the classic retail-geography toolbox in one small, typed
package that depends on NumPy only:

- **Huff model**: patronage probabilities, expected patronage, store sales, market and fair share.
- **MCI** (Multiplicative Competitive Interaction) with the Nakanishi & Cooper **log-centering OLS** calibration. Huff's `alpha` and `beta` are estimated the same way.
- **Reilly's law** of retail gravitation and **Converse breaking points**, located on the great circle between towns.
- **Wilson's family** of spatial interaction models: unconstrained, production-, attraction- and doubly constrained (**Furness / IPF** balancing), with power or exponential deterrence.
- **Calibration of distance decay** to observed flows by Poisson **maximum likelihood** or **mean trip length** matching, reporting log-likelihood, R² and SRMSE.
- **Trade areas**: dominant-store assignment, probability contours as point sets, primary and secondary trade areas from cumulative patronage, and gridded probability surfaces.
- **What-if scenarios**: add, remove or resize stores and measure **cannibalisation**, competitor draw and net chain gain.
- Origins and stores come from lists, **CSV** or **GeoJSON** points. Distances are supplied as a matrix or computed (haversine for lon/lat, or Euclidean).
- A `retailgravity` command-line tool.

```bash
pip install retailgravity
```

Requires Python 3.9 or newer and NumPy.

---

## Quick start (Python)

```python
from retailgravity import read_locations, attribute_array, distance_matrix, huff

origins = read_locations("examples/origins.csv")   # id, lon, lat, population
stores = read_locations("examples/stores.csv")     # id, lon, lat, floor_area, chain
d = distance_matrix(origins, stores)               # haversine, km

res = huff(d, attribute_array(stores, "floor_area"), attribute_array(origins, "population"),
           alpha=1.0, beta=2.0, store_ids=[s.id for s in stores])
res.probabilities          # (origins x stores); every row sums to 1
res.sales                  # expected customers per store
res.market_share           # sums to 1
res.store_table()          # sales, market share, fair share, share index
```

Calibrating, delineating and testing scenarios:

```python
from retailgravity import calibrate_beta, fit_huff, cumulative_trade_area, Market, Store, compare

# beta from observed origin-store flows (production-constrained = Huff with spending O_i)
cal = calibrate_beta(observed, d, constraint="production", deterrence_kind="power",
                     destination_mass=floor_area)
cal.beta, cal.log_likelihood, cal.r_squared, cal.srmse

# alpha and beta from observed shares via MCI log-centering regression
fit = fit_huff(observed_shares, d, floor_area)
fit.alpha, fit.beta, fit.mci.summary()

# primary (60 %) / secondary (85 %) trade area of store 0, nearest origins first
labels = cumulative_trade_area(res.patronage[:, 0], order_by=d[:, 0])

# a new store for chain "Alpha"
market = Market.from_coordinates(origins, population, [Store("S1", 12000, 27.07, 38.41, "Alpha"), ...])
print(compare(market, market.add_store(Store("S5", 6000, 27.15, 38.47, "Alpha"))).summary())
```

`python examples/quickstart.py` runs all of this on the bundled synthetic data.

## Quick start (command line)

```bash
# Huff: store summary to stdout, long origin-store table to CSV
retailgravity huff --origins examples/origins.csv --stores examples/stores.csv \
    --demand-col population --attractiveness-col floor_area --beta 2 -o huff.csv

# Trade area of S1 as GeoJSON points (dominant store, probability band, primary/secondary)
retailgravity tradearea --origins examples/origins.csv --stores examples/stores.csv \
    --demand-col population --attractiveness-col floor_area --store S1 \
    --levels 0.75,0.5,0.25 --cumulative 0.6,0.85 -o s1_trade_area.geojson

# What-if: Alpha opens S5; S2 is enlarged by 50 %
retailgravity scenario --origins examples/origins.csv --stores examples/stores.csv \
    --demand-col population --attractiveness-col floor_area --chain-col chain \
    --add S5,27.15,38.47,6000,Alpha --resize "S2*1.5"

# Breaking points between every pair of towns
retailgravity breakpoint --places towns.csv --size-col population

# Calibrate beta from a long flow table (origin, destination, flow, cost)
retailgravity calibrate --flows flows.csv --constraint doubly --deterrence exponential
```

Output of the scenario command on the example data:

```
store           chain                 base      scenario        change        %
S1              Alpha             79136.22      72927.53      -6208.69     -7.8
S2              Beta              66232.10      53157.53     -13074.57    -19.7
S3              Alpha             73996.17      62843.20     -11152.98    -15.1
S4              Gamma             57635.51      52495.37      -5140.14     -8.9
S5              Alpha                 0.00      35576.38      35576.38
new store sales: 35576.38
cannibalisation (own chain): 17361.67
drawn from competitors: 18214.71
cannibalisation rate: 48.8 %
net change for chain Alpha: 18214.71
```

Precomputed network distances or travel times can replace the straight-line
distances: pass a matrix in Python, or `--distances` with a long CSV
(`origin_id,store_id,distance`) on the command line.

## API overview

| Area | Functions / classes |
|---|---|
| Distances | `distance_matrix`, `haversine_matrix`, `euclidean_matrix`, `interpolate` |
| Input/output | `read_locations` (CSV/GeoJSON), `locations_from_records`, `Location`, `attribute_array`, `coordinates`, `write_csv` |
| Huff | `huff` → `HuffResult`, `huff_probabilities`, `utilities`, `deterrence` |
| MCI | `mci_probabilities`, `fit_mci` → `MCIFit`, `fit_huff` → `HuffFit` |
| Reilly / Converse | `reilly_ratio`, `reilly_shares`, `breaking_point`, `breaking_point_location`, `breaking_points` |
| Wilson family | `spatial_interaction` → `SIResult`, `furness`, `mean_cost`, `ConvergenceWarning` |
| Calibration | `calibrate_beta` → `CalibrationResult`, `poisson_log_likelihood`, `goodness_of_fit` |
| Trade areas | `dominant_store`, `contour_sets`, `probability_bands`, `cumulative_trade_area`, `probability_surface` → `ProbabilitySurface` |
| Scenarios | `Market`, `Store`, `compare` → `ScenarioComparison` |

## Methods and sources

**Huff model** (Huff 1963, 1964). `P_ij = S_j^α f(d_ij) / Σ_k S_k^α f(d_ik)` with
`f(d) = d^-β` (Huff's λ is called `beta` here) or `exp(-β d)`. Expected patronage
`E_ij = C_i P_ij`. An infinite distance takes a store out of an origin's choice set. A zero
distance with the power form needs `min_distance`.

**MCI and log-centering** (Nakanishi & Cooper 1974; Cooper & Nakanishi 1988).
`P_ij = Π_k X_kij^β_k / Σ_l Π_k X_kil^β_k`. Dividing by the geometric mean of each choice
set gives `log(p_ij/p̃_i) = Σ_k β_k log(X_kij/X̃_ki)`, which is estimated by OLS without an
intercept. This equals a regression on logs with one dummy per choice set, so the residual
degrees of freedom are `N − I − K`. The test suite checks the equivalence numerically. Zero
shares cannot be logged and are dropped.

**Reilly and Converse** (Reilly 1931; Converse 1949). `B_a/B_b = (P_a/P_b)(D_b/D_a)²`.
The breaking point measured from the smaller place B is `D_B = D_AB / (1 + √(P_A/P_B))`.
Both exponents can be changed.

**Wilson's family** (Wilson 1971; Fotheringham & O'Kelly 1989):
unconstrained `T_ij = k V_i^μ W_j^α f(c_ij)`; production-constrained
`T_ij = A_i O_i W_j^α f(c_ij)` (the retail model, identical to Huff with power deterrence);
attraction-constrained `T_ij = B_j D_j V_i^μ f(c_ij)`; doubly constrained
`T_ij = A_i O_i B_j D_j f(c_ij)`, balanced by Furness (1965) iterations until both margins
are met to a relative tolerance of 1e-9.

**Calibration** (Hyman 1969; Batty & Mackie 1972). When the balancing factors (or `k`)
are free, the Poisson ML estimate of β reproduces the observed flow-weighted mean of
`c` (exponential) or `ln c` (power). `calibrate_beta(method="mle")` solves that
monotone equation by bisection. `method="mean_trip_length"` matches the mean cost
(Hyman's criterion) and gives the same answer for exponential deterrence. Fit is reported
as the Poisson log-likelihood, R² and SRMSE (Knudsen & Fotheringham 1986).

**Trade areas.** Huff mapped trade areas as equal-probability contours.
`contour_sets` and `ProbabilitySurface.contour_points` return them as point sets.
`cumulative_trade_area` ranks origins by distance or by contribution and cuts at
cumulative shares of the store's patronage. The default 60 % / 85 % primary/secondary
cut-offs are a common rule of thumb, not a published standard. Set them for your study.

**Scenarios.** Total demand is fixed, so a new store's sales always equal the losses of
existing stores. These losses split into cannibalisation (own chain) and competitor draw.

References:

- Huff, D. L. (1963). A probabilistic analysis of shopping center trade areas. *Land Economics*, 39(1), 81–90.
- Huff, D. L. (1964). Defining and estimating a trading area. *Journal of Marketing*, 28(3), 34–38.
- Nakanishi, M., & Cooper, L. G. (1974). Parameter estimation for a multiplicative competitive interaction model: least squares approach. *Journal of Marketing Research*, 11(3), 303–311.
- Cooper, L. G., & Nakanishi, M. (1988). *Market-Share Analysis*. Kluwer.
- Reilly, W. J. (1931). *The Law of Retail Gravitation*. New York: Knickerbocker Press.
- Converse, P. D. (1949). New laws of retail gravitation. *Journal of Marketing*, 14, 379–384.
- Wilson, A. G. (1971). A family of spatial interaction models, and associated developments. *Environment and Planning A*, 3(1), 1–32.
- Furness, K. P. (1965). Time function iteration. *Traffic Engineering and Control*, 7(7), 458–460.
- Hyman, G. M. (1969). The calibration of trip distribution models. *Environment and Planning*, 1(1), 105–112.
- Batty, M., & Mackie, S. (1972). The calibration of gravity, entropy, and related models of spatial interaction. *Environment and Planning*, 4(2), 205–233.
- Fotheringham, A. S., & O'Kelly, M. E. (1989). *Spatial Interaction Models: Formulations and Applications*. Kluwer.
- Knudsen, D. C., & Fotheringham, A. S. (1986). Matrix comparison, goodness-of-fit, and spatial interaction modeling. *International Regional Science Review*, 10(2), 127–147.
- Sinnott, R. W. (1984). Virtues of the haversine. *Sky and Telescope*, 68(2), 159.

## Limitations

- Distances are straight-line (spherical haversine, mean Earth radius 6371.0088 km, or planar Euclidean). For network distances or travel times, supply your own matrix (for example from a routing engine).
- Demand is fixed. The models redistribute spending and do not create new spending, so they do not model market expansion or outshopping beyond the study area. Choose a study area large enough to avoid edge effects.
- Huff and MCI assume independence from irrelevant alternatives (IIA). Closely competing nearby stores can be over-predicted.
- MCI calibration needs strictly positive shares and attributes. Zero shares are dropped, which biases estimates when zeros are common.
- `calibrate_beta` estimates one global β. It does not estimate local or origin-specific decay, the attractiveness exponent of constrained models, or confidence intervals.
- The 60 % / 85 % cumulative trade-area defaults are a convention and must be overridden as needed.

## Development

```bash
pip install -e ".[dev]"
pytest --cov=retailgravity
ruff check . && ruff format --check src tests && mypy src
```

The tests check worked examples against hand-computed values. Examples include Huff
probabilities of 6/11, 3/11 and 2/11, a Converse breaking point of 10 miles, the Furness
closed form `O_i D_j / T`, and exact recovery of known MCI and β parameters. They also
check invariants: rows sum to 1, constrained margins are reproduced, and cannibalisation
plus competitor draw equals the new store's sales.

## License

MIT © 2026 Yusuf Eminoğlu. See [LICENSE](LICENSE). To cite retailgravity, see
[CITATION.cff](CITATION.cff).

## Academic citation

```bibtex
@software{eminoglu_retailgravity,
  author  = {Eminoğlu, Yusuf},
  title   = {retailgravity},
  year    = {2026},
  version = {0.1.1},
  url     = {https://github.com/YusufEminoglu/retailgravity_sdk}
}
```

See also `CITATION.cff`.
