Potential connectivity surface for medium-sized mammals in the Argentine Chaco

1. Project overview
This project models potential functional connectivity for medium-sized to large forest-associated mammals in the Argentine Chaco. The workflow focuses on the area between Copo National Park and the northern forest remnants toward the El Impenetrable region.
The goal was to build a reproducible GIS workflow using open-access spatial data and Python, then visualize the final outputs in QGIS.
Conservation object
Medium-sized to large forest-associated mammals of the Argentine Chaco.
Representative species may include:
- Puma concolor
- Pecari tajacu
- Tayassu pecari
- Mazama gouazoubira
- Leopardus geoffroyi
- Cerdocyon thous
Study area
Copo – El Impenetrable, Argentine Dry Chaco
Connectivity type
Potential functional connectivity
Methods
- Resistance surface
- Least-cost path
- Cost-distance connectivity surface
- Pinch point identification
- Conservation and restoration priority mapping
Interpretation
The outputs represent potential corridors, not confirmed animal movement routes. No GPS, genetic or telemetry data were used. Therefore, the results should be interpreted as a spatial screening product for conservation planning.
2. Data sources
- MapBiomas Argentina 2024 for land-cover data.
- SIB/APN for protected area information.
- QGIS for visualization and final cartographic layout.
- Python for reproducible geospatial processing.
- Optional supporting datasets: OpenStreetMap, GBIF, SRTM or Copernicus DEM.
3. Project folder structure
chaco_connectivity/
│
├── data/
│ ├── raw/
│ ├── processed/
│ └── external/
│
├── outputs/
│ ├── maps/
│ ├── figures/
│ └── tables/
│
├── scripts/
│ ├── 01_create_aoi.py
│ ├── 02_clip_landcover_to_aoi.py
│ ├── 03_create_core_points.py
│ ├── 04_inspect_landcover_classes.py
│ ├── 05_create_resistance_surface.py
│ ├── 06_least_cost_path.py
│ ├── 07_create_lcp_corridor_buffers.py
│ ├── 08_corridor_landcover_composition.py
│ ├── 09_prepare_circuit_inputs.py
│ ├── 10_create_cost_distance_corridor.py
│ ├── 11_identify_pinch_points.py
│ ├── 12_pinch_points_landcover_composition.py
│ ├── 13_create_priority_areas.py
│ └── 13b_refine_restoration_areas.py
│
└── README.md
4. Workflow
Step 1 — Create the area of interest
The first step was to create a rectangular pilot AOI around the Copo–El Impenetrable region.
Output:
data/processed/aoi_copo_el_impenetrable.gpkg
import geopandas as gpd
from shapely.geometry import box
from pathlib import Path
project_dir = Path(__file__).resolve().parents[1]
processed_dir = project_dir / "data" / "processed"
processed_dir.mkdir(parents=True, exist_ok=True)
xmin = -62.40
xmax = -60.50
ymin = -26.30
ymax = -24.50
aoi_geom = box(xmin, ymin, xmax, ymax)
aoi = gpd.GeoDataFrame(
{
"name": ["Copo_El_Impenetrable_AOI"],
"description": ["Pilot AOI for Gran Chaco connectivity analysis"],
},
geometry=[aoi_geom],
crs="EPSG:4326"
)
output_path = processed_dir / "aoi_copo_el_impenetrable.gpkg"
aoi.to_file(output_path, layer="aoi", driver="GPKG")
print(f"AOI saved to: {output_path}")
Step 2 — Clip MapBiomas land-cover data to the AOI
The MapBiomas Argentina 2024 land-cover raster was clipped to the study area.
Input:
data/raw/argentina_coverage_2024.tif
Output:
data/processed/landcover_2024_aoi.tif
from pathlib import Path
import geopandas as gpd
import rasterio
from rasterio.mask import mask
project_dir = Path(__file__).resolve().parents[1]
raw_dir = project_dir / "data" / "raw"
processed_dir = project_dir / "data" / "processed"
processed_dir.mkdir(parents=True, exist_ok=True)
aoi_path = processed_dir / "aoi_copo_el_impenetrable.gpkg"
landcover_path = raw_dir / "argentina_coverage_2024.tif"
output_path = processed_dir / "landcover_2024_aoi.tif"
aoi = gpd.read_file(aoi_path, layer="aoi")
with rasterio.open(landcover_path) as src:
if aoi.crs != src.crs:
aoi = aoi.to_crs(src.crs)
geometries = [geom for geom in aoi.geometry]
clipped_array, clipped_transform = mask(
src,
geometries,
crop=True,
nodata=src.nodata
)
clipped_meta = src.meta.copy()
clipped_meta.update(
{
"height": clipped_array.shape[1],
"width": clipped_array.shape[2],
"transform": clipped_transform,
"driver": "GTiff",
"compress": "lzw"
}
)
with rasterio.open(output_path, "w", **clipped_meta) as dst:
dst.write(clipped_array)
print(f"Output saved to: {output_path}")
Step 3 — Create initial core points
Two initial core points were created to represent the starting and ending areas for connectivity modelling.
Output:
data/processed/core_points.gpkg
from pathlib import Path
import geopandas as gpd
from shapely.geometry import Point
project_dir = Path(__file__).resolve().parents[1]
processed_dir = project_dir / "data" / "processed"
processed_dir.mkdir(parents=True, exist_ok=True)
output_path = processed_dir / "core_points.gpkg"
cores = gpd.GeoDataFrame(
{
"core_id": [1, 2],
"name": ["Parque Nacional Copo", "Parque Nacional El Impenetrable"],
"type": ["protected_area", "protected_area"],
},
geometry=[
Point(-61.88005, -25.82089),
Point(-61.10564, -25.00468),
],
crs="EPSG:4326",
)
cores.to_file(output_path, layer="core_points", driver="GPKG")
print(f"Output saved to: {output_path}")
Step 4 — Inspect land-cover classes
Before assigning resistance values, all MapBiomas classes present inside the AOI were inspected.
Output:
outputs/tables/landcover_classes_2024_aoi.csv
Observed classes included:
| Class | Pixel count |
|---|---|
| 3 | 36,153,632 |
| 4 | 1,582,176 |
| 6 | 85,029 |
| 11 | 409,310 |
| 12 | 1,019,975 |
| 15 | 1,660,828 |
| 19 | 5,342,022 |
| 24 | 59,439 |
| 25 | 146,088 |
| 33 | 89,075 |
| 63 | 71,527 |
| 66 | 356,388 |
| 77 | 132,242 |
from pathlib import Path
import numpy as np
import rasterio
project_dir = Path(__file__).resolve().parents[1]
processed_dir = project_dir / "data" / "processed"
output_dir = project_dir / "outputs" / "tables"
output_dir.mkdir(parents=True, exist_ok=True)
landcover_path = processed_dir / "landcover_2024_aoi.tif"
output_csv = output_dir / "landcover_classes_2024_aoi.csv"
with rasterio.open(landcover_path) as src:
landcover = src.read(1)
nodata = src.nodata
if nodata is not None:
valid_pixels = landcover[landcover != nodata]
else:
valid_pixels = landcover[~np.isnan(landcover)]
classes, counts = np.unique(valid_pixels, return_counts=True)
with open(output_csv, "w", encoding="utf-8") as f:
f.write("class_value,pixel_count\n")
for class_value, pixel_count in zip(classes, counts):
f.write(f"{int(class_value)},{int(pixel_count)}\n")
print(f"Saved table to: {output_csv}")
Step 5 — Create the resistance surface
The land-cover raster was reclassified into movement resistance values. Lower values indicate easier movement, while higher values indicate more difficult movement.
Output:
data/processed/resistance_surface_2024_aoi.tif
| Land-cover class | Interpretation | Resistance |
|---|---|---|
| 3 | Native forest / forest formation | 1 |
| 4 | Savanna / open woody vegetation | 5 |
| 6 | Flooded forest / woody wetland | 5 |
| 11 | Wetland | 15 |
| 12 | Grassland / herbaceous vegetation | 20 |
| 15 | Pasture / modified grassland | 45 |
| 19 | Agriculture | 70 |
| 24 | Urban area | 100 |
| 25 | Bare soil / non-vegetated area | 80 |
| 33 | Water | 60 |
| 63 | Agricultural class | 70 |
| 66 | Agricultural class | 70 |
| 77 | Other / uncertain class | 60 |
from pathlib import Path
import numpy as np
import rasterio
project_dir = Path(__file__).resolve().parents[1]
processed_dir = project_dir / "data" / "processed"
landcover_path = processed_dir / "landcover_2024_aoi.tif"
resistance_path = processed_dir / "resistance_surface_2024_aoi.tif"
resistance_lookup = {
3: 1,
4: 5,
6: 5,
11: 15,
12: 20,
15: 45,
19: 70,
24: 100,
25: 80,
33: 60,
63: 70,
66: 70,
77: 60,
}
with rasterio.open(landcover_path) as src:
landcover = src.read(1)
profile = src.profile.copy()
nodata = src.nodata
resistance = np.full(
landcover.shape,
255,
dtype=np.uint8
)
for lc_class, resistance_value in resistance_lookup.items():
resistance[landcover == lc_class] = resistance_value
if nodata is not None:
resistance[landcover == nodata] = 255
profile.update(
dtype=rasterio.uint8,
count=1,
nodata=255,
compress="lzw"
)
with rasterio.open(resistance_path, "w", **profile) as dst:
dst.write(resistance, 1)
print(f"Output saved to: {resistance_path}")
Step 6 — Generate the least-cost path
The least-cost path was calculated between the two core points using the resistance surface.
Output:
outputs/maps/least_cost_path_copo_el_impenetrable.gpkg
from pathlib import Path
import geopandas as gpd
import numpy as np
import rasterio
from rasterio.transform import rowcol, xy
from shapely.geometry import LineString
from skimage.graph import route_through_array
project_dir = Path(__file__).resolve().parents[1]
processed_dir = project_dir / "data" / "processed"
outputs_dir = project_dir / "outputs" / "maps"
outputs_dir.mkdir(parents=True, exist_ok=True)
resistance_path = processed_dir / "resistance_surface_2024_aoi.tif"
core_points_path = processed_dir / "core_points.gpkg"
output_path = outputs_dir / "least_cost_path_copo_el_impenetrable.gpkg"
with rasterio.open(resistance_path) as src:
resistance = src.read(1).astype(np.float32)
transform = src.transform
raster_crs = src.crs
nodata = src.nodata
if nodata is not None:
resistance[resistance == nodata] = 9999
resistance[resistance <= 0] = 9999
cores = gpd.read_file(core_points_path, layer="core_points")
cores = cores.to_crs(raster_crs)
start_point = cores.iloc[0].geometry
end_point = cores.iloc[1].geometry
start_rc = rowcol(transform, start_point.x, start_point.y)
end_rc = rowcol(transform, end_point.x, end_point.y)
indices, total_cost = route_through_array(
resistance,
start_rc,
end_rc,
fully_connected=True,
geometric=True
)
coords = []
for row, col in indices:
x, y = xy(transform, row, col, offset="center")
coords.append((x, y))
line = LineString(coords)
lcp = gpd.GeoDataFrame(
{
"path_id": [1],
"from_core": [cores.iloc[0]["name"]],
"to_core": [cores.iloc[1]["name"]],
"total_cost": [float(total_cost)],
},
geometry=[line],
crs=raster_crs
)
lcp.to_file(output_path, layer="least_cost_path", driver="GPKG")
print(f"Output saved to: {output_path}")
Step 7 — Create least-cost corridor buffers
Buffers were generated around the least-cost path to represent potential corridor zones at different management scales.
Output:
outputs/maps/least_cost_corridor_buffers.gpkg
Layers:
corridor_1km
corridor_2_5km
corridor_5km
from pathlib import Path
import geopandas as gpd
project_dir = Path(__file__).resolve().parents[1]
outputs_maps_dir = project_dir / "outputs" / "maps"
outputs_maps_dir.mkdir(parents=True, exist_ok=True)
lcp_path = outputs_maps_dir / "least_cost_path_copo_el_impenetrable.gpkg"
output_path = outputs_maps_dir / "least_cost_corridor_buffers.gpkg"
lcp = gpd.read_file(lcp_path, layer="least_cost_path")
lcp_metric = lcp.to_crs("EPSG:32720")
buffer_distances = {
"corridor_1km": 1000,
"corridor_2_5km": 2500,
"corridor_5km": 5000,
}
for layer_name, distance_m in buffer_distances.items():
corridor = lcp_metric.copy()
corridor["buffer_m"] = distance_m
corridor["buffer_km"] = distance_m / 1000
corridor["geometry"] = corridor.geometry.buffer(distance_m)
corridor_wgs84 = corridor.to_crs("EPSG:4326")
corridor_wgs84.to_file(
output_path,
layer=layer_name,
driver="GPKG"
)
print(f"Saved {layer_name} to {output_path}")
Step 8 — Calculate land-cover composition inside corridor buffers
Land-cover composition was calculated for each corridor buffer.
Output:
outputs/tables/corridor_landcover_composition.csv
from pathlib import Path
import geopandas as gpd
import pandas as pd
import rasterio
from rasterio.mask import mask
import numpy as np
project_dir = Path(__file__).resolve().parents[1]
processed_dir = project_dir / "data" / "processed"
outputs_maps_dir = project_dir / "outputs" / "maps"
outputs_tables_dir = project_dir / "outputs" / "tables"
outputs_tables_dir.mkdir(parents=True, exist_ok=True)
landcover_path = processed_dir / "landcover_2024_aoi.tif"
corridors_path = outputs_maps_dir / "least_cost_corridor_buffers.gpkg"
output_csv = outputs_tables_dir / "corridor_landcover_composition.csv"
class_labels = {
3: "Forest formation",
4: "Savanna / open woody vegetation",
6: "Flooded forest / woody wetland",
11: "Wetland",
12: "Grassland / herbaceous vegetation",
15: "Pasture / modified grassland",
19: "Agriculture",
24: "Urban area",
25: "Bare soil / non-vegetated area",
33: "Water",
63: "Agriculture class 63",
66: "Agriculture class 66",
77: "Other / uncertain class",
}
corridor_layers = [
"corridor_1km",
"corridor_2_5km",
"corridor_5km",
]
results = []
with rasterio.open(landcover_path) as src:
raster_crs = src.crs
nodata = src.nodata
for layer_name in corridor_layers:
corridor = gpd.read_file(corridors_path, layer=layer_name)
corridor = corridor.to_crs(raster_crs)
geometries = [geom for geom in corridor.geometry]
clipped_array, clipped_transform = mask(
src,
geometries,
crop=True,
nodata=nodata
)
data = clipped_array[0]
if nodata is not None:
valid = data[data != nodata]
else:
valid = data[~np.isnan(data)]
classes, counts = np.unique(valid, return_counts=True)
total_pixels = counts.sum()
for class_value, pixel_count in zip(classes, counts):
class_value = int(class_value)
pixel_count = int(pixel_count)
percentage = (pixel_count / total_pixels) * 100
results.append(
{
"corridor": layer_name,
"class_value": class_value,
"class_label": class_labels.get(class_value, "Unknown"),
"pixel_count": pixel_count,
"percentage": round(percentage, 2),
}
)
df = pd.DataFrame(results)
df.to_csv(output_csv, index=False)
print(f"Output saved to: {output_csv}")
Step 9 — Prepare a 300 m UTM resistance raster
The resistance raster was reprojected and resampled to 300 m resolution in UTM Zone 20S. This reduced computational load and prepared the data for broader connectivity surfaces.
Outputs:
data/processed/resistance_surface_2024_aoi_300m_utm.tif
data/processed/core_nodes_300m_utm.tif
from pathlib import Path
import geopandas as gpd
import numpy as np
import rasterio
from rasterio.enums import Resampling
from rasterio.warp import calculate_default_transform, reproject
from rasterio.features import rasterize
project_dir = Path(__file__).resolve().parents[1]
processed_dir = project_dir / "data" / "processed"
processed_dir.mkdir(parents=True, exist_ok=True)
resistance_path = processed_dir / "resistance_surface_2024_aoi.tif"
core_points_path = processed_dir / "core_points.gpkg"
resistance_utm_path = processed_dir / "resistance_surface_2024_aoi_300m_utm.tif"
core_nodes_path = processed_dir / "core_nodes_300m_utm.tif"
target_crs = "EPSG:32720"
target_resolution = 300
with rasterio.open(resistance_path) as src:
transform, width, height = calculate_default_transform(
src.crs,
target_crs,
src.width,
src.height,
*src.bounds,
resolution=target_resolution
)
profile = src.profile.copy()
profile.update(
{
"crs": target_crs,
"transform": transform,
"width": width,
"height": height,
"dtype": rasterio.float32,
"nodata": 255,
"compress": "lzw"
}
)
destination = np.empty((height, width), dtype=np.float32)
reproject(
source=rasterio.band(src, 1),
destination=destination,
src_transform=src.transform,
src_crs=src.crs,
dst_transform=transform,
dst_crs=target_crs,
resampling=Resampling.average
)
destination[destination <= 0] = 255
destination[np.isnan(destination)] = 255
with rasterio.open(resistance_utm_path, "w", **profile) as dst:
dst.write(destination, 1)
with rasterio.open(resistance_utm_path) as ref:
ref_profile = ref.profile.copy()
ref_transform = ref.transform
ref_shape = (ref.height, ref.width)
ref_crs = ref.crs
cores = gpd.read_file(core_points_path, layer="core_points")
cores = cores.to_crs(ref_crs)
cores["geometry"] = cores.geometry.buffer(900)
shapes = [
(geom, int(core_id))
for geom, core_id in zip(cores.geometry, cores["core_id"])
]
core_raster = rasterize(
shapes=shapes,
out_shape=ref_shape,
transform=ref_transform,
fill=0,
dtype=np.int16
)
core_profile = ref_profile.copy()
core_profile.update(
{
"dtype": rasterio.int16,
"nodata": 0,
"compress": "lzw"
}
)
with rasterio.open(core_nodes_path, "w", **core_profile) as dst:
dst.write(core_raster, 1)
Step 10 — Create a cost-distance connectivity surface
A normalized connectivity surface was created by summing accumulated movement costs from both core areas and converting the result into a 0–1 connectivity index.
Output:
outputs/maps/cost_distance_corridor_300m.tif
Interpretation:
- 1 = higher potential connectivity
- 0 = lower potential connectivity
from pathlib import Path
import geopandas as gpd
import numpy as np
import rasterio
from rasterio.transform import rowcol
from skimage.graph import MCP_Geometric
project_dir = Path(__file__).resolve().parents[1]
processed_dir = project_dir / "data" / "processed"
outputs_maps_dir = project_dir / "outputs" / "maps"
outputs_maps_dir.mkdir(parents=True, exist_ok=True)
resistance_path = processed_dir / "resistance_surface_2024_aoi_300m_utm.tif"
core_points_path = processed_dir / "core_points.gpkg"
output_cost_corridor = outputs_maps_dir / "cost_distance_corridor_300m.tif"
with rasterio.open(resistance_path) as src:
resistance = src.read(1).astype(np.float32)
profile = src.profile.copy()
transform = src.transform
raster_crs = src.crs
nodata = src.nodata
if nodata is not None:
resistance[resistance == nodata] = 9999
resistance[np.isnan(resistance)] = 9999
resistance[resistance <= 0] = 9999
cores = gpd.read_file(core_points_path, layer="core_points")
cores = cores.to_crs(raster_crs)
start_point = cores.iloc[0].geometry
end_point = cores.iloc[1].geometry
start_rc = rowcol(transform, start_point.x, start_point.y)
end_rc = rowcol(transform, end_point.x, end_point.y)
mcp = MCP_Geometric(resistance)
cost_from_start, _ = mcp.find_costs([start_rc])
cost_from_end, _ = mcp.find_costs([end_rc])
cumulative_cost = cost_from_start + cost_from_end
cumulative_cost[np.isinf(cumulative_cost)] = np.nan
valid = ~np.isnan(cumulative_cost)
min_cost = np.nanmin(cumulative_cost)
max_cost = np.nanmax(cumulative_cost)
normalized_cost = np.full(cumulative_cost.shape, np.nan, dtype=np.float32)
normalized_cost[valid] = (cumulative_cost[valid] - min_cost) / (max_cost - min_cost)
connectivity_index = 1 - normalized_cost
connectivity_index[np.isnan(connectivity_index)] = -9999
profile.update(
dtype=rasterio.float32,
nodata=-9999,
compress="lzw"
)
with rasterio.open(output_cost_corridor, "w", **profile) as dst:
dst.write(connectivity_index.astype(np.float32), 1)
print(f"Output saved to: {output_cost_corridor}")
Step 11 — Identify pinch points
The top 5% of the connectivity surface was extracted as potential pinch points.
Outputs:
outputs/maps/pinch_points_top5_300m.tif
outputs/maps/pinch_points_top5_300m.gpkg
from pathlib import Path
import numpy as np
import rasterio
from rasterio.features import shapes
import geopandas as gpd
from shapely.geometry import shape
project_dir = Path(__file__).resolve().parents[1]
outputs_maps_dir = project_dir / "outputs" / "maps"
outputs_maps_dir.mkdir(parents=True, exist_ok=True)
connectivity_path = outputs_maps_dir / "cost_distance_corridor_300m.tif"
pinch_raster_path = outputs_maps_dir / "pinch_points_top5_300m.tif"
pinch_vector_path = outputs_maps_dir / "pinch_points_top5_300m.gpkg"
with rasterio.open(connectivity_path) as src:
connectivity = src.read(1).astype(np.float32)
profile = src.profile.copy()
transform = src.transform
crs = src.crs
nodata = src.nodata
if nodata is not None:
valid = connectivity != nodata
else:
valid = ~np.isnan(connectivity)
valid_values = connectivity[valid]
threshold = np.percentile(valid_values, 95)
pinch = np.full(connectivity.shape, 255, dtype=np.uint8)
pinch[valid] = 0
pinch[(connectivity >= threshold) & valid] = 1
profile.update(
dtype=rasterio.uint8,
nodata=255,
compress="lzw"
)
with rasterio.open(pinch_raster_path, "w", **profile) as dst:
dst.write(pinch, 1)
mask = pinch == 1
features = []
for geom, value in shapes(pinch, mask=mask, transform=transform):
if value == 1:
features.append(
{
"geometry": shape(geom),
"value": int(value),
"threshold": float(threshold),
"percentile": 95,
}
)
pinch_gdf = gpd.GeoDataFrame(features, crs=crs)
pinch_gdf["area_m2"] = pinch_gdf.geometry.area
pinch_gdf["area_ha"] = pinch_gdf["area_m2"] / 10000
pinch_gdf.to_file(
pinch_vector_path,
layer="pinch_points_top5",
driver="GPKG"
)
Step 12 — Calculate land-cover composition inside pinch points
The land-cover composition of the high-connectivity pinch points was calculated.
Output:
outputs/tables/pinch_points_landcover_composition.csv
Main result:
| Class | Percentage |
|---|---|
| Forest formation | 93.75% |
| Agriculture | 0.10% |
| Pasture / modified grassland | 0.62% |
| Savanna / open woody vegetation | 0.30% |
| Wetland | 2.26% |
| Urban area | 0.00% |
Interpretation:
The highest-connectivity pinch points were overwhelmingly located within native forest formation areas. This suggests that conservation of remaining native forest may be more important than large-scale restoration within the main modelled corridor.
Step 13 — Create conservation and restoration priority areas
High-connectivity areas were classified as conservation priorities because they were mostly associated with native forest. A broader restoration search zone was also created around them.
Outputs:
outputs/maps/priority_conservation_areas.gpkg
outputs/maps/priority_restoration_areas.gpkg
from pathlib import Path
import geopandas as gpd
import rasterio
from rasterio.features import shapes
import numpy as np
from shapely.geometry import shape
project_dir = Path(__file__).resolve().parents[1]
outputs_maps_dir = project_dir / "outputs" / "maps"
outputs_maps_dir.mkdir(parents=True, exist_ok=True)
connectivity_path = outputs_maps_dir / "cost_distance_corridor_300m.tif"
conservation_output = outputs_maps_dir / "priority_conservation_areas.gpkg"
restoration_output = outputs_maps_dir / "priority_restoration_areas.gpkg"
with rasterio.open(connectivity_path) as conn_src:
connectivity = conn_src.read(1).astype(np.float32)
conn_transform = conn_src.transform
conn_crs = conn_src.crs
conn_nodata = conn_src.nodata
if conn_nodata is not None:
valid = connectivity != conn_nodata
else:
valid = ~np.isnan(connectivity)
valid_values = connectivity[valid]
high_threshold = np.percentile(valid_values, 95)
high_connectivity = np.full(connectivity.shape, 255, dtype=np.uint8)
high_connectivity[valid] = 0
high_connectivity[(connectivity >= high_threshold) & valid] = 1
features = []
for geom, value in shapes(
high_connectivity,
mask=high_connectivity == 1,
transform=conn_transform
):
if value == 1:
features.append(
{
"geometry": shape(geom),
"connectivity_class": "high_connectivity_top5",
"threshold": float(high_threshold),
}
)
priority_gdf = gpd.GeoDataFrame(features, crs=conn_crs)
priority_gdf["area_m2"] = priority_gdf.geometry.area
priority_gdf["area_ha"] = priority_gdf["area_m2"] / 10000
priority_gdf = priority_gdf[priority_gdf["area_ha"] >= 10].copy()
priority_gdf["priority_type"] = "Conservation priority"
priority_gdf["interpretation"] = (
"High potential connectivity area, mostly associated with native forest according to land-cover composition."
)
priority_gdf.to_file(
conservation_output,
layer="priority_conservation_areas",
driver="GPKG"
)
restoration_gdf = priority_gdf.copy()
restoration_gdf = restoration_gdf.to_crs("EPSG:32720")
restoration_gdf["geometry"] = restoration_gdf.geometry.buffer(3000)
restoration_gdf = restoration_gdf.dissolve()
restoration_gdf["priority_type"] = "Restoration search zone"
restoration_gdf["interpretation"] = (
"Area within 3 km of high-connectivity zones where transformed land-cover patches could be evaluated for restoration."
)
restoration_gdf["area_m2"] = restoration_gdf.geometry.area
restoration_gdf["area_ha"] = restoration_gdf["area_m2"] / 10000
restoration_gdf.to_file(
restoration_output,
layer="priority_restoration_search_zone",
driver="GPKG"
)
Step 14 — Refine restoration areas
Because the restoration search zone overlapped completely with the conservation area, a refined restoration layer was created by extracting only transformed land-cover classes within the restoration search zone.
Output:
outputs/maps/priority_restoration_transformed_patches.gpkg
from pathlib import Path
import geopandas as gpd
import rasterio
from rasterio.mask import mask
from rasterio.features import shapes
import numpy as np
from shapely.geometry import shape
project_dir = Path(__file__).resolve().parents[1]
processed_dir = project_dir / "data" / "processed"
outputs_maps_dir = project_dir / "outputs" / "maps"
outputs_maps_dir.mkdir(parents=True, exist_ok=True)
landcover_path = processed_dir / "landcover_2024_aoi.tif"
restoration_search_zone_path = outputs_maps_dir / "priority_restoration_areas.gpkg"
output_path = outputs_maps_dir / "priority_restoration_transformed_patches.gpkg"
transformed_classes = {
15: "Pasture / modified grassland",
19: "Agriculture",
24: "Urban area",
25: "Bare soil / non-vegetated area",
63: "Agriculture class 63",
66: "Agriculture class 66",
77: "Other / uncertain class",
}
search_zone = gpd.read_file(
restoration_search_zone_path,
layer="priority_restoration_search_zone"
)
features = []
with rasterio.open(landcover_path) as src:
raster_crs = src.crs
nodata = src.nodata
search_zone = search_zone.to_crs(raster_crs)
geometries = [geom for geom in search_zone.geometry]
clipped_array, clipped_transform = mask(
src,
geometries,
crop=True,
nodata=nodata
)
data = clipped_array[0]
transformed_mask = np.isin(data, list(transformed_classes.keys()))
for geom, value in shapes(data, mask=transformed_mask, transform=clipped_transform):
class_value = int(value)
if class_value in transformed_classes:
features.append(
{
"geometry": shape(geom),
"class_value": class_value,
"class_label": transformed_classes[class_value],
}
)
restoration = gpd.GeoDataFrame(features, crs=raster_crs)
restoration = restoration.to_crs("EPSG:32720")
restoration = restoration.dissolve(by="class_label", as_index=False)
restoration["area_m2"] = restoration.geometry.area
restoration["area_ha"] = restoration["area_m2"] / 10000
restoration["area_km2"] = restoration["area_m2"] / 1_000_000
restoration = restoration[restoration["area_ha"] >= 5].copy()
restoration["priority_type"] = "Restoration priority"
restoration["interpretation"] = (
"Transformed land-cover patch located within the restoration search zone around high-connectivity areas."
)
restoration.to_file(
output_path,
layer="priority_restoration_transformed_patches",
driver="GPKG"
)
5. Final map design
The final map was created in QGIS using the following layers:
- Potential connectivity surface
- Least-cost path
- 5 km corridor buffer
- Restoration priority patches
- Restoration expected buffer
- Conservation priority area
- Copo National Park
- Inset map
- Scale bar, north arrow and coordinate grid
The potential connectivity surface was symbolized from low to high values:
0 = lower potential connectivity
1 = higher potential connectivity
6. Main result
The model identified a least-cost connection between Copo National Park and northern forest remnants. The highest potential connectivity areas were mainly located within native forest formation.
The top 5% pinch point areas were composed mostly of forest formation:
Forest formation: 93.75%
Wetland: 2.26%
Pasture / modified grassland: 0.62%
Agriculture: 0.10%
Urban area: 0.00%
This suggests that the main priority in the modelled corridor is the conservation of remaining native forest, while restoration should be targeted toward transformed patches located near or within the high-connectivity search zone.
7. Limitations
This is a spatial modelling exercise based on resistance assumptions. It does not validate animal movement.
Main limitations:
- No telemetry or genetic data were used.
- Core areas were initially represented using approximate points.
- The resistance surface was based on land-cover classes and expert assumptions.
- The connectivity surface is based on cumulative cost-distance, not full circuit theory.
- The model should be interpreted as a planning and screening tool, not as proof of actual movement routes.
8. Portfolio summary
This project demonstrates a reproducible Python and QGIS workflow for modelling potential ecological connectivity in the Argentine Chaco. It integrates land-cover data, protected area information, resistance-based modelling and spatial prioritization to identify potential corridors, pinch points and conservation/restoration opportunities for medium-sized to large forest-associated mammals.
The workflow is relevant for conservation planning, ecological consultancy, protected area connectivity, restoration planning and landscape-scale biodiversity assessment.