Activates when working with Python data pipelines, GCS operations, or medallion architecture (Bronze/Silver/Gold). Use this skill for: running pipelines, debugging data transformations, GCS...
This skill provides guidance for working with Landbruget.dk's data pipelines following the medallion architecture.
This skill activates when:
ALWAYS start with:
cd backend
source venv/bin/activate
Verify environment:
python -c "import geopandas, supabase; print('Environment OK')"
PREFER DuckDB over Pandas:
Process in EPSG:25832, transform to EPSG:4326 only at final Supabase upload.
| EPSG | Name | Use |
|---|---|---|
| 25832 | UTM 32N | Processing (Bronze/Silver/Gold) |
| 4326 | WGS84 | Final storage (Supabase only) |
This eliminates unnecessary transforms:
r2://landbruget-data/bronze/<source>/<date>/_fetch_timestamp, _source, _source_crs# Track source CRS in metadata
_source_crs = detect_crs_from_response(wfs_capabilities) # e.g., "EPSG:25832"
# Only transform sources that aren't already EPSG:25832
if source_crs != "EPSG:25832":
ST_Transform(geometry, source_crs, 'EPSG:25832')
# Area/buffer/distance work directly in EPSG:25832 - no transforms needed!
ST_Area(geometry) / 10000 # hectares (geometry already in meters)
ST_Buffer(geometry, 1000) # 1km buffer (meters work directly)
# Transform ONCE at final Supabase upload
ST_Transform(geometry, 'EPSG:25832', 'EPSG:4326')
import re
def validate_cvr(cvr: str) -> bool:
"""CVR must be 8 digits."""
return bool(re.match(r'^\d{8}$', str(cvr).zfill(8)))
# Format CVR
df['cvr'] = df['cvr'].astype(str).str.zfill(8)
def validate_chr(chr_num: str) -> bool:
"""CHR must be 6 digits."""
return bool(re.match(r'^\d{6}$', str(chr_num)))
import geopandas as gpd
# Danish data comes in EPSG:25832 (UTM zone 32N) - keep it there!
# Only convert to EPSG:4326 at final Supabase upload
gdf_for_supabase = gdf.to_crs('EPSG:4326')
With EPSG:25832, buffer/distance work natively in meters!
-- EPSG:25832 data - buffer works directly in meters ✓
ST_Buffer(geometry, 1000) -- 1km buffer
-- Area calculation works directly in square meters
ST_Area(geometry) / 10000 -- hectares
If working with EPSG:4326 data (avoid when possible):
from common.crs_utils import sql_buffer_meters, sql_intersects_with_buffer_meters
# These helpers transform to UTM internally
buffer_sql = sql_buffer_meters("geometry", 100) # 100 meters
intersect_sql = sql_intersects_with_buffer_meters("a.geom", "b.geom", 1000) # 1km
Bucket: landbruget-data (set via R2_BUCKET or STORAGE_BUCKET env var)
# List datasets in a layer
rclone lsd r2:landbruget-data/silver/
# List snapshots for a dataset
rclone lsd r2:landbruget-data/silver/subsidies/
# List files
rclone ls r2:landbruget-data/silver/subsidies/
from common.storage.core import StorageAccess
storage = StorageAccess()
# Read parquet into DuckDB table
storage.create_table_from_storage_parquet("my_table", "landbruget-data/silver/subsidies/*/data.parquet")
# Write DuckDB table to R2
storage.save_table_to_storage_parquet("my_table", "landbruget-data/gold/output/data.parquet")
import duckdb
from common.storage.filesystem import setup_duckdb_cloud_auth
conn = duckdb.connect()
setup_duckdb_cloud_auth(conn)
# Query parquet directly from R2
result = conn.execute("""
SELECT cvr_number, SUM(area_ha) as total_area
FROM read_parquet('r2://landbruget-data/silver/fields/*/data.parquet')
GROUP BY cvr_number
""").fetchdf()
cd backend
source venv/bin/activate
cd pipelines/<pipeline_name>
python main.py
| Pipeline | Purpose | Frequency |
|---|---|---|
unified_pipeline |
18+ Danish govt sources | Weekly |
chr_pipeline |
Livestock tracking | Weekly |
svineflytning_pipeline |
Pig movements | Weekly |
drive_data_pipeline |
Regulatory compliance | On-demand |
DuckDB is excellent for querying large files without loading into memory:
import duckdb
# Query CSV directly
result = duckdb.query("""
SELECT cvr_number, SUM(area_ha) as total_area
FROM 'large_file.csv'
WHERE date >= '2024-01-01'
GROUP BY cvr_number
""").df()
# Query Parquet files
result = duckdb.query("""
SELECT *
FROM 'data.parquet'
WHERE cvr_number = '12345678'
""").df()
# Join multiple files
result = duckdb.query("""
SELECT a.*, b.name
FROM 'fields.parquet' a
JOIN 'companies.csv' b ON a.cvr_number = b.cvr_number
WHERE a.area_ha > 100
""").df()
# Aggregate on large datasets
result = duckdb.query("""
SELECT
cvr_number,
COUNT(*) as field_count,
SUM(area_ha) as total_area,
AVG(area_ha) as avg_area
FROM 'fields.parquet'
GROUP BY cvr_number
HAVING total_area > 1000
""").df()
DuckDB's spatial extension is not PostGIS. These PostGIS functions do not exist in DuckDB:
| PostGIS Function | DuckDB Alternative |
|---|---|
ST_SRID(geometry) |
Use bounds-based CRS detection: detect_crs_from_bounds() from common/crs_utils.py |
ST_SetSRID(geometry, srid) |
Not needed — DuckDB geometries don't carry SRID metadata |
ST_GeogFromText() |
Use ST_GeomFromText() |
ST_DistanceSphere() |
Transform to UTM first, then use ST_Distance() in meters |
ST_DWithin() (geography) |
Transform to UTM, then ST_Distance(a, b) < threshold_meters |
CRS detection pattern (use instead of ST_SRID):
from common.crs_utils import detect_crs_from_bounds, sql_transform_to_processing_crs, DANISH_UTM
bounds = conn.execute(f"""
SELECT MIN(ST_XMin(geometry)), MAX(ST_XMax(geometry)),
MIN(ST_YMin(geometry)), MAX(ST_YMax(geometry))
FROM {table} WHERE geometry IS NOT NULL
""").fetchone()
detected_crs, _ = detect_crs_from_bounds(*bounds)
if detected_crs == DANISH_UTM:
geom_expr = "geometry" # already UTM, use directly
else:
geom_expr = sql_transform_to_processing_crs("geometry", detected_crs)
Other DuckDB 1.5+ spatial gotchas:
TRY() to handle invalid geometries gracefullydelim parameter, not DELIMITER (breaking change in 1.5)ST_Area_Spheroid uses LON/LAT (x, y) order with geometry_always_xy=true defaultcd backend
source venv/bin/activate
pip install -e .
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json"
ALWAYS use DuckDB for large files - avoid Pandas:
# ✅ CORRECT: Use DuckDB
import duckdb
result = duckdb.query("""
SELECT cvr_number, area_ha
FROM 'large.csv'
WHERE condition
""").df()
# ❌ AVOID: Pandas chunking (slow, complex)
# for chunk in pd.read_csv('large.csv', chunksize=10000):
# process(chunk)
# ❌ AVOID: Pandas column selection (still loads into memory)
# df = pd.read_csv('large.csv', usecols=['cvr_number', 'area_ha'])
Use DuckDB (preferred):
Use Pandas only when:
Use GeoPandas only for:
Before marking pipeline work complete:
pytest tests/