> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tukanmx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Enriquecer una red de sucursales

> Un ejemplo sencillo de cómo enriquecer con dato externos una red de farmacias en Tlaxcala

Este ejemplo ilustra cómo se podría enriquecer una base de datos interna de sucursales con información geográfica con la API de Tukan Atlas.

<Note>
  Para este ejemplo, creamos una lista de 15 sucursales con ventas hipotéticas. El resto de los datos como población, nivel socioeconómico, competidores y geografía salen de la API.
</Note>

## Requerimientos

Python con las librerías de `pandas`, `numpy` y `shapely`.

```python theme={null}
import io
import json
import os
import urllib.request

import numpy as np
import pandas as pd

BASE = "https://client.tukanmx.com/tukan-atlas"
TOKEN = os.environ["API_TUKAN"]          # tu token de Atlas


def call(path, body=None):
    """GET si no hay cuerpo, POST si lo hay."""
    req = urllib.request.Request(
        BASE + path,
        data=json.dumps(body).encode() if body is not None else None,
        headers={"Authorization": f"Token {TOKEN}", "Content-Type": "application/json"},
        method="POST" if body is not None else "GET",
    )
    with urllib.request.urlopen(req, timeout=300) as resp:
        return json.load(resp)
```

## 1. Encontrar los identificadores del estado

Empezamos buscando el código del estado y observamos la respuesta que nos arroja la búsqueda acotada para los municipios

```python theme={null}
estado = call("/geo-entities/?entity_type_id=STATE&name=tlaxcala")["results"][0]

# name es coincidencia parcial, así que nos quedamos con el nombre exacto
candidatos = call(f"/geo-entities/?entity_type_id=MUNICIPALITY&parent_code={estado}&name=tlaxcala")["results"]

pd.DataFrame(candidatos)[["entity_id", "entity_code", "name", "parent_name"]]
```

| entity\_id | entity\_code | name                | parent\_name |
| ---------- | ------------ | ------------------- | ------------ |
| 2266       | 29026        | Santa Cruz Tlaxcala | Tlaxcala     |
| 2274       | 29033        | Tlaxcala            | Tlaxcala     |

```python theme={null}
muestra = next(m for m in candidatos if m["name"] == "Tlaxcala")
```

En lugar de adivinar identificadores de indicadores uno por uno, conviene buscar por las [colecciones](/es/atlas/catalogos/colecciones), que los agrupan por tema.

```python theme={null}
pd.DataFrame(call("/collections/")["results"])[["collection_id", "theme", "name"]]
```

| collection\_id | theme                | name                               |
| -------------- | -------------------- | ---------------------------------- |
| 401            | demographics         | Indicadores censales               |
| 501            | socioeconomic        | Niveles socioeconómicos            |
| 601            | financial\_inclusion | Cuentas bancarias                  |
| 602            | financial\_inclusion | Crédito al consumo                 |
| 701            | demographics         | Conteo poblacional                 |
| 702            | demographics         | Razones poblacionales              |
| 801            | real\_estate         | Precios de vivienda                |
| 901 a 917      | economic\_activity   | Densidad comercial, una por sector |

Para este análisis usaremos las colecciones de *precios de vivienda*, *indicadores censales* y *niveles socioeconómicos*.

```python theme={null}
hogares = call("/latest/", {"collection_ids": [501], "geo_entity_ids": [muestra["entity_id"]]})

pd.DataFrame(hogares["results"])[["indicator_id", "indicator_name", "value"]]
```

| indicator\_id | indicator\_name                                    | value      |
| ------------- | -------------------------------------------------- | ---------- |
| 197           | Porcentaje de viviendas de nivel socioeconómico AB | 0.1467     |
| 198           | Porcentaje de viviendas de nivel socioeconómico C+ | 0.2010     |
| 199           | Porcentaje de viviendas de nivel socioeconómico C  | 0.1748     |
| 200           | Porcentaje de viviendas de nivel socioeconómico C- | 0.1452     |
| 3701          | Nivel socioeconómico alto (A/B y C+)               | 0.3477     |
| **3801**      | **Nivel socioeconómico medio (C y C-)**            | **0.3199** |
| 3802          | Nivel socioeconómico bajo (D+, D y E)              | 0.3323     |

