---
title: "Boundary discontinuity designs on networks with rd2dnet"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Boundary discontinuity designs on networks with rd2dnet}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 4)
```

`rd2dnet` estimates boundary discontinuity designs on networks. It carries the
location-based framework of **rd2d** (Cattaneo, Titiunik and Yu) over from Euclidean
coordinates to network coordinates and user-defined network metrics: side-specific local
polynomials, robust bias-corrected inference, uniform bands, data-driven bandwidths and
aggregate effects. Please cite the rd2d papers (see `citation("rd2dnet")`) when you use it.

This vignette uses the bundled **mock city**, a small synthetic street grid with a
boundary along "60th Street" (treated side to the south), a highway with ramps, and a park
on the boundary. It is only an illustration; nothing here depends on that application.
Everything runs offline.

```{r setup}
library(rd2dnet)
city <- load_mock_midtown()
obs <- city$obs
y <- obs$outcome
head(obs)
```

## 1. Data and network

`city$nodes` holds the street intersections with projected `x`/`y` coordinates (metres);
`city$edges` the street segments with `length`, `highway` class, `speed_kph` and
`travel_time`. `city$boundary` is a `boundary()`: a line, the treated side, and a **mask**
for the park frontage, where one side has no parcels and no effect can be estimated.

```{r map, fig.height = 6}
nd <- city$nodes
e <- city$edges
plot(nd$x, nd$y, type = "n", asp = 1, xlab = "x (m)", ylab = "y (m)",
     main = "mock city: treated (red) / control (blue)")
segments(nd$x[match(e$from, nd$name)], nd$y[match(e$from, nd$name)],
         nd$x[match(e$to, nd$name)], nd$y[match(e$to, nd$name)], col = "grey75", lwd = 0.6)
polygon(city$park, col = adjustcolor("forestgreen", 0.3), border = NA)
points(obs$x, obs$y, pch = 16, cex = 0.25, col = ifelse(obs$treated, "firebrick", "steelblue"))
lines(city$boundary$line, lwd = 2)
city$boundary
```

## 2. Distance is a metric you choose

A network is a graph **plus a metric**. Distance means dissimilarity and does not have to
be metres. Coordinates, kernel distances and bandwidths are all in the metric's units,
while the treatment side stays geographic.

```{r metrics}
metrics <- list(
  "length (m)" = "length",
  "travel time (s)" = travel_time(),
  # 50 extra units per unit change in floor-area ratio between adjacent nodes
  "length + land-use contrast" = node_contrast("far", 50),
  # switching road class costs 500 m; ramps are transparent connectors, so
  # highway -> ramp -> highway is cheap but highway -> ramp -> street is not
  "length + class switches" = transition_cost("length", class_attr = "highway",
                                              default_penalty = 500, connectors = "motorway_link")
)
pts <- obs[1:5, c("x", "y")]
round(sapply(metrics, function(m) {
  network_coordinates(city$network(m), city$boundary, pts, n_eval = 5)$s
}), 1)
```

A metric can also be any vectorised function of the edge table, for example
`function(edges) edges$length * ifelse(edges$highway == "motorway", 3, 1)`.
Any node-by-node dissimilarity matrix works too (`precomputed()`). Observations then snap
to the nearest node, because per-edge costs are unknown.

```{r precomputed}
base <- city$network("length")
D <- distances_from(base, seq_len(base$n_nodes)) # or your own matrix, in node order
pre <- city$network(precomputed(sqrt(D))) # e.g. a concave transform
round(network_coordinates(pre, city$boundary, pts, n_eval = 5)$s, 2)
```

## 3. Network coordinates

`network_coordinates()` snaps each observation to the street network **on its own side**
and computes, as rd2d's `(X1 - b1, X2 - b2)`:

* `s`: signed network distance to the boundary (positive on the treated side);
* at each evaluation point `b`: `d(b)`, the network distance used for kernel weights, and
  `o(b)`, the signed offset along the boundary.

Evaluation points default to an even grid along the boundary; masked ones are skipped.

```{r coords}
net <- city$network("length")
coords <- network_coordinates(net, city$boundary, obs, n_eval = 21)
coords
head(as.data.frame(coords, j = 11))
head(eval_table(coords), 8)
```

## 4. Estimation

`rdbwnet()` ports rd2d's direct plug-in bandwidth selector (`mserd` by default; also
`msetwo`, `imserd`, `imsetwo` and the coverage-error-optimal `cer*` variants). `rdnet()`
selects bandwidths itself when `h` is not given, fits order `p` (default 1) for the point
estimate and order `q = p + 1` for robust bias-corrected inference, and adds a uniform band.

```{r bandwidths}
bw <- rdbwnet(y, coords)
head(bw[, c("arc", "h0", "h1", "n0", "n1", "status")])
```

```{r estimate}
res <- rdnet(y, coords, seed = 0)
res
```

```{r plot}
plot(res, true_tau = city$true_tau(res$table$arc),
     main = "BATEC along the boundary: estimate, robust CIs, uniform band, truth")
