Metadata-Version: 2.4
Name: urban-dollop
Version: 0.2.0
Summary: Urban freight simulation based on the MASS-GT multi-agent model
Project-URL: Repository, https://github.com/hcubasd/urban-dollop
Author-email: hcubasd <127428549+hcubasd@users.noreply.github.com>
License-Expression: GPL-2.0-only
License-File: LICENSE
Requires-Python: <3.15,>=3.11
Requires-Dist: geopandas>=1.1
Requires-Dist: networkx>=3.6
Requires-Dist: numpy>=2.4
Requires-Dist: pandas>=3.0
Requires-Dist: pydantic>=2.13
Requires-Dist: scipy>=1.17
Requires-Dist: xlsxwriter>=3.2
Provides-Extra: fast
Requires-Dist: numba>=0.57; extra == 'fast'
Description-Content-Type: text/markdown

# urban-dollop

A Python package for urban freight simulation based on the MASS-GT multi-agent model.
Consolidates the original six-container LEAD pipeline into a single installable library —
one `pip install` replaces six Docker images.

Built as academic research: every step's mathematical model is documented alongside the
API so the simulation is fully transparent and citable.

---

## Install

```bash
pip install urban-dollop
```

For performance-critical runs (enables Numba JIT on hot loops):

```bash
pip install urban-dollop[fast]
```

---

## Usage

Each pipeline step is a plain function: it accepts data objects and returns a
`pandas.DataFrame`. No files are written unless you ask — the library produces results;
you decide what to do with them.

### Step 1 — Demand generation

Estimate the daily parcel delivery demand for a city from zonal population and employment
data.

```python
from urban_dollop import Zone, Depots, CarrierShares, DemandData, generate_demand

data = DemandData(
    zones          = Zone("zones.gpkg"),
    depots         = Depots("depots.gpkg"),
    skim_time      = "skim_time.mtx",
    carrier_shares = CarrierShares("shares.csv"),
    study_area     = ["Joinville"],
)

parcels = generate_demand(data)
```

`parcels` is a `pandas.DataFrame` — one row per parcel:

| column | type | description |
|---|---|---|
| `parcel_id` | `int` | unique parcel identifier |
| `origin_zone` | `int` | zone ID of the originating depot |
| `destination_zone` | `int` | zone ID of the delivery address |
| `depot_id` | `int` | depot that handles this parcel |
| `carrier` | `str` | carrier name (as supplied in `carrier_shares`) |
| `vehicle_type` | `int` | vehicle type code (`7` = van) |
| `locker_zone` | `int` | zone ID if routed to a parcel locker; `0` otherwise |
| `segment` | `str` | `"B2C"` (business-to-consumer) or `"C2C"` (consumer-to-consumer) |
| `local_to_local` | `bool` | parcel originates and delivers within the study area |
| `crowdship_eligible` | `bool` | eligible for crowdshipping (always a subset of `local_to_local`) |
| `fulfilment_type` | `str` | `"Hubspoke"` or `"Hyperconnected"` |

**Save as CSV:**

```python
parcels.to_csv("parcel_demand.csv", index=False)
```

**Save as a GIS layer (QGIS, ArcGIS):**

```python
from urban_dollop import demand_layer

# attach zone geometry and aggregate by destination zone
gdf = demand_layer(parcels, data.zones)
gdf.to_file("parcel_demand.gpkg")   # drag directly into QGIS
```

`gdf` is a `geopandas.GeoDataFrame` — one row per zone — with the original zone geometry
plus:

| column | description |
|---|---|
| `parcel_count` | total parcels delivered to this zone |
| `hubspoke_count` | via standard depot-to-door fulfilment |
| `hyperconnected_count` | via local / crowdshipping fulfilment |
| `b2c_count` | business-to-consumer parcels |
| `c2c_count` | consumer-to-consumer parcels |
| `local_to_local_count` | parcels that originate within the study area |

**Column mapping — when your file uses different names:**

Each input type expects canonical column names by default.  If your files use
different names, supply a mapping — only the columns that differ:

```python
data = DemandData(
    zones = Zone("zones.gpkg", columns={
        "zone_id":    "id",
        "households": "hh_count",
        "jobs":       "employment",
    }),
    depots = Depots("depots.gpkg", columns={
        "carrier": "operator",
    }),
    skim_time      = "skim_time.mtx",
    carrier_shares = CarrierShares("shares.csv", columns={
        "share": "market_share",
    }),
    study_area = ["Joinville"],
)
```

Canonical column names:

| input | column | description |
|---|---|---|
| `Zone` | `zone_id` | unique integer zone identifier |
| `Zone` | `municipality` | municipality name (used for `study_area`) |
| `Zone` | `households` | household count |
| `Zone` | `jobs` | job / employee count |
| `Depots` | `depot_id` | unique integer depot identifier |
| `Depots` | `zone_id` | zone the depot is located in |
| `Depots` | `carrier` | carrier / operator name |
| `CarrierShares` | `share` | market share fraction (sums to 1) |

**Adjust calibration parameters:**

```python
from urban_dollop import DemandConfig

parcels = generate_demand(
    data,
    DemandConfig(
        parcels_per_household   = 0.178,   # default: 0.2054 (MASS-GT / NL)
        parcels_per_employee    = 0.029,   # default: 0.0
        local_to_local_fraction = 0.08,    # default: 0.04
        random_seed             = 42,
    ),
)
```

---

### Command-line interface