La colección de *Indicadores censales* es mucho más grande, así que en vez de leerla completa filtramos por el nombre del indicador que buscamos.

```python theme={null}
censo = call("/latest/", {"collection_ids": [401], "geo_entity_ids": [muestra["entity_id"]]})
censo = pd.DataFrame(censo["results"])

censo[censo.indicator_name.str.contains("Población total")][
    ["indicator_id", "indicator_name", "value"]]
```

```
99 indicadores en la colección
  indicator_id   indicator_name    value
0         1528  Población total  99896.0
```

De *precios de vivienda* tomamos el precio de renta por metro cuadrado, que servirá como
referencia del poder adquisitivo de cada plaza.

```python theme={null}
vivienda = call("/latest/", {"collection_ids": [801], "geo_entity_ids": [muestra["entity_id"]]})

pd.DataFrame(vivienda["results"])[["indicator_id", "indicator_name", "value"]]
```

| indicator\_id | indicator\_name                                 | value     |
| ------------- | ----------------------------------------------- | --------- |
| 1801          | Metros cuadrados de viviendas listadas en renta | 1,429.85  |
| 3001          | Metros cuadrados de viviendas listadas en venta | 7,527.35  |
| 3002          | Viviendas listadas en venta                     | 35.00     |
| 3003          | Precio promedio por metro cuadrado de venta     | 15,964.45 |
| 3101          | Viviendas listadas en renta                     | 5.67      |
| **3201**      | **Precio promedio por metro cuadrado de renta** | **76.11** |

Para entender el nivel de competencia, usamos el servicio de *places* y filtramos con base en lo que obtengamos del [catálogo de categorías](/es/atlas/catalogos/categorias-de-lugares).

```python theme={null}
salud = call("/places-categories/grouped/")["GOOGLE_MAPS"]["health-and-wellness"]
[c for c in salud if c["id"] == "pharmacy"]
```

```
[{'id': 'pharmacy', 'name': 'Farmacia', 'name_en': 'Pharmacy'}]
```

Con eso ya tenemos todo para enriquecer nuestra base.

```python theme={null}
TLAXCALA = "29"
POBLACION, HOGARES_C_CMENOS, RENTA_M2 = 1528, 3801, 3201
CATEGORIA = "pharmacy"
```

## 2. La red de sucursales

Nuestros datos ficticios de las sucursales con el nombre, coordenadas y venta mensual promedio de cada punto de venta.

```python theme={null}
TEST_DATA = """
    sucursal,latitude,longitude,ventas_mensuales
    Centro,19.284879,-98.255133,1361900
    Reforma,19.307999,-98.225382,1442800
    Plaza Norte,19.337606,-98.217193,1310700
    Xicohténcatl,19.332313,-97.886693,1059700
    La Alameda,19.305184,-97.942057,639500
    Los Pinos,19.118759,-98.178993,1075500
    El Carmen,19.164834,-98.112922,953200
    La Loma,19.404575,-98.133820,995500
    Independencia,19.397536,-98.135907,1209500
    San Miguel,19.291117,-98.156040,796400
    Las Ánimas,19.309940,-98.189390,1274700
    Del Valle,19.214408,-98.244998,1361600
    Guadalupe,19.576389,-98.581800,1070300
    La Ribera,19.411487,-98.179409,686500
    Santa Cruz,19.331966,-98.174202,1262100
"""

red = pd.read_csv(io.StringIO(TEST_DATA.strip()))
```

```
15 sucursales | venta promedio $1,100,000 al mes
```

## 3. Ubicar cada sucursal

[Pinpoint masivo](/es/atlas/pinpoint/masivo) nos permite enriquecer las 15 coordenadas en una sola llamada y devuelve la jerarquía geográfica de cada punto, desde el estado hasta la manzana.

Con esto sabemos en qué municipio, código postal y AGEB cae cada tienda.

```python theme={null}
pin = call("/pinpoint/?page_size=100",
           {"coordinates": [[r.latitude, r.longitude] for r in red.itertuples()]})

ubicacion = pd.DataFrame(pin["results"])
red["municipio"] = ubicacion["municipality"].values
red["code"] = ubicacion["municipality_code"].values
red["zipcode"] = ubicacion["zipcode"].values
red["ageb"] = ubicacion["ageb"].values
```