```

Bandwidths can also be given directly: a scalar, `c(h_control, h_treated)`, one per
evaluation point, or a `J x 2` matrix. `weights` multiplies the kernel weights (for
example floor area), and `pooled = TRUE` fits a common-slope model.

```{r fixed}
res_fixed <- rdnet(y, coords, h = 600, weights = obs$far, seed = 0)
head(res_fixed$table[, c("arc", "tau_bc", "se_rb", "status")])
```

## 5. Aggregate effects: WBATE and LBATE

As in rd2d's `summary(WBATE = ..., LBATE = TRUE)`:

* **WBATE** `w' tau`: weights are normalised to sum to one, and the standard error
  `sqrt(w' Sigma w)` uses the joint covariance `res$cov` of the estimates across
  evaluation points. Points that were not estimated (masked) must get weight 0.
* **LBATE** `max_j tau(b_j)`: its interval uses the uniform critical value, so it
  inherits the band's joint coverage (conservative for the maximum).

```{r aggregate}
ok <- res$table$status == "ok"
summary(res, wbate = as.numeric(ok), lbate = TRUE)$aggregate
# Weight by frontage instead: here, by the number of parcels near each point.
wbate(res, ifelse(ok, res$table$n0 + res$table$n1, 0))
```

Averaging correlated neighbours helps less than independence would suggest:

```{r correlated}
se_naive <- sqrt(sum((ok / sum(ok))^2 * ifelse(ok, res$table$se_rb, 0)^2))
c(with_covariance = wbate(res, as.numeric(ok))[["se_rb"]], ignoring_it = se_naive)
```

## 6. Covariates, fuzzy designs, derivatives