If your data directory contains the canonical input filenames, you can run the
simulation without writing any Python:

```bash
urban-dollop generate-demand data/
urban-dollop generate-demand data/ --outdir results/
urban-dollop generate-demand data/ --study-area Joinville --outdir results/
```

The CLI looks for these filenames in `<data-dir>`:

| file | description |
|---|---|
| `zones.gpkg` | zone polygons with socioeconomic attributes |
| `depots.gpkg` | depot / parcel node locations |
| `skim_time.mtx` | travel-time matrix (binary, seconds) |
| `carrier_shares.csv` | carrier market share table |

Output written to `<data-dir>` (or `--outdir`):

| file | description |
|---|---|
| `parcel_demand.csv` | one row per parcel |
| `parcel_demand.gpkg` | parcel counts joined to zone geometry |

**Calibration via config file** — place `urban-dollop.toml` in your project root
to override defaults without touching code:

```toml
[demand]
parcels_per_household     = 0.178   # NL baseline: 0.2054
parcels_per_employee      = 0.029   # NL baseline: 0.0
local_to_local_fraction   = 0.08    # NL baseline: 0.04
random_seed               = 42
```

If no config file is present, the MASS-GT original defaults are used.

---

## Mathematical model

### Step 1 — Parcel demand generation

#### Zonal demand

The number of parcels destined for zone $z$ on an average weekday:

$$D_z = \left\lfloor \frac{H_z \cdot r_{HH}}{s_{B2C}(z)} + \frac{E_z \cdot r_E}{s_{B2B}(z)} \right\rceil$$

| symbol | `DemandConfig` field | description |
|---|---|---|
| $H_z$ | — | households in zone $z$ (`households`) |
| $E_z$ | — | employees in zone $z$ (`jobs`) |
| $r_{HH}$ | `parcels_per_household` | parcels per household per day |
| $r_E$ | `parcels_per_employee` | parcels per employee per day |
| $s_{B2C}^0$ | `delivery_success_b2c` | baseline first-attempt success rate, B2C |
| $s_{B2B}^0$ | `delivery_success_b2b` | baseline first-attempt success rate, B2B |

When a parcel locker is present in zone $z$, the effective success rate is raised:

$$s_{B2C}(z) = \alpha_z + (1 - \alpha_z)\, s_{B2C}^0$$

where $\alpha_z = \ell \cdot \mathbf{1}[z \in \mathcal{L}]$, $\mathcal{L}$ is the set of zones with
lockers (`locker_zones`), and $\ell$ is the locker adoption rate (`locker_adoption_rate`).

#### Carrier split

Total demand is distributed across carriers by market share:

$$D_{z,k} = \left\lfloor \sigma_k \cdot D_z \right\rceil$$

where $\sigma_k$ is the market share of carrier $k$ (column `share` in `carrier_shares`).

#### Depot assignment

Each parcel is assigned to the nearest depot of its carrier by travel time:

$$\delta(z, k) = \underset{n \in \mathcal{N}_k}{\arg\min}\; t(n, z)$$

where $\mathcal{N}_k$ is the set of depots operated by carrier $k$ and $t(n, z)$ is the
travel time in seconds from depot $n$ to zone $z$, read from the pre-computed skim matrix.

#### Market segment

Each parcel is independently assigned a segment:

$$\text{segment}_i \sim \text{Bernoulli}(p_{C2C}), \qquad p_{C2C} = \frac{r_{HH,C2C}}{r_{HH}}$$

where $r_{HH,C2C}$ is `parcels_per_household_c2c`.

#### Local-to-local, crowdshipping, and fulfilment type

A fraction $f_{L2L}$ of parcels are flagged local-to-local; of those, a fraction $f_{CS}$
are crowdshipping-eligible:

$$\text{L2L}_i \sim \text{Bernoulli}(f_{L2L})$$

$$\text{CS}_i \sim \text{L2L}_i \cdot \text{Bernoulli}(f_{CS})$$

For L2L parcels, the origin zone is resampled from the study-area household distribution
(reflecting that senders are residents, not distant warehouses):

$$P(\text{origin}_i = z) = \frac{H_z}{\displaystyle\sum_{z'} H_{z'}}$$

Fulfilment type is then assigned deterministically:

$$\text{fulfilment}_i = \begin{cases} \text{Hyperconnected} & \text{if } \text{L2L}_i \lor \text{CS}_i \lor (\text{locker}_i \neq 0) \\ \text{Hubspoke} & \text{otherwise} \end{cases}$$

---

## History and research context

Forked from [MASS-GT](https://github.com/orgs/mass-gt/repositories), a multi-agent
simulation system for urban goods transport originally developed for the Dutch
Randstad region.

This refactor is part of ongoing academic research with two primary contributions:

1. **Unified package.** The original six-container LEAD microservice architecture is
   dissolved into a single in-memory Python pipeline. Shared data (zone maps, skim
   matrices) is loaded once and passed through all steps without intermediate files.

2. **Grade-aware emission accounting.** The original COPERT emission model assumes flat
   roads. This package introduces road gradient as a third coordinate into the emission
   step via the Vehicle Specific Power (VSP) formulation, enabling topographically
   accurate emission estimates in hilly cities. The Joinville (Brazil) case study,
   with elevation ranging from 0 to 881 m, is the primary validation dataset.

The original LEAD Packaging and Modeler Version source files are preserved in `tmp/`
during the refactoring process.