| sucursal     | municipio           | zipcode | ageb          |
| ------------ | ------------------- | ------- | ------------- |
| Centro       | Tlaxcala            | 90110   | 2903300100777 |
| Reforma      | Tlaxcala            | 90114   | 2903300080688 |
| Plaza Norte  | Tlaxcala            | 90100   | 2903300110809 |
| Xicohténcatl | Huamantla           | 90508   | 290130083     |
| La Alameda   | Huamantla           | 90530   | 2901300010257 |
| Los Pinos    | San Pablo del Monte | 90970   | 2902500010205 |

## 4. El perfil de cada sucursal

Hacemos dos consultas a la base de datos, una para los códigos postales y otra para los municipos. Luego, con los datos que nos devolvió *pinpoint* hacemos el merge con las sucursales de la farmacia.

```python theme={null}
def perfil(nivel):
    d = call("/latest/", {
        "indicator_ids": [POBLACION, HOGARES_C_CMENOS, RENTA_M2],
        "geo_filter": {"entity_type_id": nivel,
                       "within": {"entity_code": TLAXCALA, "entity_type_id": "STATE"}},
    })
    nombres = {str(POBLACION): "poblacion", str(HOGARES_C_CMENOS): "hogares_c_cmenos", str(RENTA_M2): "renta_m2"}
    filas = {}
    for r in d["results"]:
        filas.setdefault(r["entity_code"], {})[nombres[r["indicator_id"]]] = r["value"]
    return pd.DataFrame(filas).T


cp = perfil("ZIPCODE")
municipio = perfil("MUNICIPALITY")

red = red.merge(cp.add_suffix("_cp"), left_on="zipcode", right_index=True, how="left")
red = red.merge(municipio.add_suffix("_mun"), left_on="code", right_index=True, how="left")
```

| Sucursal     | CP    | Población del CP | Hogares C y C- del CP | Población del municipio | Hogares C y C- del municipio |
| ------------ | ----- | ---------------- | --------------------- | ----------------------- | ---------------------------- |
| Centro       | 90110 | 21,563           | 31.1%                 | 99,896                  | 32.0%                        |
| Reforma      | 90114 | 13,266           | 35.8%                 | 99,896                  | 32.0%                        |
| Plaza Norte  | 90100 | 11,171           | 30.3%                 | 99,896                  | 32.0%                        |
| Xicohténcatl | 90508 | 15,364           | 26.7%                 | 98,764                  | 20.9%                        |
| La Alameda   | 90530 | 3,369            | 11.1%                 | 98,764                  | 20.9%                        |
| Los Pinos    | 90970 | 16,078           | 26.7%                 | 82,688                  | 22.4%                        |
| El Carmen    | 90890 | 11,196           | 6.4%                  | 82,688                  | 22.4%                        |

Si queremos la estimación de los indicadores a un radio de 5 km alrededor de cada sucursal, podemos pedir las [estadísticas por polígono](/es/atlas/estadisticas/estadisticas-por-poligono).

```python theme={null}
from shapely.geometry import Point

# Función para hacer un círculo de 5KM alrededor de una coordenada y luego pedir los datos.
def contexto_5km(lat, lon):
    circulo = Point(lon, lat).buffer(5 / 111.32, quad_segs=16)
    d = call("/polygon-context/", {
        "polygon": json.loads(json.dumps(circulo.__geo_interface__)),
        "indicators": [POBLACION, HOGARES_C_CMENOS, RENTA_M2],
    })["data"]
    return {k: (d.get(i, {}).get("estimate")) for k, i in
            [("poblacion_5km", str(POBLACION)), ("hogares_c_cmenos_5km", str(HOGARES_C_CMENOS)), ("renta_5km", str(RENTA_M2))]}


red = red.join(pd.DataFrame([contexto_5km(r.latitude, r.longitude) for r in red.itertuples()]))
red[["sucursal", "municipio", "poblacion_5km", "hogares_c_cmenos_5km", "renta_5km", "ventas_mensuales"]]
```