**Covariates** enter with coefficients common to both sides (rd2d's `covs_eff`) and are
estimated jointly with the local polynomials. In the mock city the outcome depends only
weakly on log floor-area ratio, so adjusting for it leaves estimates and standard errors
almost unchanged; a covariate that predicts the outcome well shrinks the standard errors.

```{r covariates}
res_cov <- rdnet(y, coords, h = 600, covs = log(obs$far), seed = 0)
res_raw <- rdnet(y, coords, h = 600, seed = 0)
cmp <- data.frame(tau_bc = res_raw$table$tau_bc, tau_bc_covs = res_cov$table$tau_bc,
                  se_rb = res_raw$table$se_rb, se_rb_covs = res_cov$table$se_rb)
head(round(na.omit(cmp), 3))
```

**Fuzzy designs**: pass take-up `fuzzy = D`. The estimate is the ratio of the outcome jump
to the take-up jump at each point; the first stage is reported as `fs`/`fs_se`. Below,
take-up is 75% on the treated side and 25% on the other (true first stage 0.5) and the
effect of take-up is 2, in a larger simulated city (12,000 parcels).

```{r fuzzy}
big <- make_mock_midtown(n_obs = 12000, seed = 11)
big_coords <- network_coordinates(big$network(), big$boundary, big$obs, n_eval = 21)
set.seed(2)
T <- big_coords$treated
D_takeup <- as.numeric(ifelse(T, runif(length(T)) < 0.75, runif(length(T)) < 0.25))
y_fuzzy <- 0.0008 * (big$obs$y - 1600) + 2 * D_takeup + rnorm(length(T), 0, 0.5)
res_fz <- rdnet(y_fuzzy, big_coords, fuzzy = D_takeup, seed = 0)
head(round(na.omit(res_fz$table[, c("arc", "fs", "fs_se", "tau", "ci_lo", "ci_hi")]), 3))
```

**Derivatives**: `deriv = c(1, 0)` is the jump in the slope *across* the boundary (a
kink), `deriv = c(0, 1)` the jump in the slope *along* it, and `tangvec = c(t_s, t_o)` any
directional derivative. The mock outcome has the same slope on both sides, so the true
kink is zero.

```{r kink}
kink <- rdnet(y, coords, h = 800, deriv = c(1, 0), seed = 0)
head(signif(na.omit(kink$table[, c("arc", "tau_bc", "ci_lo", "ci_hi")]), 3))
```

## 7. Standard errors

`vce` takes `"hc0"`-`"hc3"`; `cluster` gives cluster-robust errors; `conley_cutoff` gives
a Bartlett spatial HAC over **network** distance (or `conley_distance = "euclidean"`).
The same choice drives the uniform band and WBATE/LBATE through `res$cov`.

A caution on `cluster`: as in rd2d, cluster-robust errors use the residuals of the local
fit. When clusters are small relative to the bandwidth (here, city blocks inside a 600 m
window), the local polynomial absorbs much of the within-cluster variation and the
cluster standard errors come out too small. For spatially correlated shocks, the Conley
option, whose residuals come from a wider auxiliary fit, is the validated choice.

```{r vce}
fits <- list(
  HC1 = rdnet(y, coords, h = 600, seed = 0),
  cluster_block = rdnet(y, coords, h = 600, cluster = obs$block, seed = 0),
  conley_network = rdnet(y, coords, h = 600, conley_cutoff = 150, seed = 0),
  conley_euclidean = rdnet(y, coords, h = 600, conley_cutoff = 150, conley_distance = "euclidean", seed = 0)
)
head(round(na.omit(sapply(fits, function(r) r$table$se_rb)), 3))
```

## 8. Density (sorting) test

The local polynomial density test of Cattaneo, Jansson and Ma (`rddensity`), as a
`density()` method for network coordinates. By default position is measured in
**cumulative street length** from the boundary (`normalize = "network"`), so the test asks
whether there are more parcels per metre of street on one side, instead of reacting to
differences in street supply.

```{r density}
density(coords)
density(coords, normalize = "none")
```

## 9. Recipes

These need no special API; they combine the pieces above.

### Donut

Drop observations within `delta` (in metric units) of the boundary. Missing outcomes are
skipped.

```{r donut}
y_donut <- ifelse(abs(coords$s) < 40, NA, y)
donut <- rdnet(y_donut, coords, h = 700, seed = 0)
head(round(na.omit(data.frame(full = res_fixed$table$tau_bc, donut_40m = donut$table$tau_bc)), 3))
```

### Covariate balance

Pass a pre-determined characteristic as the outcome; there should be no jump. In the mock
city, floor-area ratio is unrelated to treatment.

```{r balance}
bal <- rdnet(log(obs$far), coords, seed = 0)
summary(bal, wbate = as.numeric(bal$table$status == "ok"), lbate = TRUE)$aggregate
```

### Placebo boundary

Build a `boundary()` along a fake line inside one side and use only that side's
observations. Here: a street 8 blocks north, on the control side. It runs through the
park, which is masked.

```{r placebo}
fake_y <- city$boundary_y + 8 * 80
north <- obs$y > city$boundary_y
fake <- boundary(rbind(c(-400, fake_y), c(2600, fake_y)),
                 treated_region = rect_polygon(-1e5, -1e5, 1e5, fake_y), mask = list(city$park))
pc <- network_coordinates(city$network(), fake, obs[north, ], n_eval = 15)
placebo <- rdnet(y[north], pc, seed = 0)
summary(placebo, wbate = as.numeric(placebo$table$status == "ok"))$aggregate
```

### Difference-in-discontinuities

With a **panel** (the same units before and after), run on `y_post - y_pre`. With
**independent samples**, estimate each period with the same evaluation points and
bandwidths and difference the estimates, with `se = sqrt(se_pre^2 + se_post^2)`.

```{r did}
set.seed(5)
f <- y - city$true_tau(coords$crossing_arc) * coords$treated # outcome without the effect
y_pre <- f + rnorm(length(y), 0, 0.5) # same parcels, earlier period
y_post <- y + rnorm(length(y), 0, 0.5)
did_panel <- rdnet(y_post - y_pre, coords, h = 700, seed = 0)

c_pre <- make_mock_midtown(n_obs = 3000, seed = 21)
c_post <- make_mock_midtown(n_obs = 3000, seed = 22)
co_pre <- network_coordinates(c_pre$network(), c_pre$boundary, c_pre$obs, n_eval = 21)
co_post <- network_coordinates(c_post$network(), c_post$boundary, c_post$obs, n_eval = 21)
y0 <- c_pre$obs$outcome - c_pre$true_tau(co_pre$crossing_arc) * co_pre$treated
r_pre <- rdnet(y0, co_pre, h = 700, seed = 0)$table
r_post <- rdnet(c_post$obs$outcome, co_post, h = 700, seed = 0)$table
did <- data.frame(arc = r_post$arc, panel = did_panel$table$tau_bc,
                  independent = r_post$tau_bc - r_pre$tau_bc,
                  se_independent = sqrt(r_pre$se_rb^2 + r_post$se_rb^2),
                  truth = city$true_tau(r_post$arc))
head(round(na.omit(did), 3))
```

## 10. Large data

* **Repeated measurements** (sensor panels, many readings per site) are snapped and
  measured once per distinct location: compare `coords$n_locations` and `coords$n_obs`.
* Shortest paths, snapping and the Conley kernel run in compiled code. Conley cost grows
  with the number of observation pairs closer than the cutoff, which grows with the square
  of local density. With millions of nearby points, a smaller cutoff or clustered errors
  are much cheaper.

```{r panel}
panel_xy <- as.matrix(obs[rep(1:500, each = 20), c("x", "y")]) # 500 sensors x 20 readings
pcoords <- network_coordinates(net, city$boundary, panel_xy, n_eval = 11)
c(n_obs = pcoords$n_obs, n_locations = pcoords$n_locations)
```