| Sucursal      | Municipio               | Población a 5 km | Hogares C y C- | Renta por m² | Ventas mensuales |
| ------------- | ----------------------- | ---------------- | -------------- | ------------ | ---------------- |
| Centro        | Tlaxcala                | 115,223          | 30.5%          | \$80.3       | 1,361,900        |
| Reforma       | Tlaxcala                | 193,211          | 31.0%          | \$80.0       | 1,442,800        |
| Plaza Norte   | Tlaxcala                | 167,506          | 30.6%          | \$76.2       | 1,310,700        |
| Xicohténcatl  | Huamantla               | 48,888           | 28.0%          | sin dato     | 1,059,700        |
| La Alameda    | Huamantla               | 76,235           | 23.9%          | sin dato     | 639,500          |
| Los Pinos     | San Pablo del Monte     | 278,657          | 28.0%          | \$116.7      | 1,075,500        |
| El Carmen     | San Pablo del Monte     | 28,409           | 10.0%          | sin dato     | 953,200          |
| La Loma       | Apizaco                 | 141,019          | 31.6%          | \$70.6       | 995,500          |
| Independencia | Apizaco                 | 139,596          | 31.7%          | \$67.2       | 1,209,500        |
| San Miguel    | Chiautempan             | 101,496          | 26.1%          | \$108.4      | 796,400          |
| Las Ánimas    | Chiautempan             | 186,311          | 29.9%          | \$75.4       | 1,274,700        |
| Del Valle     | Zacatelco               | 107,924          | 29.3%          | sin dato     | 1,361,600        |
| Guadalupe     | Calpulalpan             | 45,182           | 27.1%          | sin dato     | 1,070,300        |
| La Ribera     | Yauhquemehcan           | 92,211           | 31.6%          | \$65.1       | 686,500          |
| Santa Cruz    | Contla de Juan Cuamatzi | 151,307          | 29.0%          | \$77.1       | 1,262,100        |

<Note>
  Los 382 códigos postales del estado tienen población y hogares C y C-, pero el precio de renta solo existe en 18, así que aparece en 10 de las 15 sucursales.
</Note>

## 5. Densidad de la competencia

Para cada sucursal contamos las farmacias en un radio de 5 km con [listado por entidad](/es/atlas/lugares/listado-por-entidad).

En este caso el back de Atlas calcula el círculo con el radio de 5 kilómetros.

```python theme={null}
competencia = []
for r in red.itertuples():
    resp = call("/places/detail/", {
        "geo_filter": {"latitude": r.latitude, "longitude": r.longitude, "radius_km": 5},
        "filters": {"category_ids": [CATEGORIA]},
        "page_size": 1,
    })
    competencia.append(resp["count"])

red["farmacias_5km"] = competencia
```

<img className="block dark:hidden" src="https://mintcdn.com/tukan/-Qgqo0R3ne6Arh2T/images/atlas/ejemplos/competencia-por-plaza-claro.png?fit=max&auto=format&n=-Qgqo0R3ne6Arh2T&q=85&s=0a6d364b261649ab12a1638cc50ed1fa" alt="Farmacias competidoras en un radio de 5 kilómetros por sucursal" width="1580" height="1099" data-path="images/atlas/ejemplos/competencia-por-plaza-claro.png" />

<img className="hidden dark:block" src="https://mintcdn.com/tukan/-Qgqo0R3ne6Arh2T/images/atlas/ejemplos/competencia-por-plaza-oscuro.png?fit=max&auto=format&n=-Qgqo0R3ne6Arh2T&q=85&s=a757ce2cf93c54920c276989fd46c4bd" alt="Farmacias competidoras en un radio de 5 kilómetros por sucursal" width="1580" height="1099" data-path="images/atlas/ejemplos/competencia-por-plaza-oscuro.png" />

<Warning>
  En Atlas estamos curando la lista de puntos de interés más confiable de México, lo que quiere decir que la incorporación de marcas al servicio va de forma paulatina. Puedes consultar las marcas disponibles en el [catálogo de marcas](/es/atlas/catalogos/marcas).
</Warning>

## 6. Quién compite y a qué distancia

Podemos explorar a más detalle las marcas de farmacias de los municipios donde opera la red, esta vez en modo polígono, pasando los códigos de municipio en lugar de un radio.

```python theme={null}
codes = sorted(red.code.unique())

competidores, page = [], 1
while True:
    d = call("/places/detail/", {
        "geo_filter": {"entity_type": "MUNICIPALITY", "entity_codes": codes},
        "filters": {"category_ids": [CATEGORIA]},
        "page": page, "page_size": 500,
    })
    competidores += d["results"]
    if len(competidores) >= d["count"]:
        break
    page += 1

comp = pd.DataFrame([{"marca": c["brand_name"],
                      "lat": c["geography"]["latitude"],
                      "lon": c["geography"]["longitude"]} for c in competidores])

comp.marca.value_counts()
```

| Marca                 | Farmacias |
| --------------------- | --------- |
| Farmacias Similares   | 54        |
| Farmacias del Ahorro  | 19        |
| Farmacias Guadalajara | 12        |
| Farmacia San Pablo    | 1         |
| Farmacias Benavides   | 1         |

Con las coordenadas de cada competidor calculamos a qué distancia está el rival más cercano de cada sucursal.

```python theme={null}
# shapely mide en las unidades de las coordenadas (grados), no en kilómetros,
# así que para distancias reales usamos la fórmula de haversine
def km(lat1, lon1, lat2, lon2):
    R = 6371.0
    p1, p2 = np.radians(lat1), np.radians(lat2)
    dp, dl = np.radians(lat2 - lat1), np.radians(lon2 - lon1)
    a = np.sin(dp / 2) ** 2 + np.cos(p1) * np.cos(p2) * np.sin(dl / 2) ** 2
    return 2 * R * np.arcsin(np.sqrt(a))


cercanos = []
for r in red.itertuples():
    d = km(r.latitude, r.longitude, comp.lat.values, comp.lon.values)
    i = int(np.argmin(d))
    cercanos.append({"sucursal": r.sucursal, "marca_rival": comp.marca.values[i],
                     "distancia_km": round(float(d[i]), 2)})

pd.DataFrame(cercanos).sort_values("distancia_km")
```

| Sucursal     | Municipio               | Rival más cercano     | Distancia |
| ------------ | ----------------------- | --------------------- | --------- |
| Plaza Norte  | Tlaxcala                | Farmacias Similares   | 0.34 km   |
| Las Ánimas   | Chiautempan             | Farmacias Similares   | 0.36 km   |
| Del Valle    | Zacatelco               | Farmacias del Ahorro  | 0.44 km   |
| Santa Cruz   | Contla de Juan Cuamatzi | Farmacias Similares   | 0.58 km   |
| Reforma      | Tlaxcala                | Farmacias Similares   | 0.81 km   |
| La Loma      | Apizaco                 | Farmacias Similares   | 1.03 km   |
| Xicohténcatl | Huamantla               | Farmacias Guadalajara | 3.82 km   |
| San Miguel   | Chiautempan             | Farmacias Similares   | 4.41 km   |
| El Carmen    | San Pablo del Monte     | Farmacias Similares   | 5.93 km   |

## 7. El área de servicio completa

La unión de los 15 radios de 5 km forma el área de servicio de la red. `unary_union` combina los círculos en un `MultiPolygon` que se manda tal cual a [estadísticas por polígono](/es/atlas/estadisticas/estadisticas-por-poligono).

```python theme={null}
from shapely.ops import unary_union

circulos = [Point(r.longitude, r.latitude).buffer(5 / 111.32, quad_segs=16)
            for r in red.itertuples()]
cobertura = unary_union(circulos)

contexto = call("/polygon-context/", {
    "polygon": json.loads(json.dumps(cobertura.__geo_interface__)),
    "indicators": [POBLACION, HOGARES_C_CMENOS, RENTA_M2],
})
```

| Indicador      | Dentro del área | Municipios que toca | Entidades que toca | Nacional    |
| -------------- | --------------- | ------------------- | ------------------ | ----------- |
| Población      | 1,027,957       | 2,750,262           | 11,009,096         | 126,014,024 |
| Hogares C y C- | 28.1%           | 29.9%               | 23.3%              | 28.0%       |
| Renta por m²   | \$79.7          | \$133.5             | \$131.0            | \$228.1     |

<Note>
  El área puede cruzar límites administrativos sin problema. Atlas resuelve los códigos postales, municipios y entidades que toque el polígono, sin importar cuántos sean.
</Note>

## ¿Cómo usarlo con tus propios datos?

Sustituye el contenido de `TEST_DATA` por tu propia cartera de puntos y explora las estadísticas alrededor de tu red de puntos de venta.
