commit eb12a58cb7ab8770491399438869359cd16745f7 Author: alro1965 Date: Mon May 4 23:03:19 2026 -0400 Initial commit — QGIS S-57 Converter diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48f81d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# PyInstaller — carpeta de build intermedia (no necesaria) +build/ + +# Logs +build_log.txt + +# OS +.DS_Store +Thumbs.db diff --git a/CAPAS_REFERENCIA.pdf b/CAPAS_REFERENCIA.pdf new file mode 100644 index 0000000..3157b5f Binary files /dev/null and b/CAPAS_REFERENCIA.pdf differ diff --git a/INSTALL.txt b/INSTALL.txt new file mode 100644 index 0000000..70cf6eb --- /dev/null +++ b/INSTALL.txt @@ -0,0 +1,127 @@ +======================================== +QGIS → S-57 Converter — Installation +======================================== + +REQUIRED: Python 3.9+ +REQUIRED: GDAL with S-57 support + +───────────────────────────────────────── +OPTION A: Windows — Easiest (OSGeo4W) +───────────────────────────────────────── +1. Download OSGeo4W: https://trac.osgeo.org/osgeo4w/ +2. Run installer → Advanced Install → Select: + - gdal + - python3-gdal + - python3-geopandas (or install via pip) +3. Use the "OSGeo4W Shell" to run the converter: + cd "D:\Proyectos Software\QGISS57Converter" + python converter.py myproject.qgs + +───────────────────────────────────────── +OPTION B: Conda / Mamba (Recommended) +───────────────────────────────────────── +conda create -n s57 python=3.11 +conda activate s57 +conda install -c conda-forge gdal geopandas +pip install lxml +cd "D:\Proyectos Software\QGISS57Converter" +python converter.py myproject.qgs + +───────────────────────────────────────── +OPTION C: QGIS Python environment +───────────────────────────────────────── +QGIS already includes GDAL. Run from QGIS Python console: + import sys + sys.path.append(r"D:\Proyectos Software\QGISS57Converter") + from converter import convert + convert("myproject.qgs", None, None, False, True, True) + +───────────────────────────────────────── +INSTALL other dependencies +───────────────────────────────────────── +pip install geopandas lxml pyproj + +───────────────────────────────────────── +VERIFY installation +───────────────────────────────────────── +python -c "from osgeo import gdal; drv = gdal.GetDriverByName('S57'); print('S-57 driver:', drv.GetDescription() if drv else 'NOT AVAILABLE')" + +======================================== +USAGE +======================================== + +# List layers and their S-57 mapping: +python converter.py project.qgs --list + +# Convert: +python converter.py project.qgs + +# Custom output name: +python converter.py project.qgs --output CO1CO01M.000 + +# Custom config: +python converter.py project.qgs --config my_config.json + +# No prompts (convert all mapped layers): +python converter.py project.qgs --force + +======================================== +LAYER NAMING — IMPORTANT +======================================== + +You have THREE options for mapping your QGIS layers to S-57: + +OPTION 1 (easiest): Name your layers directly with S-57 acronyms + - Layer named "COALNE" → coastline + - Layer named "DEPARE" → depth area + - Layer named "SOUNDG" → soundings + See s57_objects.json for all available object classes. + +OPTION 2: Name layers in Spanish or English (auto-detected) + - Layer named "costa" or "coastline" → auto → COALNE + - Layer named "fondos" or "batimetria" → auto → DEPARE + - Layer named "sondas" → auto → SOUNDG + See cell_config.json → "layer_mappings" for full list. + +OPTION 3: Add custom mappings to cell_config.json + "layer_mappings": { + "mi_capa_costa": "COALNE", + "datos_profundidad": "DEPARE" + } + +======================================== +CELL NAMING CONVENTION (IHO S-57) +======================================== +Format: CC1AA##X.000 + CC = 2-letter country code (CO=Colombia, US=USA, etc.) + 1 = Navigational Purpose (1=overview...6=berthing) + AA = 2-letter area code + ## = serial number (01, 02...) + X = compilation scale indicator (M=medium) + +Example: CO3CA01M.000 = Colombia, scale 1:50000, Caribbean area + +Edit "cell_name" in cell_config.json accordingly. + +======================================== +COMMON S-57 OBJECT CLASSES +======================================== +COALNE Coastline (lines) +LNDARE Land Area (polygons) +DEPARE Depth Area (polygons) — needs DRVAL1, DRVAL2 attributes +DEPCNT Depth Contour (lines) — needs VALDCO attribute +SOUNDG Soundings (points) — needs VALSOU (depth value) attribute +LIGHTS Lights (points) +BUOYLAT Lateral Buoy (points) +BCNLAT Lateral Beacon (points) +ACHARE Anchorage Area (polygons) +HRBARE Harbour Area (polygons) +BERTHS Berth (polygons/lines) +OBSTRN Obstruction (any) +WRECKS Wreck (polygons/points) +FAIRWY Fairway (polygons) +RESARE Restricted Area (polygons) +RIVERS River (polygons/lines) +M_COVR Coverage (polygons) — required in valid ENCs + +See s57_objects.json for complete list. diff --git a/MANUAL.html b/MANUAL.html new file mode 100644 index 0000000..ad479c7 --- /dev/null +++ b/MANUAL.html @@ -0,0 +1,695 @@ + + + + + +QGISS57Converter — Manual + + + + + + + + +
+ + +
+

Manual QGISS57Converter

+

Convierte proyectos QGIS (.qgz/.qgs) a cartas naúticas S-57 (.000) válidas para cualquier ECDIS.

+ +
+ ¿Qué hace esta app? + Lee tus capas QGIS con SHP y las convierte a formato S-57 ISO 8211 — el estándar IHO para cartas electrónicas de navegación (ENC). El archivo .000 resultante puede cargarse en cualquier ECDIS que soporte GDAL, incluyendo el AR ECDIS. +
+ +
+ Requisito del sistema + El entorno conda s57 debe estar instalado en D:\Miniconda\envs\s57. La app lo llama automáticamente en el backend. +
+
+ + +
+

Flujo de trabajo

+
    +
  1. +
    + Crear proyecto QGIS +

    En QGIS, crea capas SHP con los nombres de la sección "Objetos puntuales / lineales / de área". Cada capa debe estar en coordenadas WGS84 (EPSG:4326) o el converter la reproyecta automáticamente.

    +
    +
  2. +
  3. +
    + Agregar atributos al SHP +

    Agrega columnas a tu SHP con los nombres de la sección "Atributos". Por ejemplo, para boyas: columnas nombre, catlam, colour, litchr, sigper, alcance.

    +
    +
  4. +
  5. +
    + Guardar como .qgz +

    Proyecto → Guardar como → formato .qgz (archivo comprimido que incluye el .qgs y los SHP embebidos).

    +
    +
  6. +
  7. +
    + Abrir QGISS57Converter +

    Haz clic en Examinar…, selecciona tu .qgz, elige la carpeta de salida, y presiona ▶ Convertir.

    +
    +
  8. +
  9. +
    + Cargar en el ECDIS +

    El archivo .000 generado se puede instalar directamente en AR ECDIS desde el menú Charts → Instalar carta.

    +
    +
  10. +
+
+ + +
+

Objetos puntuales

+

Nombra tu capa QGIS con cualquier texto de la columna "Nombres reconocidos" (sin importar mayúsculas). También puedes usar el acrónimo S-57 directamente.

+ + + + + + + + + + + + + + + + +
Acrónimo S-57DescripciónNombres reconocidos en QGIS
BOYLAT PuntoBoya lateral (babor/estribor)boyas, buoys
BOYCAR PuntoBoya cardinal (N/S/E/W)boycar
BOYISD PuntoBoya de peligro aisladoboyisd
BOYSAW PuntoBoya de aguas segurasboysaw
BCNLAT PuntoBaliza lateralbalizas, beacons
BCNSPP PuntoBaliza especialbcnspp
LIGHTS PuntoLuz / faroluces, lights, faroles
LNDMRK PuntoHito en tierra (torre, tanque…)Puntos del Terreno, landmark
SOUNDG PuntoSonda batimétricasondas, soundings, profundidades
UWTROC PuntoRoca sumergida / a flor de aguarocas, rocks
WRECKS PuntoNaufragionaufragio, wreck
OBSTRN PuntoObstrucciónobstruccion, obstruction
+
+ + +
+

Objetos lineales

+ + + + + + + + + +
Acrónimo S-57DescripciónNombres reconocidos en QGIS
COALNE LíneaLínea de costaLinderos, coastline, costa, linea_de_costa
DEPCNT LíneaCurva batimétrica (isobata)isobata, curvas_nivel, depth_contour
CBLSUB LíneaCable submarinocable
PIPSOL LíneaTubería submarina / en tierratuberia
RIVERS LíneaRío / canalrio, river
+
+ + +
+

Objetos de área

+ + + + + + + + + + + + +
Acrónimo S-57DescripciónNombres reconocidos en QGIS
LNDARE ÁreaÁrea terrestreÁrea Terreno, tierra, land
DEPARE ÁreaÁrea de profundidadfondos, batimetria, depth_area
FAIRWY ÁreaCanal de navegacióncanal_navegacion, fairway
RESARE ÁreaÁrea restringidazona_restringida, restricted
ACHARE ÁreaÁrea de fondeofondeadero, anchorage
HRBARE ÁreaÁrea portuariapuerto, harbor
BERTHS ÁreaAtraque / muelleatraque, berth
SBDARE ÁreaÁrea de fondo marinofondo_marino, seabed
+
+ + +
+

Atributos generales

+

Agrega estas columnas en tu SHP. El nombre de columna es flexible — el converter usa las palabras clave de la tabla.

+ + + + + + + + + + + + +
Columna en el SHPAtributo S-57Descripción
nombre / nameOBJNAMNombre del objeto — aparece en tooltip del ECDIS
altura / heightHEIGHTAltura sobre el nivel del mar (metros)
colour / colorCOLOURColor — ver tabla de códigos abajo
profundidad / depthDRVAL1Profundidad mínima (metros)
depth_maxDRVAL2Profundidad máxima (metros)
sonda / soundingVALSOUValor de sonda (metros)
contour / valorVALDCOValor de curva batimétrica (metros)
estado / statusSTATUSEstado: 1=permanente, 2=ocasional, 7=privado
+
+ + +
+

BOYLAT — Boyas laterales

+
+ Capa QGIS: nombrar boyas / boylat / lateral — geometria Punto — CRS EPSG:4326 +
+ + + + + + + + + + + + + + +
Columna SHP / CSVAtributo S-57Descripcion y valoresRequerido
nombre / OBJNAMOBJNAMNombre. Ej: "Boya No.1"
catlam / CATLAMCATLAM1 = Babor (VERDE en IALA-B)   2 = Estribor (ROJO en IALA-B)Si
colour / COLOURCOLOURCodigo de color — ver tabla. IALA-B: 4=verde (babor), 3=rojo (estribor)Si
boyshp / BOYSHPBOYSHPForma: 1=conica, 2=cilindrica, 4=pilar, 5=barril, 6=esfera
litchr / LITCHRLITCHRDestello — ver tabla LITCHR. Ej: 2=Fl, 4=Q
sigper / SIGPERSIGPERPeriodo en segundos. Ej: 4.0
siggrp / SIGGRPSIGGRPGrupo de destellos. Ej: (2), (2+1)
alcance / VALNMRVALNMRAlcance nominal en millas nauticas. Ej: 5.0
altura / HEIGHTHEIGHTAltura del plano focal sobre MLLW (metros)
colpat / COLPATCOLPATPatron de color: 1=horizontal, 2=vertical, 3=diagonal
+

Ejemplo de fila CSV (IALA-B — Americas)

+ + + + + + + +
OBJNAMCATLAMCOLOURBOYSHPLITCHRSIGPERSIGGRPVALNMRHEIGHT
Boya Verde No.1 (Babor)14223.0(1)3.02.5
Boya Roja No.2 (Estribor)23124.0(1)3.02.5
Boya Verde No.4 (Babor)1444(4)4.03.0
+
+ + +
+

BCNLAT — Balizas / Faros de orilla

+
+ Capa QGIS: nombrar balizas / bcnlat / faros — geometria Punto — CRS EPSG:4326 +
+

Estructura fija anclada en tierra o sobre el agua. Mismo esquema de atributos que BOYLAT pero sin BOYSHP. Usa BCNSHP para la forma de la baliza y TOPSHP para la marca de tope.

+ + + + + + + + + + + + + + + +
Columna SHP / CSVAtributo S-57Descripcion y valoresRequerido
nombre / OBJNAMOBJNAMNombre. Ej: "Faro X1"
catlam / CATLAMCATLAM1 = Babor (VERDE IALA-B)   2 = Estribor (ROJO IALA-B)Si
colour / COLOURCOLOURIALA-B: 4=verde (babor), 3=rojo (estribor)Si
bcnshp / BCNSHPBCNSHPForma: 1=poste, 2=tripode, 3=torre, 4=pilao, 8=faro
topshp / TOPSHPTOPSHPMarca de tope: 2=cono, 5=cilindro, 6=esfera, 11=cuadro
litchr / LITCHRLITCHRDestello — ver tabla LITCHR
sigper / SIGPERSIGPERPeriodo en segundos
siggrp / SIGGRPSIGGRPGrupo de destellos. Ej: (4)
alcance / VALNMRVALNMRAlcance en millas nauticas
altura / HEIGHTHEIGHTAltura del plano focal (metros)
ORIENTORIENTRumbo verdadero de enfilacion (grados). Si se llena, genera linea de enfilacion en carta
+

Ejemplo de fila CSV — faros de orilla Barranquilla

+ + + + + + + +
OBJNAMCATLAMCOLOURLITCHRSIGPERSIGGRPVALNMRHEIGHT
Faro X114411.0(4)6.06.0
Faro X42324.0(1)5.08.0
Faro X101425.0(3)7.010.0
+
+ + +
+

BOYCAR — Boyas cardinales

+
+ Capa QGIS: nombrar cardinales / boycar / cardinal — geometria Punto — CRS EPSG:4326 +
+

Boyas cardinales IALA: Norte, Sur, Este, Oeste. Se identifican por la combinacion CATCAM + patron de color amarillo/negro.

+ + + + + + + + + + + +
Columna SHP / CSVAtributo S-57Descripcion y valoresRequerido
nombre / OBJNAMOBJNAMNombre. Ej: "Boyarin N Canal"
catcam / CATCAMCATCAM1=Norte, 2=Este, 3=Sur, 4=OesteSi
colour / COLOURCOLOURNormalmente 6,2 (amarillo+negro). El converter asigna automaticamente segun CATCAM si se omite
litchr / LITCHRLITCHR4=Q (Norte/Sur), 5=VQ (rapida)
sigper / SIGPERSIGPERPeriodo
siggrp / SIGGRPSIGGRPGrupo: Norte=continuo, Este=(3), Sur=(6)+LFl, Oeste=(9)
alcance / VALNMRVALNMRAlcance en millas nauticas
+

Valores CATCAM

+ + + + + + + + +
CodigoCardinalColor tipicoDestello tipico
1Norte (N)Negro arriba / Amarillo abajoQ o VQ (rapida continua)
2Este (E)Negro-Amarillo-NegroQ(3) o VQ(3) cada 5/10s
3Sur (S)Amarillo arriba / Negro abajoQ(6)+LFl o VQ(6)+LFl cada 15s
4Oeste (W)Amarillo-Negro-AmarilloQ(9) o VQ(9) cada 10/15s
+
+ + +
+

BOYISD — Boya de peligro aislado

+
+ Capa QGIS: nombrar peligro / boyisd / isolated — geometria Punto — CRS EPSG:4326 +
+

Marca un obstaculo rodeado de agua navegable por todos lados. Color: negro con banda(s) roja(s). Marcas de tope: dos esferas negras.

+ + + + + + + + + + +
Columna SHP / CSVAtributo S-57Descripcion y valoresRequerido
nombre / OBJNAMOBJNAMNombre del peligro
colour / COLOURCOLOURNormalmente 2,3 (negro+rojo). Si se omite el converter asigna automaticamente
litchr / LITCHRLITCHRTipicamente 13=FFl (Fija y destellante)
sigper / SIGPERSIGPERPeriodo
siggrp / SIGGRPSIGGRPGrupo: tipicamente (2)
alcance / VALNMRVALNMRAlcance en millas nauticas
+
+ + +
+

BOYSPP — Marcas especiales

+
+ Capa QGIS: nombrar especiales / boyspp / special — geometria Punto — CRS EPSG:4326 +
+

Boyas de usos especiales: zonas de pesca, cabos de fondeo, areas restringidas, tuberias, etc. Color: amarillo. Marca de tope: aspa (X) amarilla.

+ + + + + + + + + + + +
Columna SHP / CSVAtributo S-57Descripcion y valoresRequerido
nombre / OBJNAMOBJNAMDescripcion de uso
colour / COLOURCOLOUR6=Amarillo (estandar para marcas especiales)Si
boyshp / BOYSHPBOYSHPForma de la boya
litchr / LITCHRLITCHRDestello — ver tabla LITCHR
sigper / SIGPERSIGPERPeriodo
alcance / VALNMRVALNMRAlcance
inform / INFORMINFORMTexto libre: descripcion de la zona restringida
+
+ + +
+

LIGHTS — Luces independientes (faros, luces de sector)

+
+ Capa QGIS: nombrar luces / lights / faros — geometria Punto — CRS EPSG:4326 +
+

Luces independientes como faros de tierra, luces de sector o luces de recalada que no estan asociadas a una boya. Para boyas con luz, usa BOYLAT/BCNLAT con los campos LITCHR/SIGPER — el converter genera automaticamente el objeto LIGHTS co-ubicado.

+ + + + + + + + + + + + + +
Columna SHP / CSVAtributo S-57Descripcion y valoresRequerido
nombre / OBJNAMOBJNAMNombre del faro
colour / COLOURCOLOURColor de la luz: 1=blanco, 3=rojo, 4=verdeSi
litchr / LITCHRLITCHRCaracteristica de destello — ver tabla LITCHRSi
sigper / SIGPERSIGPERPeriodo en segundos. Ej: 2.0Si
siggrp / SIGGRPSIGGRPGrupo: si aplica. Ej: (3)
alcance / VALNMRVALNMRAlcance nominal en millas nauticas
altura / HEIGHTHEIGHTAltura del plano focal sobre MLLW (metros)
ORIENTORIENTRumbo verdadero de la enfilacion (grados). Genera linea de enfilacion en la carta
inform / INFORMINFORMNotas: color de la torre, caracteristicas especiales
+

Ejemplo — Faros de recalada Barranquilla

+ + + + + + +
OBJNAMCOLOURLITCHRSIGPERVALNMRHEIGHTORIENT
Faro F1 Recalada472.09.020.0270.0
Faro F2 Recalada372.013.423.0
+
+ + +
+

Tablas de codigos S-57

+ +

LITCHR — Caracteristica de luz (IHO S-57 Ed. 3.1)

+
+ Atencion: Usar exactamente estos codigos numericos en la columna LITCHR. El texto (Fl, Q, Iso…) es solo referencia. +
+ + + + + + + + + + + + + + + + +
CodigoAbrev.DescripcionEjemplo en carta
1FFija — luz continua sin interrupcionesF G
2FlDestellante — destello mas corto que ocultacionFl G 4s
3LFlGran destello — destello de duracion ≥ 2sLFl W 10s
4QCentelleante — 50 a 60 destellos por minutoQ(4) G 11s
5VQRapida — 100 a 120 destellos por minutoVQ(3) W
6UQUltra rapida — mas de 160 destellos por minutoUQ
7IsoIsofasica — periodo de luz igual al de oscuridadIso G 2s
8OcOcultante — periodo de luz mayor que el de oscuridadOc R 4s
9IQCentelleante interrumpidaIQ
10IVQRapida interrumpidaIVQ(3)
12MoCodigo MorseMo(A) W
13FFlFija y destellanteFFl(2) W
+ +

COLOUR — Color

+ + + + + + + + + + + + +
CódigoColor
1Blanco (White)
2Negro (Black)
3Rojo (Red)
4Verde (Green)
5Azul (Blue)
6Amarillo (Yellow)
9Naranja (Orange)
11Violeta (Violet)
+ +

CATLAM — Categoria lateral

+
+ IALA-B (Americas — Colombia, USA, Canada, Brasil…) + Babor = VERDE  |  Estribor = ROJO. Al entrar al puerto: verde a la izquierda, rojo a la derecha. +
+ + + + + + +
CodigoIALA-B (Americas)IALA-A (Europa/Asia/Africa/Australia)
1Babor — VERDE (colour=4) — mano izquierda entrandoBabor — ROJO (colour=3)
2Estribor — ROJO (colour=3) — mano derecha entrandoEstribor — VERDE (colour=4)
+ +

BOYSHP — Forma de boya

+ + + + + + + + + + + + +
CódigoForma
1Cónica (estribor en IALA-A)
2Cilíndrica (babor en IALA-A)
3Esférica
4Barril
5Super-boya
6Pilón (spar)
7Boyarín
8Ícaro (ice buoy)
+
+ + +
+

Formato CSV directo (sin QGIS)

+

Si prefieres no usar QGIS, puedes preparar un CSV maestro y correr build_barranquilla.py (o script equivalente) directamente. El CSV usa los nombres de atributos S-57 como columnas.

+
+ Columna clave: feat_type + Define el tipo S-57 de cada fila. Valores: BOYLAT, BCNLAT, BOYCAR, BOYISD, BOYSPP, LIGHTS +
+

Cabecera del CSV maestro

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ColumnaAtributo S-57Descripcion
no_dimarNumero de referencia interno (opcional)
OBJNAMOBJNAMNombre del objeto
lonLongitud decimal WGS-84 (negativa al W)
latLatitud decimal WGS-84
feat_typeClase S-57BOYLAT / BCNLAT / BOYCAR / BOYISD / BOYSPP / LIGHTS
LITCHRLITCHRCodigo de destello — ver tabla
LITCHR_TXTTexto del destello (solo referencia, no se escribe en S-57)
SIGGRPSIGGRPGrupo de destellos. Ej: (4)
SIGPERSIGPERPeriodo en segundos
COLOURCOLOURCodigo de color (ver tabla COLOUR)
COLOUR_TXTTexto del color (solo referencia)
COLPATCOLPATPatron de color: 1=horizontal, 2=vertical
VALNMRVALNMRAlcance nominal (millas nauticas)
HEIGHTHEIGHTAltura del plano focal (metros)
ORIENTORIENTRumbo de enfilacion en grados (opcional)
CATLAMCATLAMCategoria lateral: 1=babor, 2=estribor
CATCAMCATCAMCardinal: 1=N, 2=E, 3=S, 4=W
BOYSHPBOYSHPForma de boya
BCNSHPBCNSHPForma de baliza
TOPSHPTOPSHPMarca de tope
INFORMINFORMNotas libres (aparecen en tooltip)
_dimar_char_rawCaracter de luz original DIMAR (solo referencia)
_sourceFuente del dato (solo referencia)
+

Ejemplo de filas CSV maestro

+ + + + + + + + +
OBJNAMlonlatfeat_typeLITCHRSIGPERCOLOURCATLAMCATCAM
Boya No. 1-74.81011.102BOYLAT23.041
Faro X1-74.80611.095BCNLAT411.041
Cardinal N-74.82011.110BOYCAR421
Faro Recalada-74.84911.106LIGHTS72.04
+
+ Para agregar tierra (LNDARE) al .000 + El CSV directo solo soporta objetos puntuales. Para incluir poligonos de tierra (LNDARE) y linea de costa (COALNE), usar el flujo QGIS con capas SHP de area/linea, o crear una capa Tierra (geometria Poligono) en QGIS con los limites costeros y exportar con el converter normal. +
+
+ + +
+

cell_config.json

+
+ Este archivo ya viene incluido con el converter — NO hay que crearlo. + Solo edita los campos que necesites cambiar (cell_name, scale, issue_date, producer_name). + El archivo esta en: D:\Proyectos Software\QGISS57Converter\cell_config.json +
+ +

Campos que debes editar para cada carta

+ + + + + + + + + +
CampoDescripcionEjemplo
cell_nameNombre del archivo de salida (sin .000). Convenio IHO: 2 letras pais + 1 digito escala + codigo areaCO1CO01M
scaleEscala denominador de la carta50000
issue_dateFecha de emision formato YYYYMMDD20260430
producer_nameNombre del productor hidrográficoDIMAR
producer_codeCodigo ISO2 del pais productorCO
+ +

Estructura completa del archivo

+
{
+  "cell_name":        "CO1CO01M",
+  "cell_edition":     1,
+  "update_number":    1,
+  "issue_date":       "20260430",
+  "producer_code":    "CO",
+  "producer_name":    "DIMAR",
+  "data_set_name":    "Barranquilla ENC",
+  "scale":            50000,
+  "horizontal_datum": "WGS84",
+  "vertical_datum":   "MLLW",
+  "sounding_datum":   "MLLW",
+  "compilation_scale": 50000,
+
+  "layer_mappings": {
+    "boyas":          "BOYLAT",
+    "balizas":        "BCNLAT",
+    "luces":          "LIGHTS",
+    "tierra":         "LNDARE",
+    "Área Terreno":   "LNDARE",
+    "Linderos":       "COALNE",
+    "sondas":         "SOUNDG",
+    "fondeadero":     "ACHARE"
+  },
+
+  "attribute_mappings": {
+    "nombre":   "OBJNAM",
+    "colour":   "COLOUR",
+    "catlam":   "CATLAM",
+    "boyshp":   "BOYSHP",
+    "litchr":   "LITCHR",
+    "sigper":   "SIGPER",
+    "siggrp":   "SIGGRP",
+    "alcance":  "VALNMR",
+    "altura":   "HEIGHT"
+  }
+}
+ +
+ layer_mappings — traduce el nombre de tu capa QGIS al acronimo S-57. El converter ya incluye los nombres mas comunes (ver archivo completo). Si tu capa tiene un nombre especial, agrega una linea aqui. +

+ attribute_mappings — traduce el nombre de tu columna SHP al atributo S-57. Igualmente, los nombres comunes ya estan mapeados. +
+
+ + +
+

Ejemplos de SHP

+ +

Capa de boyas (BOYLAT)

+
+ Nombre de la capa: boyas — geometría Punto — CRS: EPSG:4326 +
+

Columnas recomendadas:

+ + + + + + +
nombrecatlamcolourlitchrsigpersiggrpalcance
Boya R-41324.0(1)3.0
Boya V-32424.0(1)3.0
+ +

Capa de línea de costa (COALNE)

+
+ Nombre de la capa: Linderos o coastline — geometría Línea — CRS: EPSG:4326 +
+

No requiere atributos mínimos. Opcionales: nombre para identificar el tramo.

+ +

Capa de área terrestre (LNDARE)

+
+ Nombre de la capa: Área Terreno o tierra — geometría Polígono — CRS: EPSG:4326 +
+

Opcional: nombre para identificar la isla o terreno.

+ +

Capa de sondas (SOUNDG)

+
+ Nombre de la capa: sondas — geometría Punto — CRS: EPSG:4326 +
+

Agrega columna sonda con el valor en metros. Si el punto tiene coordenada Z, se usa automáticamente.

+ + + + + + +
sondaCoordenadas
3.5-80.456, 27.752
12.0-80.451, 27.758
+
+ +
+ + + + + diff --git a/QGISS57Converter.spec b/QGISS57Converter.spec new file mode 100644 index 0000000..cd1dca1 --- /dev/null +++ b/QGISS57Converter.spec @@ -0,0 +1,46 @@ +# -*- mode: python ; coding: utf-8 -*- +import pyproj, os +_proj_data = pyproj.datadir.get_data_dir() + +a = Analysis( + ['gui.py'], + pathex=[], + binaries=[], + datas=[ + ('cell_config.json', '.'), + ('noaa_ddr_template.bin', '.'), + ('CAPAS_REFERENCIA.pdf', '.'), + ('s57_objects.json', '.'), + ('MANUAL.html', '.'), + (_proj_data, 'proj_data'), # PROJ grid files for pyproj + ], + hiddenimports=['converter', 's57_writer', 'pyproj', 'pyproj.datadir'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='QGISS57Converter', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/SCHEMA_REFERENCIA.md b/SCHEMA_REFERENCIA.md new file mode 100644 index 0000000..526434d --- /dev/null +++ b/SCHEMA_REFERENCIA.md @@ -0,0 +1,211 @@ +# SCHEMA CSV — DIMAR → S-57 ENC +## Referencia para extracción de datos de PDFs DIMAR + +Cuando Claude recibe un PDF de DIMAR (Lista de Luces, AAN, carta), lee este archivo +y genera el CSV con exactamente estas columnas y códigos. + +--- + +## COLUMNAS DEL CSV (en este orden) + +``` +no_dimar, OBJNAM, lon, lat, feat_type, LITCHR, LITCHR_TXT, SIGGRP, COLOUR, COLOUR_TXT, SIGPER, VALNMR, HEIGHT, ORIENT, CATCAM, INFORM, _dimar_char_raw, _source +``` + +| Columna | Descripción | Ejemplo | +|----------------|--------------------------------------------------|----------------------| +| no_dimar | Número en Lista de Luces DIMAR | 257 | +| OBJNAM | Nombre oficial de la ayuda | Faro Castillogrande | +| lon | Longitud decimal WGS84 (negativo = oeste) | -75.545000 | +| lat | Latitud decimal WGS84 | 10.391000 | +| feat_type | Tipo S-57 (ver tabla abajo) | BOYCAR | +| LITCHR | Código numérico de característica de luz | 2 | +| LITCHR_TXT | Texto legible de la característica | Fl | +| SIGGRP | Grupo de destellos entre paréntesis | 3 o (6)+ | +| COLOUR | Código numérico de color (ver tabla) | 3 | +| COLOUR_TXT | Texto del color | red | +| SIGPER | Período en segundos | 10 | +| VALNMR | Alcance nominal en millas náuticas | 12 | +| HEIGHT | Altura de la luz sobre MLLW en metros | 24 | +| ORIENT | Rumbo de enfilación en grados (solo enfilaciones)| 135.7 | +| CATCAM | Dirección cardinal (solo boyas cardinales) | 1 | +| INFORM | Descripción física de la estructura | Torre concreto beige | +| _dimar_char_raw| Característica de luz tal como aparece en DIMAR | Fl. W 10 s | +| _source | Fuente del dato | DIMAR Lista de Luces 2015 | + +**Regla**: columnas vacías se dejan en blanco (no NULL, no 0, solo vacío). +**Regla**: columnas que empiezan con `_` son privadas, el converter las ignora. +**Regla**: coordenadas siempre en decimal WGS84. Convertir grados-minutos así: + `DD°MM.mmm' = DD + MM.mmm/60` + Ejemplo: 10°23.45'N = 10 + 23.45/60 = 10.390833 + +--- + +## TABLA feat_type — TIPO DE AYUDA + +| feat_type | Descripción | Cuándo usarlo | +|-----------|------------------------------------|--------------------------------------------------| +| BOYCAR | Boya cardinal | Boya con topmark en forma de cono/cono invertido que indica N/S/E/W | +| BCNCAR | Baliza cardinal (fija) | Estructura fija cardinal | +| BOYLAT | Boya lateral | Boya roja o verde que marca bordes de canal | +| BCNLAT | Baliza lateral (fija) | Estructura fija lateral, también enfilaciones | +| BOYISD | Boya de peligro aislado | Boya negra-roja sobre peligro aislado | +| BOYSAW | Boya de aguas seguras | Boya roja-blanca, marca agua navegable | +| BOYSPP | Boya especial | Boya amarilla, uso especial | +| LIGHTS | Luz / Faro / Enfilación | Faros, luces de puerto, enfilaciones | +| LNDMRK | Punto de referencia terrestre | Torres, edificios notables, chimeneas | + +--- + +## TABLA LITCHR — CARACTERÍSTICA DE LUZ + +| Código | LITCHR_TXT | DIMAR escribe | Descripción | +|--------|------------|------------------------|--------------------------| +| 1 | F | F. | Fija (Fixed) | +| 2 | Fl | Fl. | Destello (Flashing) | +| 3 | LFl | LFl. | Destello largo | +| 4 | Q | Q. | Rápida (Quick) | +| 5 | VQ | VQ. | Muy rápida | +| 6 | UQ | UQ. | Ultra rápida | +| 7 | Iso | Iso. | Isofase | +| 8 | Oc | Oc. | Ocultante | +| 9 | IQ | IQ. | Interrumpida rápida | +| 12 | Mo | Mo. | Morse | +| 13 | FFl | FFl. | Fija y destellante | + +**Grupos**: el número entre paréntesis va en SIGGRP. Ejemplos: +- `Fl.(3) W 10 s` → LITCHR=2, LITCHR_TXT=Fl, SIGGRP=3, COLOUR=1, SIGPER=10 +- `Q.(6)+LFl.W 15s` → LITCHR=4, LITCHR_TXT=Q, SIGGRP=(6)+, COLOUR=1, SIGPER=15 +- `Iso. Bu 4 s` → LITCHR=7, LITCHR_TXT=Iso, SIGGRP=, COLOUR=5, SIGPER=4 + +--- + +## TABLA COLOUR — COLOR DE LA LUZ + +| Código | COLOUR_TXT | DIMAR escribe | +|--------|------------|---------------| +| 1 | white | W | +| 2 | black | — | +| 3 | red | R | +| 4 | green | G | +| 5 | blue | Bu | +| 6 | yellow | Y | +| 11 | orange | Or | + +**Colores múltiples** (sectores): separar con coma. Ejemplo `"1,3,4"` = white/red/green. +COLOUR_TXT correspondiente: `"white/red/green"` + +--- + +## TABLA CATCAM — DIRECCIÓN CARDINAL (solo BOYCAR / BCNCAR) + +| Código | Dirección | Cuándo | +|--------|-----------|-------------------------------------------------| +| 1 | N | Boya Norte — pasa al norte de la boya | +| 2 | E | Boya Este — pasa al este | +| 3 | S | Boya Sur — pasa al sur | +| 4 | W | Boya Oeste — pasa al oeste | + +Inferencia por nombre cuando CATCAM no está explícito: +- Contiene " SN", " VN", "Norte", " NN" → 1 (N) +- Contiene " SE", "Este" → 2 (E) +- Contiene " SS", " VS", "Sur" → 3 (S) +- Contiene " SO", " BB", "Oeste", " SW" → 4 (W) + +--- + +## TABLA ORIENT — RUMBO DE ENFILACIÓN (solo LIGHTS con enfilación) + +- Solo se rellena cuando la ayuda es una enfilación (leading light / range mark) +- Valor en grados verdaderos (0–360), con un decimal +- Es el rumbo que sigue el buque cuando está alineado con las luces +- Si el PDF no especifica rumbo → dejar ORIENT vacío +- Ejemplos: `135.7`, `167.3`, `347.5` + +--- + +## EJEMPLOS COMPLETOS POR TIPO DE AYUDA + +### Faro (LIGHTS) +``` +35,Faro Castillogrande,-75.545000,10.391000,LIGHTS,2,Fl,,1,white,15,12,24,,,Torre en concreto color beige,Fl. W 15 s,DIMAR Lista de Luces 2015 +``` + +### Faro con grupos (LIGHTS) +``` +34,Faro Punta Canoas,-75.499167,10.573000,LIGHTS,2,Fl,2,1,white,20,12,96,,,Torre roja bandas blancas. Giratorio,Fl.(2) W 20 s,DIMAR Lista de Luces 2015 +``` + +### Enfilación (LIGHTS con ORIENT) +``` +196,Enfilacion E1,-74.848333,11.103667,LIGHTS,6,Iso,,6,white,5,13,10,135.7,,Baliza enrejado naranja y blanco,Iso Bu 5s,DIMAR Lista de Luces 2015 +``` + +### Boya cardinal Norte (BOYCAR) +``` +250,Boya SN,-75.521000,10.366000,BOYCAR,4,Q,,1,white,15,4,4,,1,Castillete cardinal N negro,Q.W 15s,DIMAR Lista de Luces 2015 +``` + +### Boya cardinal Sur (BOYCAR) +``` +256,Boya SS,-75.527500,10.331833,BOYCAR,3,4,Q,(6)+,1,white,15,4,4,,3,Castillete cardinal S negros,Q.(6)+LFl.W 15s,AAN-DIMAR-2024-770 +``` + +### Boya cardinal Este (BOYCAR) +``` +253,Boya SE,-75.512000,10.365000,BOYCAR,4,Q,3,1,white,10,4,4,,2,Castillete cardinal E negros,Q.(3)W 10s,DIMAR Lista de Luces 2015 +``` + +### Boya cardinal Oeste (BOYCAR) +``` +252,Boya SO,-75.534000,10.372000,BOYCAR,2,Fl,9,1,white,15,4,4,,4,Castillete cardinal W negros,Fl.(9)W 15s,DIMAR Lista de Luces 2015 +``` + +### Boya lateral verde (BOYLAT) +``` +240,Boya No. 1,-75.563000,10.345000,BOYLAT,2,Fl,,4,green,3,3,4,,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +``` + +### Boya lateral roja (BOYLAT) +``` +241,Boya No. 2,-75.560000,10.347000,BOYLAT,2,Fl,,3,red,3,3,4,,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +``` + +### Boya de peligro aislado (BOYISD) +``` +258,Boya Peligro Aislado Polvorines,-75.536000,10.351167,BOYISD,2,Fl,2,1,white,5,4,2.5,,,Castillete roja bandas negras,Fl.(2) W 5 s,DIMAR Lista de Luces 2015 +``` + +### Baliza lateral con enfilación (BCNLAT) +``` +257,Enfilacion de Bocachica B,-75.508833,10.320833,BCNLAT,7,Iso,,5,blue,4,12,33,,,Torre enrejada rojo bandas blancas,Iso. Bu 4 s,DIMAR Lista de Luces 2015 +``` + +### Luz de aproximación (LIGHTS color especial) +``` +296,Luz de Aproximacion,-75.549500,10.409000,LIGHTS,2,Fl,,5,blue,2.5,11,37,,,Torre metalica roja y blanca,Fl. Bu 2.5 s,DIMAR Lista de Luces 2015 +``` + +--- + +## CONVERSIÓN DE COORDENADAS DIMAR + +DIMAR publica en grados y minutos decimales: `10°23.45'N 75°32.67'W` + +Fórmula: `grados + minutos/60` +- Latitud: 10 + 23.45/60 = **10.390833** (positivo = Norte) +- Longitud: 75 + 32.67/60 = **75.544500** → con signo negativo = **-75.544500** (Oeste) + +--- + +## INSTRUCCIÓN PARA CLAUDE + +Cuando el usuario pase un PDF de DIMAR: +1. Lee este archivo SCHEMA_REFERENCIA.md primero +2. Extrae cada ayuda a la navegación del PDF +3. Convierte coordenadas a decimal WGS84 +4. Mapea característica de luz a LITCHR + COLOUR + SIGPER + SIGGRP +5. Asigna feat_type según el tipo de estructura +6. Asigna CATCAM si es cardinal, ORIENT si es enfilación +7. Genera el CSV con exactamente las columnas de este schema, en el mismo orden +8. Nombra el archivo: `dimar_ayudas_.csv` diff --git a/build_barranquilla.py b/build_barranquilla.py new file mode 100644 index 0000000..4913c0d --- /dev/null +++ b/build_barranquilla.py @@ -0,0 +1,51 @@ +""" +Build CO1CO01M.000 directly from dimar_ayudas_barranquilla.csv +without needing a QGIS project file. +""" +import sys, json +from pathlib import Path + +# Import S57CellWriter from converter (it's defined there) +sys.path.insert(0, str(Path(__file__).parent)) +from converter import S57CellWriter + +# ── paths ───────────────────────────────────────────────────────────────────── +HERE = Path(__file__).parent +CSV = HERE / "dimar_ayudas_barranquilla.csv" +CONFIG = HERE / "cell_config.json" +OUTPUT = HERE / "dist" / "CO1CO01M" / "CO1CO01M.000" + +# ── load config ─────────────────────────────────────────────────────────────── +with open(CONFIG, encoding="utf-8") as f: + cfg = json.load(f) + +# Update issue date and ensure correct cell name +cfg["cell_name"] = "CO1CO01M" +cfg["issue_date"] = "20260430" +cfg["update_number"] = 1 + +# ── build ───────────────────────────────────────────────────────────────────── +OUTPUT.parent.mkdir(parents=True, exist_ok=True) + +attr_map = cfg.get("attribute_mappings", {}) + +print(f"Input: {CSV}") +print(f"Output: {OUTPUT}") +print() + +writer = S57CellWriter(str(OUTPUT), cfg) +writer.open() + +count = writer.add_features_from_csv( + CSV, + "BOYLAT", # default class (overridden per-row by feat_type column) + attr_map, + x_field="lon", + y_field="lat", +) + +writer.close() +writer.summary() + +print(f"\n✓ {count} feature(s) written") +print(f" {OUTPUT} ({OUTPUT.stat().st_size // 1024} KB)") diff --git a/build_cartagena.py b/build_cartagena.py new file mode 100644 index 0000000..d4090dd --- /dev/null +++ b/build_cartagena.py @@ -0,0 +1,104 @@ +""" +Build CO4CTG01M.000 — Carta ENC S-57 de la Bahía de Cartagena. +Lee todas las capas CSV de capas_ctg/ y genera un archivo S-57 válido +para OpenCPN, AR ECDIS y cualquier software compatible IHO S-57. + +Uso: + python build_cartagena.py + python build_cartagena.py --out dist/MI_CARTA/MI_CARTA.000 + +Columnas especiales en los CSV: + feat_type — acrónimo S-57 de la fila (BOYCAR, LIGHTS, etc.) + CATCAM — dirección cardinal (1=N 2=E 3=S 4=W) → se escribe directo al S-57 + ORIENT — rumbo de enfilación en grados → se escribe directo al S-57 + _* — columnas privadas, se ignoran +""" +import sys +import json +import argparse +from pathlib import Path +from datetime import date + +sys.path.insert(0, str(Path(__file__).parent)) +from converter import S57CellWriter + +# ── rutas ───────────────────────────────────────────────────────────────────── +HERE = Path(__file__).parent +CAPAS_DIR = HERE / "capas_ctg" +OUTPUT = HERE / "dist" / "CO4CTG01M" / "CO4CTG01M.000" + +# ── orden de carga de capas (primero las estructuras, luego las luces) ──────── +# El orden importa: si una fila de LIGHTS.csv tiene feat_type=LIGHTS su companion +# light ya no se emite dos veces porque solo las clases _STRUCT_CLASSES generan +# companion. Pero al cargar BOYCAR/BCNLAT antes que LIGHTS evitamos duplicados. +LAYERS = [ + # archivo CSV clase S-57 por defecto (se sobreescribe por feat_type) + ("BOYCAR.csv", "BOYCAR"), + ("BCNLAT.csv", "BCNLAT"), + ("BOYISD.csv", "BOYISD"), + ("BOYLAT.csv", "BOYLAT"), + ("BOYSPEC.csv", "BOYSPP"), + ("LIGHTS.csv", "LIGHTS"), +] + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--out", default=str(OUTPUT), help="Ruta del archivo .000 de salida") + args = ap.parse_args() + + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + + cfg = { + "cell_name": "CO4CTG01M", + "cell_edition": 1, + "update_number": 0, + "issue_date": date.today().strftime("%Y%m%d"), + "producer_code": "CO", + "producer_name": "DIMAR / AR ECDIS", + "data_set_name": "Bahia de Cartagena ENC", + "scale": 12000, + "compilation_scale":12000, + "comment": "Generated by QGIS S-57 Converter from capas_ctg", + "horizontal_datum": "WGS84", + "vertical_datum": "MLLW", + "sounding_datum": "MLLW", + "attribute_mappings": {}, + } + + print(f"Input dir : {CAPAS_DIR}") + print(f"Output : {out_path}") + print() + + writer = S57CellWriter(str(out_path), cfg) + writer.open() + + total = 0 + for csv_name, default_class in LAYERS: + csv_path = CAPAS_DIR / csv_name + if not csv_path.exists(): + print(f" [SKIP] {csv_name} — no encontrado") + continue + n = writer.add_features_from_csv( + csv_path, + default_class, + attr_map={}, + x_field="lon", + y_field="lat", + ) + print(f" {csv_name:20s} → {n:3d} feature(s)") + total += n + + writer.close() + writer.summary() + + size_kb = out_path.stat().st_size // 1024 + print(f"\n✓ {total} feature(s) escritos") + print(f" {out_path} ({size_kb} KB)") + print() + print("Para OpenCPN: Herramientas → Opciones → Cartas → Agregar directorio") + print(f" → {out_path.parent}") + + +if __name__ == "__main__": + main() diff --git a/build_ecdis_manual.py b/build_ecdis_manual.py new file mode 100644 index 0000000..d0a2ceb --- /dev/null +++ b/build_ecdis_manual.py @@ -0,0 +1,157 @@ +""" +build_ecdis_manual.py — Reconstruye los GeoJSONs del ECDIS manual chart +desde los CSVs fuente. Funciona igual para CUALQUIER puerto. + +Uso: + python build_ecdis_manual.py capas_ctg BAHÍA_DE_CARTAGENA + python build_ecdis_manual.py capas_baq BARRANQUILLA + python build_ecdis_manual.py capas_ptco BUENAVENTURA + +El script: + 1. Lee todos los *.csv del directorio de capas + 2. Genera un GeoJSON por feat_type con atributos S-57 limpios + 3. Escribe los GeoJSONs en el directorio manual del ECDIS + +Reglas de datos: + - feat_type determina el archivo de salida (LIGHTS.geojson, BOYCAR.geojson...) + - SIGGRP "**" → null (limpia basura de GDAL) + - LITCHR_TXT se convierte a código S-57 si LITCHR está vacío + - COLOUR_TXT se convierte a código S-57 si COLOUR está vacío + - CATCAM y ORIENT se pasan directo al GeoJSON + - INFORM se preserva completo +""" +import csv, json, sys, argparse +from pathlib import Path + +ECDIS_DATA = Path(__file__).parent.parent / "AR ECDIS" / "webecdis" / "data" / "charts" / "manual" + +LITCHR_TXT = { + "f":"1","fl":"2","lfl":"3","q":"4","vq":"5","uq":"6", + "iso":"7","oc":"8","iq":"9","mo":"12","ffl":"13", +} +COLOUR_TXT = { + "white":"1","black":"2","red":"3","green":"4","blue":"5", + "yellow":"6","grey":"7","brown":"8","amber":"9","violet":"10", + "orange":"11","magenta":"12", +} + +def _fval(s): + s = (s or "").strip() + if not s or all(c in "* " for c in s): return None + try: return float(s) + except: return None + +def _ival(s): + s = (s or "").strip() + if not s or all(c in "* " for c in s): return None + try: return int(float(s)) + except: return None + +def _sval(s): + s = (s or "").strip() + return s if s and not all(c in "* " for c in s) else None + +def _parse_litchr(row): + v = _ival(row.get("LITCHR", "")) + if v is not None: return v + txt = (row.get("LITCHR_TXT") or "").lower().split("(")[0].strip() + c = LITCHR_TXT.get(txt) + return int(c) if c else None + +def _parse_colour(row): + v = (row.get("COLOUR") or "").strip() + if v and not all(c in "* " for c in v): + parts = [p.strip() for p in v.split(",") if p.strip().isdigit()] + if parts: return [int(p) for p in parts] + txt = (row.get("COLOUR_TXT") or "").lower().strip() + c = COLOUR_TXT.get(txt) + return [int(c)] if c else None + +def build(capas_dir: Path, chart_name: str, ecdis_data: Path): + out_dir = ecdis_data / chart_name + if not out_dir.exists(): + print(f"[WARN] Directorio ECDIS no existe: {out_dir}") + print(f" Creando...") + out_dir.mkdir(parents=True) + + layers: dict[str, list] = {} + rcid = 1 + + for csv_file in sorted(capas_dir.glob("*.csv")): + default_layer = csv_file.stem.upper() + with open(csv_file, newline="", encoding="utf-8-sig") as f: + for row in csv.DictReader(f): + try: + lon = float(row.get("lon", "").strip()) + lat = float(row.get("lat", "").strip()) + except (ValueError, AttributeError): + continue + + feat_type = (_sval(row.get("feat_type", "")) or default_layer).upper() + + props = { + "RCID": rcid, + "PRIM": 1, + "GRUP": 1, + "OBJL": 75, + "RVER": 1, + "AGEN": 999, + "FIDN": rcid, + "FIDS": 1, + "LNAM": f"03E7{rcid:08X}0001", + "OBJNAM": _sval(row.get("OBJNAM", "")), + "LITCHR": _parse_litchr(row), + "COLOUR": _parse_colour(row), + "SIGGRP": _sval(row.get("SIGGRP", "")), + "SIGPER": _fval(row.get("SIGPER", "")), + "VALNMR": _fval(row.get("VALNMR", "")), + "HEIGHT": _fval(row.get("HEIGHT", "")), + "ORIENT": _fval(row.get("ORIENT", "")), + "CATCAM": _ival(row.get("CATCAM", "")), + "INFORM": _sval(row.get("INFORM", "")), + "NOBJNM": _sval(row.get("NOBJNM", "")), + } + + feat = { + "type": "Feature", + "geometry": { + "coordinates": [lon, lat], + "type": "Point", + "geometries": None + }, + "properties": props + } + + layers.setdefault(feat_type, []).append(feat) + rcid += 1 + + total = 0 + for layer, feats in sorted(layers.items()): + fc = {"type": "FeatureCollection", "features": feats} + out_file = out_dir / f"{layer}.geojson" + out_file.write_text(json.dumps(fc, ensure_ascii=False), encoding="utf-8") + print(f" {layer}.geojson : {len(feats)} features") + total += feats.__len__() + + print(f"\nOK {total} features en {out_dir}") + return total + + +def main(): + ap = argparse.ArgumentParser(description="Rebuild ECDIS manual GeoJSONs from CSV layers") + ap.add_argument("capas_dir", help="Directorio con los CSVs (capas_ctg, capas_baq...)") + ap.add_argument("chart_name", help="Nombre del chart en ECDIS (BAHÍA_DE_CARTAGENA, BARRANQUILLA...)") + ap.add_argument("--ecdis", default=str(ECDIS_DATA), + help=f"Ruta base de charts/manual del ECDIS [default: {ECDIS_DATA}]") + args = ap.parse_args() + + capas = Path(args.capas_dir) + if not capas.exists(): + print(f"ERROR: No existe {capas}") + sys.exit(1) + + build(capas, args.chart_name, Path(args.ecdis)) + + +if __name__ == "__main__": + main() diff --git a/capas_baq/BOYCAR.csv b/capas_baq/BOYCAR.csv new file mode 100644 index 0000000..dd58c8b --- /dev/null +++ b/capas_baq/BOYCAR.csv @@ -0,0 +1,3 @@ +no_dimar,OBJNAM,lon,lat,feat_type,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,VALNMR,HEIGHT,ORIENT,INFORM,_dimar_char_raw,_source +235,Boya Cardinal Norte,-74.753500,10.959167,BOYCAR,3,Fl,,6,white,1,6,4,,Castillete cardinal N negros,Fl W 1s,DIMAR Lista de Luces 2015 +236,Boya Cardinal Sur,-74.753500,10.959167,BOYCAR,3,Fl,,6,white,15,6,4,,Castillete cardinal S negros,Fl W 15s,DIMAR Lista de Luces 2015 diff --git a/capas_baq/BOYISD.csv b/capas_baq/BOYISD.csv new file mode 100644 index 0000000..cbbf652 --- /dev/null +++ b/capas_baq/BOYISD.csv @@ -0,0 +1,2 @@ +no_dimar,OBJNAM,lon,lat,feat_type,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,VALNMR,HEIGHT,ORIENT,INFORM,_dimar_char_raw,_source +240,Boya Peligro Aislado,-74.757333,10.954500,BOYISD,3,Fl(2),2,6,white,4,3,3.3,,Castillete roja bandas negras. Bajo rocoso,Fl(2) W 4s,DIMAR Lista de Luces 2015 diff --git a/capas_baq/BOYLAT.csv b/capas_baq/BOYLAT.csv new file mode 100644 index 0000000..f12cc81 --- /dev/null +++ b/capas_baq/BOYLAT.csv @@ -0,0 +1,29 @@ +no_dimar,OBJNAM,lon,lat,feat_type,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,VALNMR,HEIGHT,ORIENT,INFORM,_dimar_char_raw,_source +199,Boya No. 1,-74.833500,11.084500,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +200,Boya No. 3,-74.844833,11.075833,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +202,Boya No. 5,-74.841000,11.065667,BOYLAT,1,Q,,3,green,1,6,4,,Castillete verde,Q G 1s,DIMAR Lista de Luces 2015 +208,Boya No. 7,-74.837500,11.060000,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +209,Boya No. 9,-74.824000,11.046833,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +210,Boya No. 11,-74.812167,11.039667,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +211,Boya No. 12,-74.813500,11.037167,BOYLAT,3,Fl,,1,red,3,6,4,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +212,Boya No. 13,-74.802000,11.034333,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +213,Boya No. 14,-74.788333,11.021833,BOYLAT,3,Fl,,1,red,3,6,3,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +214,Boya No. 15,-74.793833,11.028167,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +215,Boya No. 16,-74.797500,11.027333,BOYLAT,3,Fl,,1,red,3,6,4,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +218,Boya No. 18,-74.788333,11.021833,BOYLAT,3,Fl,,1,red,3,6,4,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +219,Boya No. 19,-74.776500,11.017500,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +220,Boya No. 20,-74.777333,11.015500,BOYLAT,3,Fl,,1,red,3,6,4,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +221,Boya No. 21,-74.772000,11.014000,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +222,Boya No. 22,-74.773333,11.012333,BOYLAT,1,Q,,1,red,1,6,4,,Castillete roja,Q R 1s,DIMAR Lista de Luces 2015 +223,Boya No. 23,-74.755000,10.975000,BOYLAT,3,Fl,,3,green,1.3,6,3,,Castillete verde,Fl G 1.3s,DIMAR Lista de Luces 2015 +224,Boya No. 24,-74.770500,11.009167,BOYLAT,3,Fl,,1,red,3,6,4,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +225,Boya No. 25,-74.766333,11.006667,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +226,Boya No. 26,-74.768333,11.005833,BOYLAT,3,Fl,,1,red,3,6,4,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +227,Boya No. 27,-74.762000,10.998500,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +228,Boya No. 28,-74.765333,10.999833,BOYLAT,3,Fl,,1,red,3,6,4,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +229,Boya No. 29,-74.758000,10.987333,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +230,Boya No. 30,-74.760667,10.987333,BOYLAT,3,Fl,,1,red,3,6,4,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +231,Boya No. 31,-74.754833,10.975000,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +232,Boya No. 33,-74.755667,10.959333,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +233,Boya No. 35,-74.754167,10.942667,BOYLAT,3,Fl,,3,green,3,6,4,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +234,Boya No. 36,-74.756667,10.941500,BOYLAT,3,Fl,,1,red,3,6,4,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 diff --git a/capas_baq/BOYSPEC.csv b/capas_baq/BOYSPEC.csv new file mode 100644 index 0000000..13d158f --- /dev/null +++ b/capas_baq/BOYSPEC.csv @@ -0,0 +1,2 @@ +no_dimar,OBJNAM,lon,lat,feat_type,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,VALNMR,HEIGHT,ORIENT,INFORM,_dimar_char_raw,_source +239,Boya de Oleaje,-74.758000,11.134000,BOYSPEC,3,Fl,,11,yellow,20,4.5,0.5,,Esferica amarilla. Recolectora datos oceanograficos,Fl Y 20s,DIMAR Lista de Luces 2015 diff --git a/capas_baq/LIGHTS.csv b/capas_baq/LIGHTS.csv new file mode 100644 index 0000000..2024c25 --- /dev/null +++ b/capas_baq/LIGHTS.csv @@ -0,0 +1,33 @@ +no_dimar,OBJNAM,lon,lat,feat_type,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,VALNMR,HEIGHT,ORIENT,INFORM,_dimar_char_raw,_source +13,Faro F1 Recalada,-74.849500,11.106167,LIGHTS,6,Iso,,3,green,2,9,20,,Torre naranja bandas blancas. Faro de Recalada,Iso G 2s,DIMAR Lista de Luces 2015 +14,Faro F2 Recalada,-74.854667,11.106000,LIGHTS,6,Iso,,1,red,2,13.4,23,,Torre naranja bandas blancas. Racon B,Iso R 2s,DIMAR Lista de Luces 2015 +32,Faro Morro Hermoso,-75.017500,10.963333,LIGHTS,3,Fl,,6,white,4,28,134,,Torre blanca bandas rojas. Giratorio,Fl W 4s,DIMAR Lista de Luces 2015 +33,Faro Galerazamba,-75.266000,10.785333,LIGHTS,3,Fl,,6,white,4,11,14,,Torre fibra vidrio blanca bandas rojas. Giratorio,Fl W 4s,DIMAR Lista de Luces 2015 +15,Faro X1,-74.849500,11.102167,LIGHTS,1,Q(4)G,4,3,green,11,6,6,,Torre verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +16,Faro X2,-74.853333,11.100000,LIGHTS,1,Q(4)R,4,1,red,11,6,6,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +18,Faro X3,-74.847167,11.091333,LIGHTS,1,Q(4)G,4,3,green,11,6,6,,Torre verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +17,Faro X4,-74.851667,11.093000,LIGHTS,1,Q(4)R,4,1,red,11,6,6,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +19,Faro X5,-74.846667,11.089167,LIGHTS,1,Q(4)G,4,3,green,11,6,6,,Torre verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +20,Faro X6,-74.850500,11.087667,LIGHTS,1,Q(4)R,4,1,red,11,6,6,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +21,Faro X7,-74.814333,11.041500,LIGHTS,1,Q(4)G,4,3,green,11,6,8,,Baliza enrejado rojo bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +22,Faro X8,-74.849167,11.081667,LIGHTS,1,Q(4)R,4,1,red,11,6,6,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +23,Faro X9,-74.804833,11.035833,LIGHTS,1,Q(4)G,4,3,green,11,6,8,,Baliza enrejado rojo bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +24,Faro X10,-74.848000,11.076000,LIGHTS,1,Q(4)R,4,1,red,11,6,6,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +25,Faro X11,-74.795500,11.029833,LIGHTS,1,Q(4)G,4,3,green,11,6,8,,Baliza enrejado rojo bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +26,Faro X12,-74.844167,11.065000,LIGHTS,1,Q(4)R,4,1,red,11,6,6,,Baliza enrejado roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +27,Faro X13,-74.789667,11.025833,LIGHTS,1,Q(4)G,4,3,green,11,6,8,,Baliza enrejado rojo bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +28,Faro X14,-74.839500,11.057833,LIGHTS,1,Q(4)R,4,1,red,11,6,6,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +30,Faro X15,-74.785500,11.022833,LIGHTS,1,Q(4)G,4,3,green,11,6,6,,Baliza enrejado verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +29,Faro X16,-74.833000,11.050000,LIGHTS,1,Q(4)R,4,1,red,11,6,6,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +31,Faro X17,-74.778333,11.018667,LIGHTS,1,Q(4)G,4,3,green,11,6,6,,Baliza enrejado verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +196,Enfilacion E1,-74.848333,11.103667,LIGHTS,6,Iso,,6,white,5,13,10,135.7,Baliza enrejado naranja y blanco. Rumbo 135.7,Iso Bu 5s,DIMAR Lista de Luces 2015 +197,Enfilacion E3,-74.846333,11.101667,LIGHTS,6,Iso,,6,white,5,9,22,139.3,Torre enrejada naranja y blanco. Rumbo 139.3,Iso Bu 5s,DIMAR Lista de Luces 2015 +198,Enfilacion E3A,-74.845000,11.100333,LIGHTS,6,Iso,,6,white,5,12.3,20,135.7,Torre naranja y blanco. Rumbo 135.7,Iso W 5s,DIMAR Lista de Luces 2015 +201,Enfilacion E4,-74.846833,11.070167,LIGHTS,6,Iso,,1,red,4,4.5,11,142.3,Baliza enrejado naranja bandas blancas. Rumbo 142.3,Iso R 4s,DIMAR Lista de Luces 2015 +203,Enfilacion E6,-74.843667,11.063000,LIGHTS,6,Iso,,1,red,4,8,12,167.7,Baliza enrejado roja bandas blancas. Rumbo 167.7,Iso Bu 4s,DIMAR Lista de Luces 2015 +204,Enfilacion E8,-74.841833,11.058500,LIGHTS,6,Iso,,6,white,4,14.5,25,167.7,Baliza enrejado naranja bandas blancas. Rumbo 167.7,Iso Bu 4s,DIMAR Lista de Luces 2015 +205,Enfilacion E10,-74.841667,11.059833,LIGHTS,6,Iso,,3,green,5,10,11,167.3,Torre naranja bandas blancas. Rumbo 167.3,Iso G 5s,DIMAR Lista de Luces 2015 +206,Enfilacion E12,-74.840667,11.056167,LIGHTS,6,Iso,,3,green,5,8,22,167.3,Baliza tablero blanco franja roja. Rumbo 167.3,Iso G 5s,DIMAR Lista de Luces 2015 +207,Enfilacion E14,-74.840667,11.056167,LIGHTS,6,Iso,,6,white,6,8,22,122,Tablero blanco con franja roja. Rumbo 122,Iso Bu 6s,DIMAR Lista de Luces 2015 +237,Enfilacion E16,-74.836667,11.053667,LIGHTS,6,Iso,,6,white,6,9,12,122,Baliza enrejado naranja bandas blancas. Rumbo 122,Iso Bu 6s,DIMAR Lista de Luces 2015 +238,Enfilacion E18,-74.825500,11.043000,LIGHTS,3,Fl,,"1,3,6",white/red/green,4,6,18,142,Torre roja bandas blancas. Sector 9 grados. Rumbo 142,Fl WRG 4s,DIMAR Lista de Luces 2015 diff --git a/capas_ctg/BCNLAT.csv b/capas_ctg/BCNLAT.csv new file mode 100644 index 0000000..9412381 --- /dev/null +++ b/capas_ctg/BCNLAT.csv @@ -0,0 +1,4 @@ +no_dimar,OBJNAM,lon,lat,feat_type,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,VALNMR,HEIGHT,ORIENT,INFORM,_dimar_char_raw,_source +257,Enfilacion de Bocachica B,-75.508833,10.320833,BCNLAT,7,Iso,,5,blue,4,12,33,,Torre enrejada rojo bandas blancas,Iso. Bu 4 s,DIMAR Lista de Luces 2015 +304,Baliza No. 01,-75.5245,10.304,BCNLAT,2,Fl,,4,green,2,3,4.5,,Poste cilindrico verde,Fl. G 2 s,DIMAR Lista de Luces 2015 +305,Baliza No. 02,-75.5245,10.304,BCNLAT,2,Fl,,3,red,2,3,4.5,,Poste cilindrico rojo,Fl. R 2 s,DIMAR Lista de Luces 2015 diff --git a/capas_ctg/BOYCAR.csv b/capas_ctg/BOYCAR.csv new file mode 100644 index 0000000..288bfde --- /dev/null +++ b/capas_ctg/BOYCAR.csv @@ -0,0 +1,11 @@ +no_dimar,OBJNAM,lon,lat,feat_type,CATCAM,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,VALNMR,HEIGHT,ORIENT,INFORM,_dimar_char_raw,_source +256,Boya SS,-75.527500,10.331833,BOYCAR,3,4,Q,(6)+,1,white,15,4,4,,Castillete Cardinal S negros - Senala Bajo Santa Cruz,Q.(6)+LFl.W 15s,AAN-DIMAR-2024-770 +266,Boya SN,-75.526667,10.3445,BOYCAR,1,4,Q,,1,white,1,3,4,,Castillete Cardinal N negros - Senala Bajo Santa Cruz,Q. W 1 s,DIMAR Lista de Luces 2015 +289,Boya VN,-75.5425,10.399833,BOYCAR,1,4,Q,,1,white,1,3,4,,Castillete Cardinal N negros - Senalizacion Bajo la Virgen,Q. W 1 s,DIMAR Lista de Luces 2015 +290,Boya VS,-75.5415,10.393667,BOYCAR,3,4,Q,6,1,white,15,3,4,,Castillete Cardinal S negros - Senalizacion Bajo la Virgen,Q.(6)+Fl. W 15 s,DIMAR Lista de Luces 2015 +297,Boya BB1,-75.516167,10.326167,BOYCAR,4,4,Q,9,1,white,15,3,4,,Castillete Cardinal W negros - Senala Bajo Brujas,Q.(9) W 15 s,DIMAR Lista de Luces 2015 +298,Boya BB2,-75.515167,10.322500,BOYCAR,4,4,Q,9,1,white,15,3,4,,Castillete Cardinal W negros - Senala Bajo Brujas,Q.(9) W 15 s,AAN-DIMAR-2025-180 +299,Boya SN (Salmedina),-75.648022,10.384530,BOYCAR,1,4,Q,,1,white,1,3,4,,Castillete Cardinal N negros - Senala Bancos de Salmedina,Q. W 1 s,AAN-DIMAR-2025-261 +300,Boya SS (Salmedina),-75.651000,10.364833,BOYCAR,3,4,Q,6,1,white,15,3,4,,Castillete Cardinal S negros - Senala Bancos de Salmedina,Q.(6)+Fl. W 15 s,AAN-DIMAR-2024-246 +301,Boya SE (Salmedina),-75.635850,10.380173,BOYCAR,2,4,Q,3,1,white,10,3,4,,Castillete Cardinal E negros - Senala Bancos de Salmedina,Q.(3) W 10 s,AAN-DIMAR-2025-154 +302,Boya SO (Salmedina),-75.689133,10.381967,BOYCAR,4,4,Q,9,1,white,10,3,4,,Castillete Cardinal W negros - Senala Bancos de Salmedina,Q.(9) W 10 s,AAN-DIMAR-2024-229 diff --git a/capas_ctg/BOYISD.csv b/capas_ctg/BOYISD.csv new file mode 100644 index 0000000..3fbdd9c --- /dev/null +++ b/capas_ctg/BOYISD.csv @@ -0,0 +1,3 @@ +no_dimar,OBJNAM,lon,lat,feat_type,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,VALNMR,HEIGHT,ORIENT,INFORM,_dimar_char_raw,_source +258,Boya Peligro Aislado Polvorines,-75.536,10.351167,BOYISD,2,Fl,2,1,white,5,4,2.5,,Castillete roja bandas negras,Fl.(2) W 5 s,DIMAR Lista de Luces 2015 +259,Boya TT,-75.521,10.366667,BOYISD,2,Fl,2,1,white,5,3,4,,Castillete roja bandas negras - Boya de Peligro Aislado La Tata,Fl.(2) W 5 s,DIMAR Lista de Luces 2015 diff --git a/capas_ctg/BOYLAT.csv b/capas_ctg/BOYLAT.csv new file mode 100644 index 0000000..75c2739 --- /dev/null +++ b/capas_ctg/BOYLAT.csv @@ -0,0 +1,65 @@ +no_dimar,OBJNAM,lon,lat,feat_type,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,VALNMR,HEIGHT,ORIENT,INFORM,_dimar_char_raw,_source +242,Boya No. 1,-75.588833,10.318,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +243,Boya No. 2,-75.589167,10.314833,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +244,Boya No. 3,-75.584833,10.316667,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +245,Boya No. 4,-75.585333,10.315167,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +246,Boya No. 5,-75.580667,10.316833,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2023-268 +247,Boya No. 6,-75.580667,10.3155,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +248,Boya No. 7,-75.576083,10.317598,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2022-067 +249,Boya No. 8,-75.576167,10.315833,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,AAN-DIMAR-2025-315 +250,Boya No. 9,-75.572333,10.317667,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +251,Boya No. 10,-75.572167,10.316,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +252,Boya No. 11,-75.565517,10.318358,BOYLAT,4,Q,,4,green,1,3,4,,Castillete verde - Boya de viraje,Q. G 1 s,AAN-DIMAR-2022-193 +253,Boya No. 12,-75.563,10.314167,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +254,Boya No. 13,-75.556333,10.3235,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +255,Boya No. 15,-75.551500,10.327333,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2025-403 +260,Boya No. 17,-75.545833,10.331333,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2021-325 +261,Boya No. 18,-75.540750,10.320367,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,AAN-DIMAR-2024-328 +262,Boya No. 19,-75.540667,10.319333,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +263,Boya No. 20,-75.518,10.3305,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +264,Boya No. 21,-75.524000,10.320000,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2021-237 +265,Boya No. 22,-75.517350,10.335050,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,AAN-DIMAR-2025-038 +267,Boya No. 23,-75.533080,10.341723,BOYLAT,4,Q,,4,green,1,3,4,,Castillete verde - Boya de viraje,Q. G 1 s,AAN-DIMAR-2023-108 +268,Boya No. 24,-75.529667,10.340333,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +269,Boya No. 25,-75.532500,10.348333,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2023-183 +270,Boya No. 26,-75.518215,10.347442,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,AAN-DIMAR-2024-273 +271,Boya No. 27,-75.532287,10.355342,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2022-626 +272,Boya No. 28,-75.520167,10.358833,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,AAN-DIMAR-2025-452 +273,Boya No. 29,-75.5365,10.364167,BOYLAT,4,Q,,4,green,1,3,4,,Castillete verde - Boya de viraje,Q. G 1 s,DIMAR Lista de Luces 2015 +274,Boya No. 30,-75.522667,10.364833,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +275,Boya No. 31,-75.5445,10.368167,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +276,Boya No. 32,-75.533200,10.378533,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,AAN-DIMAR-2025-625 +277,Boya No. 33,-75.543833,10.391333,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2024-263 +278,Boya No. 34,-75.539833,10.393167,BOYLAT,4,Q,,3,red,1,3,4,,Castillete roja - Boya de viraje,Q. R 1 s,DIMAR Lista de Luces 2015 +279,Boya No. 35,-75.544500,10.394000,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2022-243 +280,Boya No. 36,-75.544167,10.396167,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,AAN-DIMAR-2023-159 +281,Boya No. 37,-75.540017,10.396500,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2024-171 +282,Boya No. 38,-75.538667,10.395167,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +283,Boya No. 39,-75.539668,10.398237,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2021-150 +284,Boya No. 40,-75.536833,10.397333,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +285,Boya No. 41,-75.545833,10.395147,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2023-016 +286,Boya No. 42,-75.544672,10.397017,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,AAN-DIMAR-2022-154 +287,Boya No. 43,-75.550150,10.400018,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2023-371 +288,Boya No. 45,-75.55,10.4065,BOYLAT,2,Fl,,4,green,3,3,4,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +291,Boya No. 48,-75.545167,10.412000,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja,Fl. R 3 s,AAN-DIMAR-2025-089 +294,Boya E2,-75.571,10.389667,BOYLAT,2,Fl,,3,red,3,4,3,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +295,Boya E1,-75.570667,10.39,BOYLAT,2,Fl,,4,green,3,3,3,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +307,Boya No. 50,-75.547167,10.415167,BOYLAT,2,Fl,,3,red,3,3,4,,Castillete roja rojo,Fl. R 3 s,DIMAR Lista de Luces 2015 +308,Boya No. 51,-75.547500,10.415167,BOYLAT,2,Fl,,4,green,3,3,3,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2024-296 +309,Boya No. 52,-75.549305,10.417628,BOYLAT,2,Fl,,3,red,3,3,3,,Castillete roja,Fl. R 3 s,AAN-DIMAR-2023-032 +310,Boya No. 53,-75.549918,10.419850,BOYLAT,2,Fl,,4,green,3,3,3,,Castillete verde,Fl. G 3 s,AAN-DIMAR-2022-262 +311,Boya No. 54,-75.5495,10.419,BOYLAT,2,Fl,,3,red,3,3,3,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +312,Boya No. 55,-75.55,10.419333,BOYLAT,2,Fl,,4,green,3,3,3,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +314,Boya No. 56,-75.55,10.420333,BOYLAT,2,Fl,,3,red,3,3,3,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +317,Boya Bifurcacion,-75.529333,10.402667,BOYLAT,2,Fl,,4,green,3,4,3,,Castillete verde bandas rojas,Fl. G 3 s,DIMAR Lista de Luces 2015 +318,Boya Verde,-75.529167,10.4035,BOYLAT,2,Fl,,4,green,3,4,3,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +325,Boya No. 1 (Sector Compas),-75.529833,10.402167,BOYLAT,2,Fl,,4,green,3,,3,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +326,Boya No. 2 (Sector Compas),-75.5305,10.401167,BOYLAT,2,Fl,,3,red,3,,3,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +327,Boya No. 3 (Sector Compas),-75.528167,10.4015,BOYLAT,2,Fl,,4,green,3,,3,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +328,Boya No. 4 (Sector Compas),-75.528667,10.400667,BOYLAT,2,Fl,,3,red,3,,3,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +329,Boya No. 5 (Sector Compas),-75.526167,10.4005,BOYLAT,2,Fl,,4,green,3,,3,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +330,Boya No. 6 (Sector Compas),-75.527833,10.4,BOYLAT,2,Fl,,3,red,3,,3,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +331,Boya No. 7 (Sector Compas),-75.525667,10.399833,BOYLAT,2,Fl,,4,green,3,,3,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +332,Boya No. 8 (Sector Compas),-75.532167,10.397667,BOYLAT,2,Fl,,3,red,3,,3,,Castillete roja,Fl. R 3 s,DIMAR Lista de Luces 2015 +333,Boya No. 9 (Sector Compas),-75.525333,10.399333,BOYLAT,2,Fl,,4,green,3,,3,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 +334,Boya No. 13 (Sector Compas),-75.531167,10.398833,BOYLAT,2,Fl,,4,green,3,,3,,Castillete verde,Fl. G 3 s,DIMAR Lista de Luces 2015 diff --git a/capas_ctg/BOYSPEC.csv b/capas_ctg/BOYSPEC.csv new file mode 100644 index 0000000..f9b9627 --- /dev/null +++ b/capas_ctg/BOYSPEC.csv @@ -0,0 +1,5 @@ +no_dimar,OBJNAM,lon,lat,feat_type,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,VALNMR,HEIGHT,ORIENT,INFORM,_dimar_char_raw,_source +303,Boya Metocean,-75.609667,10.3265,BOYSPEC,2,Fl,,6,yellow,20,7,3.5,,Castillete Amarillo. Sistema datos oceanograficos y meteorologicos.,Fl. Y 20 s,DIMAR Lista de Luces 2015 +306,Boya Especial,-75.520333,10.3035,BOYSPEC,2,Fl,,6,yellow,10,1.5,3.5,,Cilindrica amarilla,Fl. Y 10 s,DIMAR Lista de Luces 2015 +313,Boya BA7,-75.549667,10.420833,BOYSPEC,2,Fl,,6,yellow,3,3,2.6,,Castillete amarilla,Fl. Y 3 s,DIMAR Lista de Luces 2015 +335,Boya Especial No. 3 Isla Manzanillo,-75.530833,10.3955,BOYSPEC,2,Fl,2,6,yellow,10,3,4,,Castillete amarilla,Fl.(2) Y 10 s,DIMAR Lista de Luces 2015 diff --git a/capas_ctg/LIGHTS.csv b/capas_ctg/LIGHTS.csv new file mode 100644 index 0000000..ab86ef5 --- /dev/null +++ b/capas_ctg/LIGHTS.csv @@ -0,0 +1,19 @@ +no_dimar,OBJNAM,lon,lat,feat_type,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,VALNMR,HEIGHT,ORIENT,INFORM,_dimar_char_raw,_source +32,Faro Punta Morro Hermoso,-75.0175,10.963333,LIGHTS,2,Fl,,1,white,4,28,134,,Torre blanca bandas rojas. Giratorio.,Fl. W 4 s,DIMAR Lista de Luces 2015 +33,Faro Galerazamba,-75.266,10.785333,LIGHTS,2,Fl,,1,white,4,11,14,,Torre fibra de vidrio blanca bandas rojas. Giratorio.,Fl. W 4 s,DIMAR Lista de Luces 2015 +34,Faro Punta Canoas,-75.499167,10.573,LIGHTS,2,Fl,2,1,white,20,12,96,,Torre roja bandas blancas. Giratorio.,Fl.(2) W 20 s,DIMAR Lista de Luces 2015 +35,Faro Castillogrande,-75.545,10.391,LIGHTS,2,Fl,,1,white,15,12,24,,Torre en concreto color beige.,Fl. W 15 s,DIMAR Lista de Luces 2015 +36,Faro Salmedina,-75.651333,10.378333,LIGHTS,2,Fl,,1,white,10,8,5,,Torre roja bandas blancas.,Fl. W 10 s,DIMAR Lista de Luces 2015 +37,Faro Tierrabomba,-75.581,10.34,LIGHTS,2,Fl,,1,white,12,26,112,,Torre roja bandas blancas. Giratorio.,Fl. W 12 s,DIMAR Lista de Luces 2015 +38,Faro Isla Tesoro,-75.740667,10.235333,LIGHTS,2,Fl,,1,white,6.6,13,20,,Torre metalica roja y blanca.,Fl. W 6.6 s,DIMAR Lista de Luces 2015 +39,Faro Isla de Rosario,-75.8005,10.168,LIGHTS,2,Fl,3,1,white,10,12,14,,Torre metalica roja y blanca.,Fl.(3) W 10 s,DIMAR Lista de Luces 2015 +40,Faro Isla Mucura,-75.87,9.7835,LIGHTS,2,Fl,,1,white,6.7,11,20,,Torre roja bandas blancas. Giratorio.,Fl. W 6.7 s,DIMAR Lista de Luces 2015 +41,Faro Isla Arena,-75.726833,10.144833,LIGHTS,2,Fl,,1,white,12,13,20,,Torre metalica roja y blanca. Giratorio.,Fl. W 12 s,DIMAR Lista de Luces 2015 +42,Faro Ceycen,-75.855833,9.692833,LIGHTS,2,Fl,,1,white,12,17,20,,Torre roja bandas blancas. Giratorio.,Fl. W 12 s,DIMAR Lista de Luces 2015 +43,Faro Roca Morrosquillo,-75.992167,9.591333,LIGHTS,2,Fl,,1,white,3,10,6,,Torre roja bandas blancas.,Fl. W 3 s,DIMAR Lista de Luces 2015 +296,Luz de Aproximacion,-75.5495,10.409,LIGHTS,2,Fl,,5,blue,2.5,11,37,,Torre metalica Roja y blanca,Fl. Bu 2.5 s,DIMAR Lista de Luces 2015 +257,Enfilacion de Bocachica B,-75.508833,10.320833,LIGHTS,7,Iso,,5,blue,4,12,33,,Torre enrejada rojo bandas blancas,,DIMAR Lista de Luces 2015 +292,Enfilacion No. 1,-75.530500,10.389333,LIGHTS,4,Q,,6,yellow,1,10,22,0,,Torre enrejada roja bandas blancas. Calibracion compases.,Q. Am 1 s,DIMAR Lista de Luces 2015 +293,Enfilacion No. 2,-75.530500,10.388500,LIGHTS,4,Q,,6,yellow,1,10,18,0,,Torre enrejada roja bandas blancas. Calibracion compases.,Q. Am 1 s,DIMAR Lista de Luces 2015 +322,Baliza de Enfilacion No. 1,-75.524667,10.405000,LIGHTS,2,Fl,,6,yellow,3,5,4,,,Torre amarilla. Marca de dia: amarillo-negro-amarillo.,Fl. Am 3 s,DIMAR Lista de Luces 2015 +323,Baliza de Enfilacion No. 2,-75.524167,10.405167,LIGHTS,2,Fl,,6,yellow,3,5,6,,,Torre amarilla. Marca de dia: amarillo-negro-amarillo.,Fl. Am 3 s,DIMAR Lista de Luces 2015 diff --git a/cell_config.json b/cell_config.json new file mode 100644 index 0000000..1ca169d --- /dev/null +++ b/cell_config.json @@ -0,0 +1,105 @@ +{ + "_comment": "S-57 cell metadata. Edit before converting.", + "cell_name": "CO1CO01M", + "cell_edition": 1, + "update_number": 0, + "issue_date": "20260427", + "producer_code": "CO", + "producer_name": "Custom Chart", + "data_set_name": "My ENC Chart", + "scale": 50000, + "comment": "Generated by QGIS S-57 Converter", + "horizontal_datum": "WGS84", + "vertical_datum": "MLLW", + "sounding_datum": "MLLW", + "compilation_scale": 50000, + "layer_mappings": { + "_comment": "Map your QGIS layer names to S-57 object class acronyms. Case-insensitive.", + "coastline": "COALNE", + "coast_line": "COALNE", + "linea_de_costa": "COALNE", + "costa": "COALNE", + "land": "LNDARE", + "tierra": "LNDARE", + "tierra_firme": "LNDARE", + "depth_area": "DEPARE", + "area_profundidad": "DEPARE", + "fondos": "DEPARE", + "batimetria": "DEPARE", + "depth_contour": "DEPCNT", + "isobata": "DEPCNT", + "curvas_nivel": "DEPCNT", + "soundings": "SOUNDG", + "sondas": "SOUNDG", + "profundidades": "SOUNDG", + "lights": "LIGHTS", + "luces": "LIGHTS", + "faroles": "LIGHTS", + "boyas": "BOYLAT", + "buoys": "BOYLAT", + "balizas": "BCNLAT", + "beacons": "BCNLAT", + "anchorage": "ACHARE", + "fondeadero": "ACHARE", + "harbor": "HRBARE", + "puerto": "HRBARE", + "berth": "BERTHS", + "atraque": "BERTHS", + "wreck": "WRECKS", + "naufragio": "WRECKS", + "obstruction": "OBSTRN", + "obstruccion": "OBSTRN", + "rocks": "UWTROC", + "rocas": "UWTROC", + "fairway": "FAIRWY", + "canal_navegacion": "FAIRWY", + "restricted": "RESARE", + "zona_restringida": "RESARE", + "cable": "CBLSUB", + "tuberia": "PIPSOL", + "bridge": "BRIDGE", + "puente": "BRIDGE", + "river": "RIVERS", + "rio": "RIVERS", + "seabed": "SBDARE", + "Linderos": "COALNE", + "Puntos del Terreno": "LNDMRK", + "Área Terreno": "LNDARE", + "Área Terreno taxable": "LNDARE", + "fondo_marino": "SBDARE" + }, + "attribute_mappings": { + "_comment": "Map your SHP field names to S-57 attribute names.", + "name": "OBJNAM", + "nombre": "OBJNAM", + "height": "HEIGHT", + "altura": "HEIGHT", + "colour": "COLOUR", + "color": "COLOUR", + "colpat": "COLPAT", + "patron": "COLPAT", + "catlam": "CATLAM", + "lateral": "CATLAM", + "boyshp": "BOYSHP", + "forma": "BOYSHP", + "litchr": "LITCHR", + "destello": "LITCHR", + "sigper": "SIGPER", + "periodo": "SIGPER", + "siggrp": "SIGGRP", + "grupo": "SIGGRP", + "alcance": "VALNMR", + "range": "VALNMR", + "valnmr": "VALNMR", + "status": "STATUS", + "estado": "STATUS", + "depth": "DRVAL1", + "profundidad":"DRVAL1", + "depth_min": "DRVAL1", + "depth_max": "DRVAL2", + "contour": "VALDCO", + "valor": "VALDCO", + "sounding": "VALSOU", + "sonda": "VALSOU" + } +} diff --git a/check_s57.py b/check_s57.py new file mode 100644 index 0000000..d30ded1 --- /dev/null +++ b/check_s57.py @@ -0,0 +1,10 @@ +import geopandas as gpd + +path = r"C:\Users\aerom\CO1CO01M.000" +for layer in ["COALNE", "LNDARE", "LNDMRK", "M_COVR"]: + try: + gdf = gpd.read_file(path, layer=layer) + nulls = gdf.geometry.isna().sum() + print(f"{layer}: {len(gdf)} features, {nulls} sin geometria") + except Exception as e: + print(f"{layer}: ERROR - {e}") diff --git a/converter.py b/converter.py new file mode 100644 index 0000000..9af49f1 --- /dev/null +++ b/converter.py @@ -0,0 +1,881 @@ +#!/usr/bin/env python3 +""" +QGIS -> S-57 ENC Converter +Zero external dependencies for geometry: reads SHP/DBF using struct only. +pyproj is used for reprojection (optional — falls back to passthrough if missing). + +Usage: + python converter.py myproject.qgs + python converter.py myproject.qgz --output my_chart.000 + python converter.py myproject.qgs --list + python converter.py myproject.qgs --config cell_config.json +""" + +import argparse +import json +import math +import struct +import sys +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path +from datetime import datetime + +SCRIPT_DIR = Path(__file__).parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from s57_writer import ( + S57Cell, + OBJL_BY_ACRONYM, ATTR_CODE, + OBJL_LIGHTS, + ATTL_CATCOV, ATTL_VALSOU, ATTL_LITCHR, ATTL_COLOUR, +) + +try: + from pyproj import CRS, Transformer + PYPROJ_AVAILABLE = True +except Exception: + PYPROJ_AVAILABLE = False + +# ── SHP shape-type sets ─────────────────────────────────────────────────────── +_PT = {1, 11, 21} +_MPT = {8, 18, 28} +_LINE = {3, 13, 23} +_POLY = {5, 15, 25} + +# ── minimal DBF reader (stdlib only) ───────────────────────────────────────── +def _read_dbf(dbf_path: Path): + """Return (field_names, list_of_dicts) from a dBASE III .dbf file.""" + for enc in ("utf-8", "latin-1", "cp1252"): + try: + with open(dbf_path, "rb") as f: + f.read(4) # version + date + nrec = struct.unpack("ii", hdr)[1] * 2 + content = f.read(content_len) + if len(content) < 4: + break + stype = struct.unpack_from(" max_d: + max_d, max_i = d, i + if max_d > tol_sq: + keep[max_i] = True + stack.append((s, max_i)) + stack.append((max_i, e)) + return [p for p, k in zip(pts, keep) if k] + +def _simplify_coords(coords, max_verts=_MAX_VERTICES, tol_deg=_RDP_TOL_DEG): + """ + Simplify a coordinate list so it fits within the ISO 8211 record limit. + 1) Apply RDP at tol_deg. + 2) If still > max_verts, apply RDP at escalating tolerance until fits. + """ + if len(coords) <= max_verts: + return coords + tol_sq = tol_deg ** 2 + result = _rdp(coords, tol_sq) + # Escalate tolerance if still too many + factor = 10.0 + while len(result) > max_verts and factor < 1e6: + result = _rdp(coords, (tol_deg * factor) ** 2) + factor *= 10.0 + # Last resort: uniform decimation + if len(result) > max_verts: + step = math.ceil(len(result) / max_verts) + result = result[::step] + if result[-1] != coords[-1]: + result.append(coords[-1]) # keep last point + n_in, n_out = len(coords), len(result) + if n_out < n_in: + print(f" [simplify] {n_in} -> {n_out} vertices (tol ~{tol_deg*factor:.6f}°)") + return result + +# ── S-57 object catalog ─────────────────────────────────────────────────────── +def load_s57_objects(): + path = SCRIPT_DIR / "s57_objects.json" + if path.exists(): + with open(path, encoding="utf-8") as f: + data = json.load(f) + return {k: v for k, v in data.items() if not k.startswith("_")} + return {} + +S57_OBJECTS = load_s57_objects() + +# ── config ──────────────────────────────────────────────────────────────────── +def load_config(path=None): + cfg_path = Path(path) if path else SCRIPT_DIR / "cell_config.json" + if not cfg_path.exists(): + print(f"[WARN] Config not found: {cfg_path}. Using defaults.") + return _default_config() + with open(cfg_path, encoding="utf-8") as f: + cfg = json.load(f) + cfg = {k: v for k, v in cfg.items() if not k.startswith("_")} + cfg.setdefault("layer_mappings", {}) + cfg.setdefault("attribute_mappings", {}) + return cfg + +def _default_config(): + return { + "cell_name": "XX1XX01M", "cell_edition": 1, "update_number": 0, + "issue_date": datetime.now().strftime("%Y%m%d"), + "producer_code": "XX", "producer_name": "Custom", + "data_set_name": "ENC Chart", "scale": 50000, "comment": "", + "horizontal_datum": "WGS84", "vertical_datum": "MLLW", + "sounding_datum": "MLLW", "compilation_scale": 50000, + "layer_mappings": {}, "attribute_mappings": {}, + } + +# ── QGIS project parser ─────────────────────────────────────────────────────── +class QGISProject: + def __init__(self, project_path): + self.project_path = Path(project_path) + self.base_dir = self.project_path.parent + self.layers = [] + self._parse() + + def _parse(self): + if self.project_path.suffix.lower() == ".qgz": + self._parse_qgz() + else: + self._parse_qgs(self.project_path) + + def _parse_qgz(self): + with zipfile.ZipFile(self.project_path, "r") as z: + qgs_files = [f for f in z.namelist() if f.endswith(".qgs")] + if not qgs_files: + raise ValueError("No .qgs file inside .qgz") + with z.open(qgs_files[0]) as f: + content = f.read().decode("utf-8") + tmp = self.project_path.parent / "_tmp_project.qgs" + tmp.write_text(content, encoding="utf-8") + self._parse_qgs(tmp) + tmp.unlink(missing_ok=True) + + def _parse_qgs(self, qgs_path): + tree = ET.parse(qgs_path) + root = tree.getroot() + for ltl in root.iter("layer-tree-layer"): + lid = ltl.get("id", "") + name = ltl.get("name", "unnamed") + vis = ltl.get("checked", "Qt::Checked") != "Qt::Unchecked" + ml = self._find_maplayer(root, lid) + if ml is None or ml.get("type", "") != "vector": + continue + ds = ml.find("datasource") + if ds is None: + continue + ds_text = (ds.text or "").strip() + crs_el = ml.find(".//srs/spatialrefsys/authid") + crs = crs_el.text if crs_el is not None else "EPSG:4326" + + # ── Capa SHP ────────────────────────────────────────────────── + shp = self._resolve_path(ds_text.split("|")[0].strip()) + if shp is not None and str(shp).lower().endswith(".shp"): + self.layers.append({ + "id": lid, "name": name, "path": shp, + "crs": crs, "visible": vis, "layer_type": "shp", + }) + continue + + # ── Capa CSV / texto delimitado ─────────────────────────────── + # QGIS guarda: file:///ruta/al/archivo.csv?delimiter=,&xField=lon&yField=lat&... + csv_path = self._resolve_csv_path(ds_text) + if csv_path is not None: + # Leer xField / yField del URI + x_field = "lon" + y_field = "lat" + for part in ds_text.split("?")[-1].split("&"): + if part.startswith("xField="): + x_field = part.split("=", 1)[1] + elif part.startswith("yField="): + y_field = part.split("=", 1)[1] + self.layers.append({ + "id": lid, "name": name, "path": csv_path, + "crs": crs, "visible": vis, "layer_type": "csv", + "x_field": x_field, "y_field": y_field, + }) + + def _resolve_csv_path(self, ds_text): + """Extrae y resuelve la ruta de una datasource CSV de QGIS.""" + import urllib.parse + # Formatos: file:///C:/ruta/file.csv?... o /ruta/file.csv + raw = ds_text.split("?")[0] + if raw.startswith("file:///"): + raw = raw[8:] # quitar file:/// + elif raw.startswith("file://"): + raw = raw[7:] + raw = urllib.parse.unquote(raw) + p = Path(raw) + if p.exists() and p.suffix.lower() == ".csv": + return p + # Intentar resolver relativo al proyecto + rel = self.base_dir / raw + if rel.exists() and rel.suffix.lower() == ".csv": + return rel.resolve() + return None + + def _find_maplayer(self, root, lid): + for ml in root.iter("maplayer"): + el = ml.find("id") + if el is not None and el.text == lid: + return ml + return None + + def _resolve_path(self, path_str): + p = Path(path_str) + if p.is_absolute() and p.exists(): + return p + rel = self.base_dir / path_str + if rel.exists(): + return rel.resolve() + for c in self.base_dir.rglob(p.name): + return c + return None + +# ── layer -> S-57 class resolver ───────────────────────────────────────────── +def resolve_s57_class(layer_name, layer_mappings): + nl = layer_name.lower().strip() + if nl in layer_mappings: + return layer_mappings[nl].upper() + for key, val in layer_mappings.items(): + if key in nl or nl in key: + return val.upper() + nu = layer_name.upper().strip() + if nu in S57_OBJECTS: + return nu + for acro, info in S57_OBJECTS.items(): + if any(w in nl for w in info["desc"].lower().split() if len(w) > 3): + return acro + return None + +# ── SHP feature iterator (stdlib only) ─────────────────────────────────────── +def iter_shapes(shp_path: Path, crs_str: str, attr_map: dict): + """Yield (geom_type, coords_wgs84, mapped_attrs) using only stdlib.""" + + # Reprojection + transformer = None + if PYPROJ_AVAILABLE and crs_str: + try: + src = CRS.from_user_input(crs_str) + wgs84 = CRS.from_epsg(4326) + if not src.equals(wgs84): + transformer = Transformer.from_crs(src, wgs84, always_xy=True) + except Exception as e: + print(f" [WARN] Reprojection unavailable ({e}); assuming WGS84") + + def tr(x, y): + return transformer.transform(x, y) if transformer else (x, y) + + def tr_pts(pts): + return [tr(p[0], p[1]) for p in pts] + + # Read attributes from .dbf + dbf_path = shp_path.with_suffix(".dbf") + _, dbf_rows = _read_dbf(dbf_path) if dbf_path.exists() else ([], []) + + # Iterate geometry + for idx, (stype, pts, parts) in enumerate(_read_shp(shp_path)): + if stype == 0: + continue + + raw = dbf_rows[idx] if idx < len(dbf_rows) else {} + mapped = [] + for shp_col, s57_acro in attr_map.items(): + if shp_col in raw: + attl = ATTR_CODE.get(s57_acro.upper()) + if attl is not None: + mapped.append((attl, raw[shp_col])) + + # ── Auto-detect: SHP column name == S-57 attribute acronym ────────── + already_attls = {a for a, _ in mapped} + raw_upper = {k.upper(): v for k, v in raw.items()} + for s57_acro, attl in ATTR_CODE.items(): + if attl in already_attls: + continue + if s57_acro in raw_upper: + val = raw_upper[s57_acro] + val_str = str(val).strip() if val is not None else "" + # Skip DBF nulls: empty, all-asterisks, or all-zeros (numeric null) + if val_str and not all(c in "*0 " for c in val_str): + mapped.append((attl, val_str)) + already_attls.add(attl) + + # ── COLOUR_TXT override: text name → correct S-57 colour code ─────── + # SHP/QGIS may store COLOUR as 0-indexed or wrong-offset numeric; + # COLOUR_TXT (the human name) is the ground truth. + _CNAME = { + "white": "1", "black": "2", "red": "3", "green": "4", + "blue": "5", "yellow": "6", "grey": "7", "gray": "7", + "brown": "8", "amber": "9", "orange": "11", + "magenta": "12", "violet": "13", + } + attl_colour = ATTR_CODE.get("COLOUR") + if attl_colour is not None and "COLOUR_TXT" in raw_upper: + cname = raw_upper["COLOUR_TXT"].lower().strip() + s57c = _CNAME.get(cname) + if s57c: + mapped = [(a, v) for a, v in mapped if a != attl_colour] + mapped.append((attl_colour, s57c)) + already_attls.discard(attl_colour) + already_attls.add(attl_colour) + + # ── Infer CATLAM from colour when absent (IALA B: green=port, red=stbd) + attl_catlam = ATTR_CODE.get("CATLAM") + if attl_catlam is not None and attl_catlam not in already_attls and attl_colour is not None: + colour_val = next((v for a, v in mapped if a == attl_colour), None) + if colour_val == "4": # green + mapped.append((attl_catlam, "1")) # port-hand + elif colour_val == "3": # red + mapped.append((attl_catlam, "2")) # starboard-hand + + if stype in _PT and pts: + yield "point", [tr(*pts[0])], mapped + + elif stype in _MPT: + for pt in pts: + yield "point", [tr(*pt)], mapped + + elif stype in _LINE: + bounds = list(parts) + [len(pts)] + for i in range(len(bounds) - 1): + seg = pts[bounds[i]:bounds[i+1]] + if len(seg) >= 2: + yield "line", tr_pts(seg), mapped + + elif stype in _POLY: + bounds = list(parts) + [len(pts)] + for i in range(len(bounds) - 1): + ring = pts[bounds[i]:bounds[i+1]] + if len(ring) < 3: + continue + # ESRI outer rings are CW (negative shoelace area); skip CCW holes + if _signed_area([(p[0], p[1]) for p in ring]) > 0: + continue + yield "polygon", tr_pts(ring), mapped + +# ── S-57 cell writer ────────────────────────────────────────────────────────── +class S57CellWriter: + def __init__(self, output_path, config): + self.output_path = Path(output_path) + self.cfg = config + self._cell = None + self._bbox = None + self._feature_counter = {} + + def open(self): + cfg = self.cfg + issue = cfg.get("issue_date") or datetime.now().strftime("%Y%m%d") + self._cell = S57Cell( + dsnm = cfg.get("cell_name", "CHART01") + ".000", + edition = int(cfg.get("cell_edition", 1)), + intu = 5, + scale = int(cfg.get("scale", 50000)), + agen = 999, + comt = cfg.get("comment", "Generated by QGISS57Converter"), + issue_date = issue, + ) + + def _update_bbox(self, coords): + for x, y in coords: + if self._bbox is None: + self._bbox = [x, y, x, y] + else: + if x < self._bbox[0]: self._bbox[0] = x + if y < self._bbox[1]: self._bbox[1] = y + if x > self._bbox[2]: self._bbox[2] = x + if y > self._bbox[3]: self._bbox[3] = y + + def add_features_from_csv(self, csv_path: Path, s57_class: str, + attr_map: dict, x_field: str = "lon", + y_field: str = "lat") -> int: + """Lee una capa CSV de QGIS (puntos) y la convierte a features S-57. + + Estándar IHO S-57: usa los nombres de atributo S-57 como cabeceras de + columna (LITCHR, COLOUR, VALNMR, BOYSHP, CATLAM, …). El converter los + recoge automáticamente sin necesidad de ningún mapeo adicional. + + Columna especial feat_type: + Si una fila tiene la columna 'feat_type' con un acrónimo S-57 + válido (BCNLAT, BOYLAT, LIGHTS, …), esa fila usa ese objeto en + lugar del s57_class del nivel de capa. Esto permite mezclar tipos + en un solo CSV (p.ej. la carta de Barranquilla que incluye BCNLAT, + BOYLAT, LIGHTS y BOYCAR en el mismo archivo). + + Luces compañeras (companion LIGHTS): + Cuando una estructura física (BCNLAT, BOYLAT, BCNCAR, BOYCAR, + BOYISD, BOYSAW, BOYSPP, LNDMRK) tiene LITCHR definido, el + converter emite además un objeto LIGHTS co-ubicado con solo los + atributos de luz. Así, el ECDIS puede mostrar tanto el símbolo 3D + de la estructura como la descripción de luz en el tooltip, igual + que en las cartas NOAA. + + Columnas privadas (prefijo _): + Las columnas que empiezan por _ (p.ej. _source, _dimar_char_raw) + se ignoran y nunca se escriben al S-57. + """ + import csv as _csv + + # LITCHR_TXT → código S-57 oficial (para CSVs con texto legible) + _LITCHR_TXT = { + "f": "1", "fl": "2", "lfl": "3", "q": "4", + "vq": "5", "uq": "6", "iso": "7", "oc": "8", + "iq": "9", "ivq": "10", "iuq": "11", "mo": "12", + "ffl": "13", "fl+lfl":"14", "oc+fl": "15", + "al.oc": "25", "al.lfl":"26", "al.fl": "27", "al.grp":"28", + } + # COLOUR_TXT → código S-57 oficial + _COLOUR_TXT = { + "white":"1", "black":"2", "red":"3", "green":"4", + "blue":"5", "yellow":"6", "grey":"7", "gray":"7", + "brown":"8", "amber":"9", "violet":"10", "orange":"11", "magenta":"12", + } + # S-57 classes that represent physical structures and may carry light attrs + _STRUCT_CLASSES = { + "BCNLAT","BCNCAR","BCNISD","BCNSAW","BCNSPP","BCNWTW", + "BOYLAT","BOYCAR","BOYISD","BOYSAW","BOYSPP", + "LNDMRK","LITFLT","LITVES", + } + # Attribute codes for companion LIGHTS + _LIGHT_ATTL = {ATTR_CODE[a] for a in + ("LITCHR","SIGGRP","SIGPER","COLOUR","VALNMR","HEIGHT", + "SECTR1","SECTR2","ORIENT","MLTYLT","CATLIT","OBJNAM") + if a in ATTR_CODE} + + if self._cell is None: + raise RuntimeError("call open() first") + + count = 0 + with open(csv_path, newline="", encoding="utf-8-sig") as f: + reader = _csv.DictReader(f) + for row in reader: + try: + lon = float(row[x_field]) + lat = float(row[y_field]) + except (KeyError, ValueError): + continue + + # Determine S-57 object class for this row + row_class = (row.get("feat_type") or "").strip().upper() + if not row_class: + row_class = s57_class.upper() + objl = OBJL_BY_ACRONYM.get(row_class) + if objl is None: + print(f" [WARN] Unknown S-57 class '{row_class}' in row, skipping") + continue + + # Build attribute list using S-57 column name auto-detection + already_attls: set[int] = set() + mapped: list[tuple[int, str]] = [] + + raw_upper = {k.upper(): v for k, v in row.items() + if not k.startswith("_") and k not in (x_field, y_field, "feat_type")} + + for s57_acro, attl in ATTR_CODE.items(): + if attl in already_attls: + continue + if s57_acro in raw_upper: + val = raw_upper[s57_acro].strip() + if val and not all(c in "*0 " for c in val): + mapped.append((attl, val)) + already_attls.add(attl) + + # Also apply attribute_mappings from config + for shp_col, s57_acro in attr_map.items(): + attl = ATTR_CODE.get(s57_acro.upper()) + if attl and attl not in already_attls and shp_col.upper() in raw_upper: + val = raw_upper[shp_col.upper()].strip() + if val: + mapped.append((attl, val)) + already_attls.add(attl) + + # LITCHR_TXT override — parse readable chars like "Q(4)G" → 4 + attl_litchr = ATTR_CODE.get("LITCHR") + if attl_litchr and "LITCHR_TXT" in raw_upper and attl_litchr not in already_attls: + txt = raw_upper["LITCHR_TXT"].lower().split("(")[0].strip() + code = _LITCHR_TXT.get(txt) + if code: + mapped.append((attl_litchr, code)) + already_attls.add(attl_litchr) + elif attl_litchr and "LITCHR_TXT" in raw_upper and attl_litchr in already_attls: + # Correct an already-set LITCHR if TXT provides a better value + txt = raw_upper["LITCHR_TXT"].lower().split("(")[0].strip() + code = _LITCHR_TXT.get(txt) + if code: + mapped = [(a, v) for a, v in mapped if a != attl_litchr] + mapped.append((attl_litchr, code)) + + # COLOUR_TXT override + attl_colour = ATTR_CODE.get("COLOUR") + if attl_colour and "COLOUR_TXT" in raw_upper: + cname = raw_upper["COLOUR_TXT"].lower().strip() + s57c = _COLOUR_TXT.get(cname) + if s57c: + mapped = [(a, v) for a, v in mapped if a != attl_colour] + mapped.append((attl_colour, s57c)) + + # Write the main object + self._cell.add_point_feature(objl=objl, lon=lon, lat=lat, + attrs=mapped if mapped else None) + self._update_bbox([(lon, lat)]) + count += 1 + + # ── Companion LIGHTS ───────────────────────────────────────── + # When a physical structure carries light data, emit a co-located + # LIGHTS object so ECDIS proximity-merge picks up the light desc. + if row_class in _STRUCT_CLASSES and attl_litchr in already_attls: + light_attrs = [(a, v) for a, v in mapped if a in _LIGHT_ATTL] + if light_attrs: + self._cell.add_point_feature( + objl=OBJL_LIGHTS, lon=lon, lat=lat, + attrs=light_attrs, + ) + + self._feature_counter[s57_class] = ( + self._feature_counter.get(s57_class, 0) + count + ) + return count + + def add_features_from_shp(self, shp_path: Path, crs_str: str, + s57_class: str, attr_map: dict) -> int: + if self._cell is None: + raise RuntimeError("call open() first") + objl = OBJL_BY_ACRONYM.get(s57_class.upper()) + if objl is None: + print(f" [WARN] Unknown S-57 class '{s57_class}', skipping") + return 0 + + count = 0 + try: + for geom_type, coords, attrs in iter_shapes(shp_path, crs_str, attr_map): + if not coords: + continue + self._update_bbox(coords) + a = attrs or None + if geom_type == "point": + self._cell.add_point_feature(objl=objl, lon=coords[0][0], + lat=coords[0][1], attrs=a) + count += 1 + elif geom_type == "line" and len(coords) >= 2: + coords = _simplify_coords(list(coords)) + if len(coords) >= 2: + self._cell.add_line_feature(objl=objl, coords=coords, attrs=a) + count += 1 + elif geom_type == "polygon" and len(coords) >= 3: + coords = _simplify_coords(list(coords)) + if len(coords) >= 3: + self._cell.add_area_feature(objl=objl, ring=coords, attrs=a) + count += 1 + except AssertionError as e: + print(f" [ERR] ISO 8211 record too large in {shp_path.name}: {e}") + print(f" [ERR] Reduce geometry complexity or increase _MAX_VERTICES in converter.py") + except Exception as e: + print(f" [ERR] {shp_path.name}: {e}") + + self._feature_counter[s57_class] = ( + self._feature_counter.get(s57_class, 0) + count + ) + return count + + def close(self): + if self._cell is None: + return + if self._bbox: + w, s, e, n = self._bbox + buf = 0.001 + ring = [(w-buf, s-buf), (e+buf, s-buf), (e+buf, n+buf), + (w-buf, n+buf), (w-buf, s-buf)] + self._cell.add_area_feature( + objl = OBJL_BY_ACRONYM["M_COVR"], + ring = ring, + attrs = [(ATTL_CATCOV, "1")], + ) + print(" M_COVR 1 Coverage (auto-generated)") + self._cell.write(self.output_path) + self._cell = None + + def summary(self): + print("\nFeatures written:") + total = 0 + for cls, cnt in self._feature_counter.items(): + desc = S57_OBJECTS.get(cls, {}).get("desc", "") + # Replace non-cp1252 chars (e.g. →) to avoid UnicodeEncodeError on Windows console + desc = desc.encode("cp1252", errors="replace").decode("cp1252") + print(f" {cls:<12} {cnt:>5} {desc}") + total += cnt + print(f" {'TOTAL':<12} {total:>5}") + + +S57Writer = S57CellWriter # backward-compat alias + + +# ── main converter ──────────────────────────────────────────────────────────── +def convert(project_path, output_path, config_path, list_only, force, verbose, + extra_csv_dir=None): + print(f"\nQGIS -> S-57 Converter") + print(f"{'='*50}") + print(f"Project : {project_path}") + + cfg = load_config(config_path) + attr_map = {k.lower(): v.upper() for k, v in cfg.get("attribute_mappings", {}).items()} + layer_map = {k.lower(): v.upper() for k, v in cfg.get("layer_mappings", {}).items()} + + print(f"\nParsing QGIS project...") + try: + project = QGISProject(project_path) + except Exception as e: + print(f"[ERROR] Could not parse project: {e}") + sys.exit(1) + + # ── Inyectar CSVs de directorio extra ────────────────────────────────── + extra_layers = [] + if extra_csv_dir: + csv_dir = Path(extra_csv_dir) + if csv_dir.is_dir(): + for csv_file in sorted(csv_dir.glob("*.csv")): + # Evitar duplicados: si la capa ya está en el proyecto, ignorar + already = any( + Path(l["path"]).resolve() == csv_file.resolve() + for l in project.layers if l.get("layer_type") == "csv" + ) + if not already: + extra_layers.append({ + "id": "extra_" + csv_file.stem, + "name": csv_file.stem, + "path": csv_file, + "crs": "EPSG:4326", + "visible": True, + "layer_type": "csv", + "x_field": "lon", + "y_field": "lat", + }) + else: + print(f"[WARN] extra_csv_dir not found: {csv_dir}") + + if not project.layers and not extra_layers: + print("[ERROR] No layers found in project.") + sys.exit(1) + + all_layers = project.layers + extra_layers + shp_count = sum(1 for l in all_layers if l.get("layer_type","shp") == "shp") + csv_count = sum(1 for l in all_layers if l.get("layer_type") == "csv") + print(f"Found {shp_count} SHP + {csv_count} CSV layer(s):\n") + layer_assignments = [] + for layer in all_layers: + s57_class = resolve_s57_class(layer["name"], layer_map) + status = "+" if layer["visible"] else "o" + assigned = s57_class or "?? (unmapped)" + src_tag = " [extra]" if layer["id"].startswith("extra_") else "" + print(f" {status} {layer['name']:<30} -> {assigned}{src_tag}") + layer_assignments.append((layer, s57_class)) + + if list_only: + print("\n[INFO] --list mode. No conversion performed.") + return + + unmapped = [(l, c) for l, c in layer_assignments if c is None] + if unmapped and not force: + print(f"\n[WARN] {len(unmapped)} unmapped layer(s).") + ans = input("Continue anyway? [y/N]: ") + if ans.lower() != "y": + print("Aborted.") + return + + if output_path is None: + cell_name = cfg.get("cell_name", "CHART01").upper() + output_path = Path(project_path).parent / (cell_name + ".000") + else: + output_path = Path(output_path) + + print(f"\nOutput : {output_path}") + print(f"Cell : {cfg.get('cell_name','?')} Scale 1:{cfg.get('scale','?')}") + print() + + output_path.parent.mkdir(parents=True, exist_ok=True) + + writer = S57CellWriter(str(output_path), cfg) + writer.open() + + for layer, s57_class in layer_assignments: + if s57_class is None: + print(f" SKIP {layer['name']} (no S-57 mapping)") + continue + ltype = layer.get("layer_type", "shp") + crs_str = layer.get("crs", "EPSG:4326") + print(f" Converting: {layer['name']} -> {s57_class}") + if ltype == "csv": + if verbose: + print(f" CSV: {layer['path']}") + count = writer.add_features_from_csv( + layer["path"], s57_class, attr_map, + x_field=layer.get("x_field", "lon"), + y_field=layer.get("y_field", "lat"), + ) + else: + shp_path = layer["path"] + if verbose: + print(f" CRS: {crs_str} | SHP: {shp_path}") + if not shp_path.exists(): + print(f" [ERR] File not found: {shp_path}") + continue + count = writer.add_features_from_shp(shp_path, crs_str, s57_class, attr_map) + print(f" {count} feature(s) written") + + writer.close() + writer.summary() + + if output_path.exists(): + print(f"\nOutput file: {output_path} ({output_path.stat().st_size // 1024} KB)") + print("Done.") + + +# ── interactive mode ────────────────────────────────────────────────────────── +def interactive_mode(): + print("\n=== QGIS -> S-57 Converter (Interactive) ===\n") + qgs = input("QGIS project (.qgs or .qgz): ").strip().strip('"') + if not Path(qgs).exists(): + print(f"File not found: {qgs}"); sys.exit(1) + out = input("Output .000 [blank=auto]: ").strip().strip('"') or None + cfg = input("Config file [blank=default]: ").strip().strip('"') or None + convert(qgs, out, cfg, list_only=False, force=False, verbose=True) + + +# ── CLI ─────────────────────────────────────────────────────────────────────── +def main(): + if len(sys.argv) == 1: + interactive_mode() + return + parser = argparse.ArgumentParser( + description="Convert QGIS project SHP layers to S-57 ENC format") + parser.add_argument("project", help=".qgs or .qgz QGIS project file") + parser.add_argument("--output", "-o", help="Output .000 file") + parser.add_argument("--config", "-c", help="JSON config file") + parser.add_argument("--list", "-l", action="store_true", + help="List layers then exit") + parser.add_argument("--force", "-f", action="store_true", + help="Skip prompts for unmapped layers") + parser.add_argument("--verbose", "-v", action="store_true") + args = parser.parse_args() + if not Path(args.project).exists(): + print(f"[ERROR] Not found: {args.project}"); sys.exit(1) + convert(args.project, args.output, args.config, + args.list, args.force, args.verbose) + + +if __name__ == "__main__": + main() diff --git a/dimar_063_2026.pdf b/dimar_063_2026.pdf new file mode 100644 index 0000000..608177c Binary files /dev/null and b/dimar_063_2026.pdf differ diff --git a/dimar_ayudas_barranquilla.csv b/dimar_ayudas_barranquilla.csv new file mode 100644 index 0000000..cf2492e --- /dev/null +++ b/dimar_ayudas_barranquilla.csv @@ -0,0 +1,65 @@ +no_dimar,OBJNAM,lon,lat,feat_type,LITCHR,LITCHR_TXT,SIGGRP,SIGPER,COLOUR,COLOUR_TXT,COLPAT,VALNMR,HEIGHT,ORIENT,CATLAM,CATCAM,BOYSHP,BCNSHP,TOPSHP,INFORM,_dimar_char_raw,_source +13,Faro F1 Recalada,-74.849500,11.106167,LIGHTS,7,Iso,,2,4,green,,9,20,,,,,,,Torre naranja bandas blancas. Faro de Recalada,Iso G 2s,DIMAR Lista de Luces 2015 +14,Faro F2 Recalada,-74.854667,11.106000,LIGHTS,7,Iso,,2,3,red,,13.4,23,,,,,,,Torre naranja bandas blancas. Racon B,Iso R 2s,DIMAR Lista de Luces 2015 +32,Faro Morro Hermoso,-75.017500,10.963333,LIGHTS,2,Fl,,4,1,white,,28,134,,,,,,,Torre blanca bandas rojas. Giratorio,Fl W 4s,DIMAR Lista de Luces 2015 +33,Faro Galerazamba,-75.266000,10.785333,LIGHTS,2,Fl,,4,1,white,,11,14,,,,,,,Torre fibra vidrio blanca bandas rojas. Giratorio,Fl W 4s,DIMAR Lista de Luces 2015 +15,Faro X1,-74.849500,11.102167,BCNLAT,4,Q(4)G,4,11,4,green,,6,6,,1,,,3,,Torre verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +16,Faro X2,-74.853333,11.100000,BCNLAT,4,Q(4)R,4,11,3,red,,6,6,,2,,,3,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +18,Faro X3,-74.847167,11.091333,BCNLAT,4,Q(4)G,4,11,4,green,,6,6,,1,,,3,,Torre verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +17,Faro X4,-74.851667,11.093000,BCNLAT,4,Q(4)R,4,11,3,red,,6,6,,2,,,3,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +19,Faro X5,-74.846667,11.089167,BCNLAT,4,Q(4)G,4,11,4,green,,6,6,,1,,,3,,Torre verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +20,Faro X6,-74.850500,11.087667,BCNLAT,4,Q(4)R,4,11,3,red,,6,6,,2,,,3,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +21,Faro X7,-74.814333,11.041500,BCNLAT,4,Q(4)G,4,11,4,green,,6,8,,1,,,4,,Baliza enrejado verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +22,Faro X8,-74.849167,11.081667,BCNLAT,4,Q(4)R,4,11,3,red,,6,6,,2,,,3,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +23,Faro X9,-74.804833,11.035833,BCNLAT,4,Q(4)G,4,11,4,green,,6,8,,1,,,4,,Baliza enrejado verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +24,Faro X10,-74.848000,11.076000,BCNLAT,4,Q(4)R,4,11,3,red,,6,6,,2,,,3,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +25,Faro X11,-74.795500,11.029833,BCNLAT,4,Q(4)G,4,11,4,green,,6,8,,1,,,4,,Baliza enrejado verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +26,Faro X12,-74.844167,11.065000,BCNLAT,4,Q(4)R,4,11,3,red,,6,6,,2,,,4,,Baliza enrejado roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +27,Faro X13,-74.789667,11.025833,BCNLAT,4,Q(4)G,4,11,4,green,,6,8,,1,,,4,,Baliza enrejado verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +28,Faro X14,-74.839500,11.057833,BCNLAT,4,Q(4)R,4,11,3,red,,6,6,,2,,,3,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +30,Faro X15,-74.785500,11.022833,BCNLAT,4,Q(4)G,4,11,4,green,,6,6,,1,,,4,,Baliza enrejado verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +29,Faro X16,-74.833000,11.050000,BCNLAT,4,Q(4)R,4,11,3,red,,6,6,,2,,,3,,Torre roja bandas blancas,Q(4)R 11s,DIMAR Lista de Luces 2015 +31,Faro X17,-74.778333,11.018667,BCNLAT,4,Q(4)G,4,11,4,green,,6,6,,1,,,4,,Baliza enrejado verde bandas blancas,Q(4)G 11s,DIMAR Lista de Luces 2015 +196,Enfilacion E1,-74.848333,11.103667,LIGHTS,7,Iso,,5,1,white,,13,10,135.7,,,,,,Baliza enrejado naranja y blanco. Rumbo 135.7,Iso Bu 5s,DIMAR Lista de Luces 2015 +197,Enfilacion E3,-74.846333,11.101667,LIGHTS,7,Iso,,5,1,white,,9,22,139.3,,,,,,Torre enrejada naranja y blanco. Rumbo 139.3,Iso Bu 5s,DIMAR Lista de Luces 2015 +198,Enfilacion E3A,-74.845000,11.100333,LIGHTS,7,Iso,,5,1,white,,12.3,20,135.7,,,,,,Torre naranja y blanco. Rumbo 135.7,Iso W 5s,DIMAR Lista de Luces 2015 +201,Enfilacion E4,-74.846833,11.070167,LIGHTS,7,Iso,,4,3,red,,4.5,11,142.3,,,,,,Baliza enrejado naranja bandas blancas. Rumbo 142.3,Iso R 4s,DIMAR Lista de Luces 2015 +203,Enfilacion E6,-74.843667,11.063000,LIGHTS,7,Iso,,4,3,red,,8,12,167.7,,,,,,Baliza enrejado roja bandas blancas. Rumbo 167.7,Iso Bu 4s,DIMAR Lista de Luces 2015 +204,Enfilacion E8,-74.841833,11.058500,LIGHTS,7,Iso,,4,1,white,,14.5,25,167.7,,,,,,Baliza enrejado naranja bandas blancas. Rumbo 167.7,Iso Bu 4s,DIMAR Lista de Luces 2015 +205,Enfilacion E10,-74.841667,11.059833,LIGHTS,7,Iso,,5,4,green,,10,11,167.3,,,,,,Torre naranja bandas blancas. Rumbo 167.3,Iso G 5s,DIMAR Lista de Luces 2015 +206,Enfilacion E12,-74.840667,11.056167,LIGHTS,7,Iso,,5,4,green,,8,22,167.3,,,,,,Baliza tablero blanco franja roja. Rumbo 167.3,Iso G 5s,DIMAR Lista de Luces 2015 +207,Enfilacion E14,-74.840667,11.056167,LIGHTS,7,Iso,,6,1,white,,8,22,122,,,,,,Tablero blanco con franja roja. Rumbo 122,Iso Bu 6s,DIMAR Lista de Luces 2015 +237,Enfilacion E16,-74.836667,11.053667,LIGHTS,7,Iso,,6,1,white,,9,12,122,,,,,,Baliza enrejado naranja bandas blancas. Rumbo 122,Iso Bu 6s,DIMAR Lista de Luces 2015 +238,Enfilacion E18,-74.825500,11.043000,LIGHTS,2,Fl,,4,"1,3,4",white/red/green,,6,18,142,,,,,,Torre roja bandas blancas. Sector 9 grados. Rumbo 142,Fl WRG 4s,DIMAR Lista de Luces 2015 +199,Boya No. 1,-74.833500,11.084500,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +200,Boya No. 3,-74.844833,11.075833,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +202,Boya No. 5,-74.841000,11.065667,BOYLAT,4,Q,,1,4,green,,6,4,,1,,4,,,Castillete verde,Q G 1s,DIMAR Lista de Luces 2015 +208,Boya No. 7,-74.837500,11.060000,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +209,Boya No. 9,-74.824000,11.046833,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +210,Boya No. 11,-74.812167,11.039667,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +211,Boya No. 12,-74.813500,11.037167,BOYLAT,2,Fl,,3,3,red,,6,4,,2,,4,,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +212,Boya No. 13,-74.802000,11.034333,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +213,Boya No. 14,-74.788333,11.021833,BOYLAT,2,Fl,,3,3,red,,6,3,,2,,4,,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +214,Boya No. 15,-74.793833,11.028167,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +215,Boya No. 16,-74.797500,11.027333,BOYLAT,2,Fl,,3,3,red,,6,4,,2,,4,,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +218,Boya No. 18,-74.788333,11.021833,BOYLAT,2,Fl,,3,3,red,,6,4,,2,,4,,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +219,Boya No. 19,-74.776500,11.017500,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +220,Boya No. 20,-74.777333,11.015500,BOYLAT,2,Fl,,3,3,red,,6,4,,2,,4,,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +221,Boya No. 21,-74.772000,11.014000,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +222,Boya No. 22,-74.773333,11.012333,BOYLAT,4,Q,,1,3,red,,6,4,,2,,4,,,Castillete roja,Q R 1s,DIMAR Lista de Luces 2015 +223,Boya No. 23,-74.755000,10.975000,BOYLAT,2,Fl,,1.3,4,green,,6,3,,1,,4,,,Castillete verde,Fl G 1.3s,DIMAR Lista de Luces 2015 +224,Boya No. 24,-74.770500,11.009167,BOYLAT,2,Fl,,3,3,red,,6,4,,2,,4,,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +225,Boya No. 25,-74.766333,11.006667,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +226,Boya No. 26,-74.768333,11.005833,BOYLAT,2,Fl,,3,3,red,,6,4,,2,,4,,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +227,Boya No. 27,-74.762000,10.998500,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +228,Boya No. 28,-74.765333,10.999833,BOYLAT,2,Fl,,3,3,red,,6,4,,2,,4,,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +229,Boya No. 29,-74.758000,10.987333,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +230,Boya No. 30,-74.760667,10.987333,BOYLAT,2,Fl,,3,3,red,,6,4,,2,,4,,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +231,Boya No. 31,-74.754833,10.975000,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +232,Boya No. 33,-74.755667,10.959333,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +233,Boya No. 35,-74.754167,10.942667,BOYLAT,2,Fl,,3,4,green,,6,4,,1,,4,,,Castillete verde,Fl G 3s,DIMAR Lista de Luces 2015 +234,Boya No. 36,-74.756667,10.941500,BOYLAT,2,Fl,,3,3,red,,6,4,,2,,4,,,Castillete roja,Fl R 3s,DIMAR Lista de Luces 2015 +235,Boya Cardinal Norte,-74.753500,10.959167,BOYCAR,2,Fl,,1,1,white,1,6,4,,,1,4,,,Castillete cardinal N negros,Fl W 1s,DIMAR Lista de Luces 2015 +236,Boya Cardinal Sur,-74.753500,10.959167,BOYCAR,2,Fl,,15,1,white,1,6,4,,,3,4,,,Castillete cardinal S negros,Fl W 15s,DIMAR Lista de Luces 2015 +239,Boya de Oleaje,-74.758000,11.134000,BOYSPP,2,Fl,,20,6,yellow,,4.5,0.5,,,,3,,,Esferica amarilla. Recolectora datos oceanograficos,Fl Y 20s,DIMAR Lista de Luces 2015 +240,Boya Peligro Aislado,-74.757333,10.954500,BOYISD,2,Fl(2),2,4,1,white,1,3,3.3,,,,4,,,Castillete roja bandas negras. Bajo rocoso,Fl(2) W 4s,DIMAR Lista de Luces 2015 diff --git a/dimar_plano_MuzX6.pdf b/dimar_plano_MuzX6.pdf new file mode 100644 index 0000000..e543ae3 Binary files /dev/null and b/dimar_plano_MuzX6.pdf differ diff --git a/dist/CO1CO01M/CO1CO01M.000 b/dist/CO1CO01M/CO1CO01M.000 new file mode 100644 index 0000000..b3bcf4a Binary files /dev/null and b/dist/CO1CO01M/CO1CO01M.000 differ diff --git a/dist/QGISS57Converter.exe b/dist/QGISS57Converter.exe new file mode 100644 index 0000000..1c397d3 Binary files /dev/null and b/dist/QGISS57Converter.exe differ diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..ac73a17 --- /dev/null +++ b/gui.py @@ -0,0 +1,324 @@ +""" +QGISS57Converter — GUI +Corre el converter en el mismo proceso (sin subprocess ni conda). +Completamente portable: solo necesita este .exe. +""" +import io +import os +import queue +import sys as _sys +import threading +import tkinter as tk +from tkinter import filedialog, messagebox, scrolledtext +from pathlib import Path + +# ── paths ───────────────────────────────────────────────────────────────────── +if getattr(_sys, "frozen", False): + SCRIPT_DIR = Path(_sys._MEIPASS) +else: + SCRIPT_DIR = Path(__file__).parent + +_sys.path.insert(0, str(SCRIPT_DIR)) + +# Tell pyproj where its PROJ data is (bundled inside the exe) +if getattr(_sys, "frozen", False): + os.environ["PROJ_DATA"] = str(SCRIPT_DIR / "proj_data") + os.environ["PROJ_LIB"] = str(SCRIPT_DIR / "proj_data") + +CONFIG = SCRIPT_DIR / "cell_config.json" +REF_PDF = SCRIPT_DIR / "CAPAS_REFERENCIA.pdf" +MANUAL_HTML = SCRIPT_DIR / "MANUAL.html" + +# Import converter (bundled alongside gui in the exe) +from converter import convert as _do_convert + +# ── theme ───────────────────────────────────────────────────────────────────── +BG = "#1e2532" +PANEL = "#252d3d" +ACCENT = "#1e7fc8" +ACCENT_HOV = "#1565a0" +TEXT_LIGHT = "#e8eaf0" +TEXT_DIM = "#8a94aa" +GREEN = "#2ecc71" +RED = "#e74c3c" +FONT_UI = ("Segoe UI", 10) +FONT_MONO = ("Consolas", 9) + + +# ── stdout capture ──────────────────────────────────────────────────────────── +class _QueueWriter: + """Redirects print() output to a queue for the GUI to consume.""" + def __init__(self, q: queue.Queue): + self._q = q + def write(self, text): + if text: + self._q.put(text) + def flush(self): + pass + def isatty(self): + return False + + +# ── app ─────────────────────────────────────────────────────────────────────── +class App(tk.Tk): + def __init__(self): + super().__init__() + self.title("QGISS57Converter") + self.geometry("720x540") + self.resizable(True, True) + self.minsize(600, 440) + self.configure(bg=BG) + self._converting = False + self._log_queue = queue.Queue() + self._cancel_flag = threading.Event() + self._build_ui() + + # ── UI ──────────────────────────────────────────────────────────────────── + def _build_ui(self): + hdr = tk.Frame(self, bg=ACCENT, height=48) + hdr.pack(fill="x") + tk.Label(hdr, text="QGIS -> S-57 ENC Converter", + bg=ACCENT, fg="white", + font=("Segoe UI", 13, "bold")).pack(side="left", padx=16, pady=10) + tk.Button(hdr, text="? Manual de usuario", + bg=ACCENT_HOV, fg="white", relief="flat", + font=("Segoe UI", 9), cursor="hand2", bd=0, padx=8, + command=self._open_manual).pack(side="right", padx=4, pady=10) + tk.Button(hdr, text="? Referencia de capas", + bg=ACCENT_HOV, fg="white", relief="flat", + font=("Segoe UI", 9), cursor="hand2", bd=0, padx=8, + command=self._open_ref).pack(side="right", padx=4, pady=10) + + body = tk.Frame(self, bg=BG) + body.pack(fill="both", expand=True, padx=20, pady=16) + body.columnconfigure(0, weight=1) + + # entrada + tk.Label(body, text="Proyecto QGIS (.qgz / .qgs)", + bg=BG, fg=TEXT_DIM, font=FONT_UI).grid(row=0, column=0, sticky="w") + row_in = tk.Frame(body, bg=BG) + row_in.grid(row=1, column=0, sticky="ew", pady=(2, 10)) + self._var_in = tk.StringVar() + self._ent_in = tk.Entry(row_in, textvariable=self._var_in, + bg=PANEL, fg=TEXT_LIGHT, insertbackground=TEXT_LIGHT, + relief="flat", font=FONT_UI, bd=6) + self._ent_in.pack(side="left", fill="x", expand=True) + self._ent_in.bind("", lambda _: self._auto_output()) + tk.Button(row_in, text="Examinar...", bg=ACCENT, fg="white", + relief="flat", font=FONT_UI, cursor="hand2", bd=0, padx=10, + command=self._pick_input).pack(side="left", padx=(6, 0)) + + # salida + tk.Label(body, text="Archivo de salida (.000)", + bg=BG, fg=TEXT_DIM, font=FONT_UI).grid(row=2, column=0, sticky="w") + row_out = tk.Frame(body, bg=BG) + row_out.grid(row=3, column=0, sticky="ew", pady=(2, 10)) + self._var_out = tk.StringVar() + tk.Entry(row_out, textvariable=self._var_out, + bg=PANEL, fg=TEXT_LIGHT, insertbackground=TEXT_LIGHT, + relief="flat", font=FONT_UI, bd=6).pack(side="left", fill="x", expand=True) + tk.Button(row_out, text="Guardar en...", bg=PANEL, fg=TEXT_LIGHT, + relief="flat", font=FONT_UI, cursor="hand2", bd=0, padx=10, + command=self._pick_output).pack(side="left", padx=(6, 0)) + + # directorio extra de CSVs (opcional) + tk.Label(body, text="Directorio extra de CSVs (opcional — boyas, luces, etc.)", + bg=BG, fg=TEXT_DIM, font=FONT_UI).grid(row=4, column=0, sticky="w") + row_csv = tk.Frame(body, bg=BG) + row_csv.grid(row=5, column=0, sticky="ew", pady=(2, 4)) + self._var_csv = tk.StringVar() + self._ent_csv = tk.Entry(row_csv, textvariable=self._var_csv, + bg=PANEL, fg=TEXT_LIGHT, insertbackground=TEXT_LIGHT, + relief="flat", font=FONT_UI, bd=6) + self._ent_csv.pack(side="left", fill="x", expand=True) + tk.Button(row_csv, text="Examinar...", bg=PANEL, fg=TEXT_LIGHT, + relief="flat", font=FONT_UI, cursor="hand2", bd=0, padx=10, + command=self._pick_csv_dir).pack(side="left", padx=(6, 0)) + tk.Button(row_csv, text="✕", bg=PANEL, fg=TEXT_DIM, + relief="flat", font=("Segoe UI", 9), cursor="hand2", bd=0, padx=6, + command=lambda: self._var_csv.set("")).pack(side="left", padx=(2, 0)) + tk.Label(body, + text="Los archivos .csv en ese directorio se incluyen como capas extra " + "(nombre del archivo = clase S-57, ej. BOYLAT.csv → BOYLAT).", + bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8), + wraplength=640, justify="left").grid(row=6, column=0, sticky="w", + pady=(0, 8)) + + # botones + row_btn = tk.Frame(body, bg=BG) + row_btn.grid(row=7, column=0, sticky="ew", pady=(4, 12)) + self._btn_convert = tk.Button( + row_btn, text=">> Convertir", + bg=GREEN, fg="white", activebackground="#27ae60", + relief="flat", font=("Segoe UI", 11, "bold"), + cursor="hand2", bd=0, padx=20, pady=8, + command=self._start_conversion) + self._btn_convert.pack(side="left") + self._btn_open = tk.Button( + row_btn, text="Abrir carpeta", + bg=PANEL, fg=TEXT_LIGHT, relief="flat", font=FONT_UI, + cursor="hand2", bd=0, padx=14, pady=8, + state="disabled", command=self._open_output_dir) + self._btn_open.pack(side="left", padx=(8, 0)) + + # estado + self._lbl_status = tk.Label(body, text="Listo.", bg=BG, fg=TEXT_DIM, + font=("Segoe UI", 9)) + self._lbl_status.grid(row=8, column=0, sticky="w") + + # log + tk.Label(body, text="Log de conversion", + bg=BG, fg=TEXT_DIM, font=FONT_UI).grid(row=9, column=0, + sticky="w", pady=(10, 2)) + self._log = scrolledtext.ScrolledText( + body, bg="#0d1117", fg="#c9d1d9", + insertbackground="white", relief="flat", + font=FONT_MONO, bd=4, height=12, wrap="word") + self._log.grid(row=10, column=0, sticky="nsew") + self._log.configure(state="disabled") + self._log.tag_configure("green", foreground=GREEN) + self._log.tag_configure("red", foreground=RED) + body.rowconfigure(10, weight=1) + + # ── acciones ────────────────────────────────────────────────────────────── + def _pick_input(self): + p = filedialog.askopenfilename( + title="Seleccionar proyecto QGIS", + filetypes=[("QGIS project", "*.qgz *.qgs"), ("Todos", "*.*")]) + if p: + self._var_in.set(p) + self._auto_output() + + def _auto_output(self): + src = Path(self._var_in.get().strip()) + if src.exists(): + self._var_out.set(str(src.parent / (src.stem.replace(" ", "_") + ".000"))) + + def _pick_output(self): + p = filedialog.asksaveasfilename( + title="Guardar carta S-57 como", + defaultextension=".000", + filetypes=[("S-57 ENC", "*.000"), ("Todos", "*.*")]) + if p: + self._var_out.set(p) + + def _open_manual(self): + if MANUAL_HTML.exists(): + import webbrowser + webbrowser.open(MANUAL_HTML.as_uri()) + else: + messagebox.showinfo("Manual", "No se encontro MANUAL.html.") + + def _open_ref(self): + if REF_PDF.exists(): + os.startfile(str(REF_PDF)) + else: + messagebox.showinfo("Referencia", "No se encontro CAPAS_REFERENCIA.pdf.") + + def _pick_csv_dir(self): + p = filedialog.askdirectory(title="Seleccionar directorio con archivos CSV extra") + if p: + self._var_csv.set(p) + + def _open_output_dir(self): + out = Path(self._var_out.get().strip()) + folder = out.parent if out.parent.exists() else SCRIPT_DIR + os.startfile(str(folder)) + + def _log_write(self, text, tag=None): + self._log.configure(state="normal") + self._log.insert("end", text, tag or ()) + self._log.see("end") + self._log.configure(state="disabled") + + def _set_status(self, msg, color=TEXT_DIM): + self._lbl_status.configure(text=msg, fg=color) + + # ── conversion ──────────────────────────────────────────────────────────── + def _start_conversion(self): + src = self._var_in.get().strip() + out = self._var_out.get().strip() + csv_dir = self._var_csv.get().strip() or None + if not src: + messagebox.showwarning("Falta archivo", "Selecciona un archivo .qgz primero.") + return + if not Path(src).exists(): + messagebox.showerror("No encontrado", f"No existe:\n{src}") + return + if csv_dir and not Path(csv_dir).is_dir(): + messagebox.showerror("Directorio no existe", + f"El directorio de CSVs no existe:\n{csv_dir}") + return + if not out: + self._auto_output() + out = self._var_out.get().strip() + + self._log.configure(state="normal") + self._log.delete("1.0", "end") + self._log.configure(state="disabled") + self._btn_convert.configure(state="disabled") + self._btn_open.configure(state="disabled") + self._set_status("Convirtiendo...", ACCENT) + self._converting = True + + # Start polling the log queue + self._poll_log() + + threading.Thread(target=self._run_conversion, + args=(src, out, csv_dir), daemon=True).start() + + def _poll_log(self): + """Drain the log queue and update the text widget.""" + try: + while True: + text = self._log_queue.get_nowait() + self._log_write(text) + except queue.Empty: + pass + if self._converting: + self.after(40, self._poll_log) + + def _run_conversion(self, src: str, out: str, csv_dir=None): + old_stdout = _sys.stdout + old_stderr = _sys.stderr + writer = _QueueWriter(self._log_queue) + _sys.stdout = writer + _sys.stderr = writer + rc = 0 + try: + _do_convert( + project_path = src, + output_path = out, + config_path = str(CONFIG), + list_only = False, + force = True, + verbose = True, + extra_csv_dir = csv_dir, + ) + except SystemExit as e: + rc = int(e.code) if e.code is not None else 1 + except Exception as e: + _sys.stdout.write(f"\n[ERROR] {e}\n") + rc = 1 + finally: + _sys.stdout = old_stdout + _sys.stderr = old_stderr + + self.after(0, self._on_done, rc, out) + + def _on_done(self, rc: int, out: str): + self._converting = False + self._btn_convert.configure(state="normal") + if rc == 0 and Path(out).exists(): + size = Path(out).stat().st_size + self._log_write(f"\nListo: {out} ({size:,} bytes)\n", "green") + self._set_status(f"Completado: {Path(out).name}", GREEN) + self._btn_open.configure(state="normal") + else: + self._log_write(f"\nLa conversion fallo (codigo {rc})\n", "red") + self._set_status("Error en la conversion.", RED) + + +if __name__ == "__main__": + app = App() + app.mainloop() diff --git a/make_ref_pdf.py b/make_ref_pdf.py new file mode 100644 index 0000000..00d9437 --- /dev/null +++ b/make_ref_pdf.py @@ -0,0 +1,205 @@ +"""Genera CAPAS_REFERENCIA.pdf en el directorio del converter.""" +from fpdf import FPDF +from pathlib import Path + +OUT = Path(__file__).parent / "CAPAS_REFERENCIA.pdf" + +# ── datos ────────────────────────────────────────────────────────────────────── +SECCIONES = [ + ("OBJETOS PUNTUALES", [ + ("BOYLAT", "Boya lateral (babor/estribor)", "boyas, buoys"), + ("BOYCAR", "Boya cardinal (N/S/E/W)", "boycar"), + ("BOYISD", "Boya de peligro aislado", "boyisd"), + ("BOYSAW", "Boya de aguas seguras", "boysaw"), + ("BCNLAT", "Baliza lateral", "balizas, beacons"), + ("BCNSPP", "Baliza especial", "bcnspp"), + ("LIGHTS", "Luz / faro", "luces, lights, faroles"), + ("LNDMRK", "Hito en tierra (torre, tanque...)", "Puntos del Terreno"), + ("SOUNDG", "Sonda batimetrica", "sondas, soundings, profundidades"), + ("UWTROC", "Roca sumergida / a flor de agua", "rocas, rocks"), + ("WRECKS", "Naufragio", "naufragio, wreck"), + ("OBSTRN", "Obstruccion", "obstruccion, obstruction"), + ]), + ("OBJETOS LINEALES", [ + ("COALNE", "Linea de costa", "Linderos, coastline, costa, linea_de_costa"), + ("DEPCNT", "Curva batimetrica (isobata)", "isobata, curvas_nivel, depth_contour"), + ("CBLSUB", "Cable submarino", "cable"), + ("PIPSOL", "Tuberia submarina / en tierra", "tuberia"), + ("RIVERS", "Rio / canal", "rio, river"), + ]), + ("OBJETOS DE AREA", [ + ("LNDARE", "Area terrestre", "Area Terreno, tierra, land"), + ("DEPARE", "Area de profundidad", "fondos, batimetria, depth_area"), + ("FAIRWY", "Canal de navegacion", "canal_navegacion, fairway"), + ("RESARE", "Area restringida", "zona_restringida, restricted"), + ("ACHARE", "Area de fondeo", "fondeadero, anchorage"), + ("HRBARE", "Area portuaria", "puerto, harbor"), + ("BERTHS", "Atraque / muelle", "atraque, berth"), + ("SBDARE", "Area de fondo marino", "fondo_marino, seabed"), + ]), +] + +ATTR_ROWS = [ + ("nombre / name", "OBJNAM", "Nombre del objeto (aparece en tooltip)"), + ("catlam / lateral", "CATLAM", "Categoria lateral: 1=babor, 2=estribor"), + ("colour / color", "COLOUR", "Color: 1=blanco, 3=rojo, 4=verde, 6=amarillo"), + ("colpat / patron", "COLPAT", "Patron de color: 1=horizontal, 2=vertical"), + ("boyshp / forma", "BOYSHP", "Forma boya: 1=conica, 2=cilindrica, 4=esferica"), + ("litchr / destello", "LITCHR", "Destello: 1=F, 2=Fl, 3=LFl, 4=Q, 8=Iso, 5=IQ"), + ("sigper / periodo", "SIGPER", "Periodo en segundos (ej: 4.0)"), + ("siggrp / grupo", "SIGGRP", "Grupo de destellos: (2), (2+1), etc."), + ("alcance / range", "VALNMR", "Alcance nominal en millas nauticas"), + ("altura / height", "HEIGHT", "Altura de la luz sobre el mar (metros)"), + ("status / estado", "STATUS", "Estado: 1=permanente, 2=ocasional"), + ("profundidad / depth", "DRVAL1", "Profundidad minima (metros)"), + ("depth_max", "DRVAL2", "Profundidad maxima (metros)"), + ("sonda / sounding", "VALSOU", "Valor de sonda (metros)"), + ("contour / valor", "VALDCO", "Valor de curva batimetrica (metros)"), +] + +LITCHR_ROWS = [ + ("1", "F", "Fija"), + ("2", "Fl", "Destellante"), + ("3", "LFl", "Gran destello"), + ("4", "Q", "Centelleante"), + ("5", "IQ", "Centelleante interrumpido"), + ("6", "Oc", "Ocultante"), + ("8", "Iso", "Isofasica"), + ("25", "VQ", "Rapida continua"), + ("28", "Mo", "Morse"), +] + +# ── PDF ──────────────────────────────────────────────────────────────────────── +class PDF(FPDF): + def header(self): + self.set_font("Helvetica", "B", 11) + self.set_fill_color(30, 80, 140) + self.set_text_color(255, 255, 255) + self.cell(0, 10, "QGISS57Converter - Referencia de nombres de capas", fill=True, new_x="LMARGIN", new_y="NEXT") + self.set_text_color(0, 0, 0) + self.ln(2) + + def footer(self): + self.set_y(-12) + self.set_font("Helvetica", "I", 8) + self.set_text_color(120, 120, 120) + self.cell(0, 10, f"Pagina {self.page_no()}", align="C") + + +pdf = PDF(orientation="P", unit="mm", format="A4") +pdf.set_auto_page_break(auto=True, margin=15) +pdf.add_page() +pdf.set_font("Helvetica", size=9) + +# intro +pdf.set_font("Helvetica", size=9) +pdf.multi_cell(0, 5, + "Nombra tus capas QGIS con cualquiera de los textos de la columna 'Nombres reconocidos' " + "(sin importar mayusculas). Tambien puedes usar el acronimo S-57 directamente como nombre de capa. " + "Para agregar nombres nuevos, edita 'layer_mappings' en cell_config.json.") +pdf.ln(3) + +COL = [22, 58, 110] # x positions +W = [22, 55, 78] # widths + +def th(texts, fill_rgb=(200, 215, 235)): + pdf.set_fill_color(*fill_rgb) + pdf.set_font("Helvetica", "B", 8) + for i, t in enumerate(texts): + pdf.set_x(10 + sum(W[:i])) + pdf.cell(W[i], 6, t, border=1, fill=True) + pdf.ln() + +def tr(texts, alt=False): + fill_rgb = (245, 248, 252) if alt else (255, 255, 255) + pdf.set_fill_color(*fill_rgb) + pdf.set_font("Helvetica", size=8) + # measure max height needed + heights = [] + for i, t in enumerate(texts): + lines = pdf.get_string_width(t) / W[i] + 1 + heights.append(max(1, int(lines) + 1) * 5) + h = max(heights) + h = min(h, 10) + for i, t in enumerate(texts): + pdf.set_x(10 + sum(W[:i])) + pdf.multi_cell(W[i], h / max(1, pdf.get_string_width(t) / W[i] + 1), + t, border=1, fill=True, max_line_height=5) + # ensure we're on next line + pdf.ln(0) + +for section_title, rows in SECCIONES: + pdf.set_font("Helvetica", "B", 9) + pdf.set_fill_color(30, 80, 140) + pdf.set_text_color(255, 255, 255) + pdf.cell(0, 7, f" {section_title}", fill=True, new_x="LMARGIN", new_y="NEXT") + pdf.set_text_color(0, 0, 0) + th(["Acronimo S-57", "Descripcion", "Nombres reconocidos en QGIS"]) + for idx, (acro, desc, aliases) in enumerate(rows): + pdf.set_fill_color(245, 248, 252) if idx % 2 else pdf.set_fill_color(255, 255, 255) + pdf.set_font("Helvetica", "B", 8) + pdf.set_x(10) + pdf.cell(W[0], 6, acro, border=1, fill=True) + pdf.set_font("Helvetica", size=8) + pdf.cell(W[1], 6, desc, border=1, fill=True) + pdf.multi_cell(W[2], 6, aliases, border=1, fill=True) + pdf.ln(4) + +# atributos +pdf.set_font("Helvetica", "B", 9) +pdf.set_fill_color(30, 80, 140) +pdf.set_text_color(255, 255, 255) +pdf.cell(0, 7, " CAMPOS DE ATRIBUTOS (columnas de tu SHP)", fill=True, new_x="LMARGIN", new_y="NEXT") +pdf.set_text_color(0, 0, 0) + +AW = [55, 22, 78] +pdf.set_fill_color(200, 215, 235) +pdf.set_font("Helvetica", "B", 8) +pdf.set_x(10); pdf.cell(AW[0], 6, "Nombre campo en SHP", border=1, fill=True) +pdf.cell(AW[1], 6, "Atrib. S-57", border=1, fill=True) +pdf.cell(AW[2], 6, "Descripcion", border=1, fill=True) +pdf.ln() + +for idx, (shp_col, s57, desc) in enumerate(ATTR_ROWS): + pdf.set_fill_color(245, 248, 252) if idx % 2 else pdf.set_fill_color(255, 255, 255) + pdf.set_font("Helvetica", size=8) + pdf.set_x(10); pdf.cell(AW[0], 6, shp_col, border=1, fill=True) + pdf.set_font("Helvetica", "B", 8) + pdf.cell(AW[1], 6, s57, border=1, fill=True) + pdf.set_font("Helvetica", size=8) + pdf.cell(AW[2], 6, desc, border=1, fill=True) + pdf.ln() + +# tabla LITCHR +pdf.ln(4) +pdf.set_font("Helvetica", "B", 9) +pdf.set_fill_color(30, 80, 140) +pdf.set_text_color(255, 255, 255) +pdf.cell(0, 7, " CODIGOS LITCHR - Caracteristica de luz", fill=True, + new_x="LMARGIN", new_y="NEXT") +pdf.set_text_color(0, 0, 0) + +LW = [20, 30, 100] +pdf.set_fill_color(200, 215, 235) +pdf.set_font("Helvetica", "B", 8) +pdf.set_x(10); pdf.cell(LW[0], 6, "Codigo", border=1, fill=True) +pdf.cell(LW[1], 6, "Abrev.", border=1, fill=True) +pdf.cell(LW[2], 6, "Descripcion", border=1, fill=True) +pdf.ln() +for idx, (code, abbr, desc) in enumerate(LITCHR_ROWS): + pdf.set_fill_color(245, 248, 252) if idx % 2 else pdf.set_fill_color(255, 255, 255) + pdf.set_font("Helvetica", size=8) + pdf.set_x(10); pdf.cell(LW[0], 6, code, border=1, fill=True) + pdf.cell(LW[1], 6, abbr, border=1, fill=True) + pdf.cell(LW[2], 6, desc, border=1, fill=True) + pdf.ln() + +pdf.ln(5) +pdf.set_font("Helvetica", "I", 8) +pdf.set_text_color(80, 80, 80) +pdf.multi_cell(0, 5, + "Nota: para anadir un nombre de capa no listado aqui, edita 'layer_mappings' en cell_config.json. " + "Para anadir campos de atributos nuevos, edita 'attribute_mappings'.") + +pdf.output(str(OUT)) +print(f"PDF generado: {OUT} ({OUT.stat().st_size} bytes)") diff --git a/nga_BOYLAT_20260430.csv b/nga_BOYLAT_20260430.csv new file mode 100644 index 0000000..94e48a3 --- /dev/null +++ b/nga_BOYLAT_20260430.csv @@ -0,0 +1,2 @@ +lon,lat,OBJNAM,NOBJNM,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,SIGPER2,VALNMR,HEIGHT,SECTR1,SECTR2,MLTYLT,ORIENT,BOYSHP,CATLBR,CATCAM,INFORM,TXTDSC,_nga_feature,_nga_volume,_nga_region,_nga_char_raw,_nga_hfm_raw,_nga_range_raw,_nga_notice +-74.253333,11.153333,SBM,,2,Fl.W.,,1,W,8,,8,,,,,,,,,Radar Reflector. ,Yellow CALM superbuoy.,16831 J6255.30,PUB 110,,Fl.W. | period 8s | ,,8,201506 diff --git a/nga_BOYSAW_20260430.csv b/nga_BOYSAW_20260430.csv new file mode 100644 index 0000000..8c56281 --- /dev/null +++ b/nga_BOYSAW_20260430.csv @@ -0,0 +1,2 @@ +lon,lat,OBJNAM,NOBJNM,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,SIGPER2,VALNMR,HEIGHT,SECTR1,SECTR2,MLTYLT,ORIENT,BOYSHP,CATLBR,CATCAM,INFORM,TXTDSC,_nga_feature,_nga_volume,_nga_region,_nga_char_raw,_nga_hfm_raw,_nga_range_raw,_nga_notice +-75.598336,10.316667,Sea buoy,,5,Q.W.,,"1,3","W,R",,,,,,,,,3,,,,"SAFE WATER RW, buoy, topmark.",16704,PUB 110,CARTAGENA:,Q.W. | ,,,201622 diff --git a/nga_LIGHTS_20260430.csv b/nga_LIGHTS_20260430.csv new file mode 100644 index 0000000..77a7ea9 --- /dev/null +++ b/nga_LIGHTS_20260430.csv @@ -0,0 +1,62 @@ +lon,lat,OBJNAM,NOBJNM,LITCHR,LITCHR_TXT,SIGGRP,COLOUR,COLOUR_TXT,SIGPER,SIGPER2,VALNMR,HEIGHT,SECTR1,SECTR2,MLTYLT,ORIENT,BOYSHP,CATLBR,CATCAM,INFORM,TXTDSC,_nga_feature,_nga_volume,_nga_region,_nga_char_raw,_nga_hfm_raw,_nga_range_raw,_nga_notice +-75.786389,9.529167,Tanker Loading Unit TLU-3,,13,Mo.(U)Y.,(U),6,Y,15,,,,,,,,,,,,,16674 J6153.6,PUB 110,GOLFO DE MORROSQUILLO:,Mo.(U)Y. | period 15s | ,,,201904 +-75.992225,9.591111,Roca Morrosquillo,,2,Fl.W.,,1,W,3,0.3,10,6,,,,,,,,,"Red tower, white bands.",16678 J6154.5,PUB 110,GOLFO DE MORROSQUILLO:,"Fl.W. | period 3s | fl. 0.3s, ec. 2.7s | ",20ft/6,10,201506 +-75.855281,9.693139,Isla Ceycen,,2,Fl.W.,,1,W,10,0.8,17,20,,,,,,,,,"Red tower, white bands; 59.",16679 J6156,PUB 110,,"Fl.W. | period 10s | fl. 0.8s, ec. 9.2s | ",66ft/20,17,201647 +-75.87075,9.782528,Isla Mucura,,2,Fl.W.,,1,W,6.7,0.5,11,20,,,,,,,,,"Red tower, white bands; 59.",16680 J6157,PUB 110,,"Fl.W. | period 6.7s | fl. 0.5s, ec. 6.2s | ",66ft/20,11,201506 +-75.72745,10.145556,Isla Arenas,,2,Fl.W.,,1,W,12,1.2,13,20,,,,,,,,,"White metal tower, red stripes; 59.",16684 J6159,PUB 110,,"Fl.W. | period 12s | fl. 1.2s, ec. 10.8s | ",66ft/20,13,201506 +-75.80055,10.168056,Isla del Rosario,,2,Fl.W.,,1,W,10,0.79,12,14,,,,,,,,,"White tower, red bands; 39.",16687.1 J6159.7,PUB 110,,"Fl.W. | period 10s | fl. 0.79s, ec. 9.21s | ",46ft/14,12,201622 +-75.74,10.235,Isla del Tesoro,,2,Fl.W.,,1,W,6.6,0.5,12,20,,,,,,,,,"White tower, red bands.",16688 J6160,PUB 110,,"Fl.W. | period 6.6s | fl. 0.5s, ec. 6.1s | ",66ft/20,12,201501 +-75.58095,10.339944,Isla Tierra Bomba,,2,Fl.W.,,1,W,12,1.2,26,112,,,,,,,,,"Red metal framework tower, white bands; 131.",16700 J6166,PUB 110,CARTAGENA:,"Fl.W. | period 12s | fl. 1.2s, ec. 10.8s | ",367ft/112,26,201626 +-75.556081,10.281361,"Bajos de Coquitos, pier, E",,2,Fl.(4)W.,(4),1,W,9,,4,11,,,,,,,,,"Yellow post, ""X"" topmark.",16703 J6166.1,PUB 110,CARTAGENA:,Fl.(4)W. | period 9s | ,36ft/11,4,201812 +-75.558944,10.280222,W,,2,Fl.(4)W.,(4),1,W,9,,4,11,,,,,,,,,"Yellow post, ""X"" topmark.",16703.5 J6166.2,PUB 110,CARTAGENA:,Fl.(4)W. | period 9s | ,36ft/11,4,201812 +-75.598336,10.316667,RACON,,,C(- • - • ),,1,W,,,,,,,,,,,,(3 & 10cm). ,,16704,PUB 110,CARTAGENA:,C(- • - • ) | ,,,201622 +-75.511664,10.317889,"Port Mamonal, Ecopetrol pier, N",,5,Q.Bu.,,5,Bu,,,,,,,,,,,,5 Q.Bu. shown along pier. ,,16705 J6166.73,PUB 110,CARTAGENA:,Q.Bu. | ,,,201831 +-75.511389,10.316667,S,,5,Q.Bu.,,5,Bu,,,,,,,,,,,,,,16705.1 J6166.69,PUB 110,CARTAGENA:,Q.Bu. | ,,,201831 +-75.508361,10.345056,Atunes de Colombia,,2,Fl.Bu.,,5,Bu,4,1.5,,,,,,,,,,,,16705.5 J6166.92,PUB 110,CARTAGENA:,"Fl.Bu. | period 4s | fl. 1.5s, ec. 2.5s | ",,,201831 +-75.530556,10.388611,"2 ENAP, Range, front",,5,Q.Y.,,6,Y,,,10,18,,,,,,,,,"White tower, red bands; 30.",16725.5 J6167.65,PUB 110,CARTAGENA:,Q.Y. | ,59ft/18,10,201506 +-75.530556,10.389444,"1 ENAP, rear, about 110 meters 000^ from front",,5,Q.Y.,,6,Y,,,10,22,,,,,,,,,"White tower, red bands; 16.",16725.51 J6167.66,PUB 110,CARTAGENA:,Q.Y. | ,72ft/22,10,201506 +-75.544975,10.391222,Castillo Grande,,2,Fl.W.,,1,W,15,1.2,20,24,,,,,,,,"Beacons ""E1'' Fl G 3s 5M and ""E2'' Fl R 3s 4M mark small craft passage 1.5 miles W. ",Beige concrete tower; 72.,16726 J6167.60,PUB 110,,"Fl.W. | period 15s | fl. 1.2s, ec. 13.8s | ",79ft/24,20,201919 +-75.546031,10.411472,Naval Base pier,,2,Fl.Bu.,,5,Bu,3.5,1.0,5,4,,,,,,,,4 Fl.Bu. 3.5s shown along pier. ,,16727 J6167.2,PUB 110,,"Fl.Bu. | period 3.5s | fl. 1.0s, ec. 2.5s | ",11ft/4,5,201831 +-75.51585,10.446103,Crespo AVIATION LIGHT,,20,Al.Fl.W.G.,,"1,4","W,G",10,,20,,,,,,,,,,,16732 J6172,PUB 110,,Al.Fl.W.G. | period 10s | ,,20,201501 +-75.499169,10.573056,Punta Canoas,,2,Fl.W.,,1,W,5,0.4,12,96,,,,,,,,,Red and white tower; 39.,16736 J6174,PUB 110,,"Fl.W. | period 5s | fl. 0.4s, ec. 4.6s | ",315ft/96,12,201722 +-75.266389,10.785361,Punta Galera,,9,Oc.W.,,1,W,4,2.5,18,12,,,,,,,,,"White fiberglass tower, red bands.",16740 J6176,PUB 110,,"Oc.W. | period 4s | fl. 2.5s, ec. 1.5s | ",39ft/12,18,201625 +-75.017525,10.963222,Punta Hermosa,,9,Oc.W.,,1,W,4,2.5,28,134,,,,,,,,,"White tower, red bands; 39.",16744 J6180,PUB 110,,"Oc.W. | period 4s | fl. 2.5s, ec. 1.5s | ",440ft/134,28,201506 +-74.849525,11.10625,"F-1, E. breakwater, head",,8,Iso.G.,,4,G,2,,9,20,,,,,,,,,"White tower, orange bands.",16747 J6190,PUB 110,RIO MAGDALENA:,Iso.G. | period 2s | ,66ft/20,9,201926 +-74.8547,11.106222,"F-2, W. breakwater, head",,8,Iso.R.,,3,R,2,,13,23,,,,,,,,,"White framework tower, orange bands.",16748 J6188,PUB 110,RIO MAGDALENA:,Iso.R. | period 2s | ,75ft/23,13,201708 +-74.8547,11.106222,RACON,,,B(- • • • ),,1,W,,,,,,,,,,,,(3 & 10cm). ,,16748 J6188,PUB 110,RIO MAGDALENA:,B(- • • • ) | ,,,201708 +-74.848275,11.103672,"E-1 Range, front, on E. breakwater, common front",,8,Iso.Bu.,,5,Bu,5,,13,10,,,,176.0,,,,Rear 16760. ,"Metal framework tower, white rectangular daymark, red stripe.",16756 J6191,PUB 110,RIO MAGDALENA:,Iso.Bu. | period 5s | ,33ft/10,13,201927 +-74.848275,11.103672,,,8,Iso.W.,,1,W,4,,9,10,,,,,,,,Rear 16761. ,,16756 J6191,PUB 110,RIO MAGDALENA:,Iso.W. | period 4s | ,33ft/10,9,201927 +-74.846439,11.101583,"E-3 Rear, 310 meters 139^18' from front",,8,Iso.Bu.,,5,Bu,5,,9,22,,,,176.0,,,,,"Orange and white framework tower, white rectangular daymark, red stripe.",16760 J6191.10,PUB 110,RIO MAGDALENA:,Iso.Bu. | period 5s | ,72ft/22,9,201506 +-74.844969,11.100306,"E-3A Rear, 135^42' from front",,8,Iso.W.,,1,W,5,,12,20,,,,176.0,,,,,"Metal framework tower, white rectangular daymark, red stripe.",16761 J6191.2,PUB 110,RIO MAGDALENA:,Iso.W. | period 5s | ,66ft/20,12,201927 +-74.8495,11.102194,X-1,,2,Fl.(4)G.,(4),4,G,11,0.5,6,8,,,,,,,,,"Red framework tower, white bands.",16763 J6193.5,PUB 110,RIO MAGDALENA:,"Fl.(4)G. | period 11s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 4.5s | ",26ft/8,6,201522 +-74.85325,11.1,"X-2, W. side of river",,2,Fl.(4)R.,(4),3,R,11,0.5,6,6,,,,,,,,,"Red framework tower, white bands.",16764 J6194,PUB 110,RIO MAGDALENA:,"Fl.(4)R. | period 11s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 4.5s | ",20ft/6,6,201605 +-74.847219,11.091389,X-3,,2,Fl.(4)G.,(4),4,G,11,0.5,6,8,,,,,,,,,"Red framework tower, white bands.",16766 J6201,PUB 110,,"Fl.(4)G. | period 11s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 4.5s | ",26ft/8,6,201522 +-74.851775,11.093167,"X-4, W. side of river",,8,Iso.R.,,3,R,4,,4,8,,,,,,,,,"Orange framework tower, white bands.",16768 J6196,PUB 110,RIO MAGDALENA:,Iso.R. | period 4s | ,26ft/8,4,201506 +-74.846811,11.070278,E-4,,8,Dir.Iso.W.,,1,W,4,,4,11,,,,141.0,,,,Visible on bearing 322°12`. ,"Orange metal tower, white bands.",16772 J6198,PUB 110,RIO MAGDALENA:,Dir.Iso.W. | period 4s | ,36ft/11,4,201506 +-74.846811,11.070278,,,8,Iso.R.,,3,R,4,,,,,,,,,,,,,16772 J6198,PUB 110,RIO MAGDALENA:,Iso.R. | period 4s | ,,,201506 +-74.846725,11.089222,X-5,,2,Fl.(4)G.,(4),4,G,11,0.5,6,8,,,,,,,,,"Red framework tower, white bands.",16776 J6200,PUB 110,RIO MAGDALENA:,"Fl.(4)G. | period 11s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 4.5s | ",26ft/8,6,201522 +-74.850556,11.087722,"X-6, W. side of river",,2,Fl.(4)R.,(4),3,R,11,0.5,6,6,,,,,,,,,"White framework tower, red bands.",16780 J6202,PUB 110,RIO MAGDALENA:,"Fl.(4)R. | period 11s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 4.5s | ",20ft/6,6,202007 +-74.844189,11.064889,"E-6 Range, front",,8,Iso.Bu.,,5,Bu,2,,11,11,,,,167.7,,,,Visible 166°-170°. ,"Orangee framework tower, white bands, red rectangular daymark, white stripes.",16784 J6191.90,PUB 110,RIO MAGDALENA:,Iso.Bu. | period 2s | ,36ft/11,11,201506 +-74.842833,11.058694,"E-8 Rear, 695 meters 167^42' from front",,8,Iso.Bu.,,5,Bu,4,,15,25,,,,167.7,,,,Visible on range line only. ,"Red and white framework tower, white rectangular daymark, red stripe.",16788 J6191.91,PUB 110,RIO MAGDALENA:,Iso.Bu. | period 4s | ,82ft/25,15,201927 +-74.836769,11.053728,"E-16 Range, front",,8,Iso.Bu.,,5,Bu,6,,9,12,,,,176.0,,,,,Orange and white framework tower.,16790 J6193,PUB 110,RIO MAGDALENA:,Iso.Bu. | period 6s | ,39ft/12,9,201501 +-74.840719,11.056147,"E-14 Rear, 512 meters 302^ from front",,8,Iso.Bu.,,5,Bu,6,,9,24,,,,176.0,,,,,Tower.,16790.5 J6192.10,PUB 110,RIO MAGDALENA:,Iso.Bu. | period 6s | ,79ft/24,9,201501 +-74.849169,11.081833,"X-8, W. side of river",,2,Fl.R.,,3,R,4,1.0,4,8,,,,,,,,,"Orange framework tower, white bands.",16792 J6204,PUB 110,RIO MAGDALENA:,"Fl.R. | period 4s | fl. 1.0s, ec. 3.0s | ",26ft/8,4,201506 +-74.848,11.076083,"X-10, W. side of river, below Las Flores",,2,Fl.(4)R.,(4),3,R,11,0.5,6,6,,,,,,,,,"White framework tower, red bands.",16804 J6206,PUB 110,RIO MAGDALENA:,"Fl.(4)R. | period 11s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 4.5s | ",20ft/6,6,202007 +-74.825561,11.043097,"E-18 Range, front",,8,Dir.Iso.W.R.G.,,"1,3,4","W,R,G",4,,8,13,,,,176.0,,,,"R. 137°30`-141°30`, W.-142°30`, G.-146°30`. ","White metal framework tower, red bands.",16806 J6216,PUB 110,RIO MAGDALENA:,Dir.Iso.W.R.G. | period 4s | ,43ft/13,8,201927 +-74.822556,11.03925,"E-20 Rear, 522 meters 142^ 12' from front",,8,Iso.Bu.,,5,Bu,4,,15,30,,,,142.2,,,,,"White and orange metal framework tower, white rectangular daymark, orange stripe.",16807 J6218,PUB 110,RIO MAGDALENA:,Iso.Bu. | period 4s | ,98ft/30,15,201508 +-74.844189,11.064917,X-12,,2,Fl.(4)R.,(4),3,R,11,0.5,6,6,,,,,,,,,"Red framework tower, white bands.",16808 J6207,PUB 110,RIO MAGDALENA:,"Fl.(4)R. | period 11s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 4.5s | ",20ft/6,6,201605 +-74.839614,11.057806,X-14,,2,Fl.(4)R.,(4),3,R,11,0.5,6,6,,,,,,,,,"White framework tower, red bands.",16810 J6208,PUB 110,RIO MAGDALENA:,"Fl.(4)R. | period 11s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 4.5s | ",20ft/6,6,201927 +-74.814414,11.041583,X-7,,2,Fl.(4)G,(4),4,G,11,0.5,6,8,,,,,,,,,"Red framework tower, white bands.",16811 J6222.5,PUB 110,RIO MAGDALENA:,"Fl.(4)G | period 11s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 4.5s | ",26ft/8,6,201522 +-74.832919,11.05,X-16,,2,Fl.(3)R.,(3),3,R,9,0.8,4,8,,,,,,,,,"Orange framework tower, white bands.",16812 J6214,PUB 110,RIO MAGDALENA:,"Fl.(3)R. | period 9s | fl. 0.8s, ec. 1.2s | fl. 0.8s, ec. 1.2s | fl. 0.8s, ec. 4.2s | ",26ft/8,4,201506 +-74.804886,11.035889,X-9,,2,Fl.(4)G.,(4),4,G,11,0.5,6,8,,,,,,,,,"Red framework tower, white bands.",16813 J6222.8,PUB 110,RIO MAGDALENA:,"Fl.(4)G. | period 11s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 4.5s | ",26ft/8,6,201522 +-74.7955,11.03,X-11,,2,Fl.(4)G.,(4),4,G,11,0.5,6,8,,,,,,,,,"Red framework tower, white bands, red and white rectangular daymark.",16813.5 J6223,PUB 110,RIO MAGDALENA:,"Fl.(4)G. | period 11s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 4.5s | ",26ft/8,6,201519 +-74.7898,11.026,X-13,,2,Fl.(4)G.,(4),4,G,11,0.5,6,8,,,,,,,,,"Red framework tower, white bands, red and white rectangular daymark.",16814 J6226,PUB 110,RIO MAGDALENA:,"Fl.(4)G. | period 11s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 1.5s | fl. 0.5s, ec. 4.5s | ",26ft/8,6,201519 +-74.785669,11.022944,X-15,,5,Q.(3)Y.,(3),6,Y,5,,4,8,,,,,,,,,"Orange framework tower, white bands.",16815 J6229,PUB 110,RIO MAGDALENA:,Q.(3)Y. | period 5s | ,26ft/8,4,201506 +-74.766811,11.010222,E-7,,8,Dir.Iso.W.R.G.,,"1,3,4","W,R,G",5,,8,16,,,,120.0,,,,"R. 118°30`-121°30`, W.-122°30`, G.-125°30` ",Red and white tower; 76.,16818 J6229.51,PUB 110,RIO MAGDALENA:,Dir.Iso.W.R.G. | period 5s | ,52ft/16,8,201918 +-74.778219,11.018667,X-17,,5,Q.(3)Y.,(3),6,Y,5,,4,8,,,,,,,,,"Orange framework tower, white bands.",16819 J6229.2,PUB 110,RIO MAGDALENA:,Q.(3)Y. | period 5s | ,26ft/8,4,201506 +-74.236664,11.071111,"Coal loading jetty, SW corner",,2,Fl.R.,,3,R,,,,,,,,,,,,,,16821 J6236,PUB 110,,Fl.R. | ,,,201433 +-74.236947,11.071111,NE corner,,2,Fl.R.,,3,R,,,,,,,,,,,,,,16822 J6236.1,PUB 110,,Fl.R. | ,,,201433 +-74.233311,11.1165,Simon Bolivar AVIATION LIGHT,,20,Al.Fl.W.G.,,"1,4","W,G",10,,,,,,,,,,,,Tower.,16828 J6254,PUB 110,,Al.Fl.W.G. | period 10s | ,,,201506 +-74.253333,11.153333,RACON,,,C(- • - • ),,1,W,,,,,,,,,,,,(3 & 10cm). ,,16831 J6255.30,PUB 110,,C(- • - • ) | ,,,201506 +-74.230581,11.25,Morro Grande,,2,Fl.W.,,1,W,15,1.0,22,85,,,,,,,,Obscured beyond Aguja Island 203°-212°. ,White and gray concrete tower; 76.,16832 J6256,PUB 110,BAHIA DE SANTA MARTA:,"Fl.W. | period 15s | fl. 1.0s, ec. 14.0s | ",279ft/85,22,201506 +-74.230581,11.25,RACON,,,S(• • • ),,1,W,,,,,,,,,,,,(3 & 10cm). ,,16832 J6256,PUB 110,BAHIA DE SANTA MARTA:,S(• • • ) | ,,,201506 diff --git a/nga_fetch.py b/nga_fetch.py new file mode 100644 index 0000000..8504d5c --- /dev/null +++ b/nga_fetch.py @@ -0,0 +1,425 @@ +""" +nga_fetch.py — Descarga ayudas a la navegacion del API NGA MSI (Pub. 110) +y genera CSVs listos para importar en QGIS, uno por tipo de objeto S-57. + +Uso: + python -X utf8 nga_fetch.py + python -X utf8 nga_fetch.py --lat0 10.0 --lat1 12.0 --lon0 -76.5 --lon1 -73.5 +""" + +import csv, json, re, ssl, sys, urllib.request +from pathlib import Path +from datetime import datetime + +# ── Bounding box por defecto: Costa Caribe colombiana ─────────────────────── +DEFAULT_LAT0, DEFAULT_LON0 = 9.5, -77.0 +DEFAULT_LAT1, DEFAULT_LON1 = 11.8, -73.5 + +OUT_DIR = Path(__file__).parent + +# ── Headers NGA (requiere origen mismo dominio) ────────────────────────────── +NGA_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Referer": "https://msi.nga.mil/Publications/NGALOL", + "Origin": "https://msi.nga.mil", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", +} + +NGA_URL = ( + "https://msi.nga.mil/api/publications/ngalol/lights-buoys" + "?latitudeLeft={lat0}&longitudeLeft={lon0}" + "&latitudeRight={lat1}&longitudeRight={lon1}" + "&includeRemovals=false&output=json" +) + +_SSL_CTX = ssl.create_default_context() +_SSL_CTX.check_hostname = False +_SSL_CTX.verify_mode = ssl.CERT_NONE + +# ── Rumbos conocidos de enfilaciones Bocas de Ceniza / Rio Magdalena ───────── +# Fuente: NGA Pub.110 Vol.D (Colombia) + geometria del canal DIMAR. +# E-1 y E-3: canal exterior (barra), E-6/E-8: primer tramo, etc. +# ATENCION: valores aproximados +/-1 grado. Verificar contra DIMAR Lista Faros. +KNOWN_ORIENT = { + 'E-1': '176.0', # Bocas de Ceniza, barra exterior + 'E-3': '176.0', # rear de E-1 + 'E-3A': '135.6', # ramal alternativo + 'E-6': '167.7', # primer codo (visible 166-170 segun NGA) + 'E-8': '167.7', # rear de E-6 + 'E-16': '122.0', # codo hacia Barranquilla + 'E-14': '122.0', # rear de E-16 (302-122 opuesto) + 'E-18': '142.2', # Dir luz W/R/G (sector W: 141.5-142.5) + 'E-20': '142.2', # rear de E-18 + 'E-4': '141.0', # Dir Iso W (interior) + 'E-7': '120.0', # Dir Iso W/R/G sector W 118.5-121.5 +} + +# ── Conversion DMS -> decimal ───────────────────────────────────────────────── +_DMS = re.compile(r'(\d+)[deg°º]\s*(\d+)[min′\'"]\s*([\d.]+)[sec"″]?\s*([NSEW])', re.I) +_DMS2 = re.compile(r'(\d+)[°º]\s*([\d.]+)[′\'"]\s*([NSEW])', re.I) + +def dms_dec(txt: str) -> float | None: + txt = txt.replace('\n', ' ').replace('\\u00b0', '°') + m = _DMS.search(txt) + if m: + d, mi, s, h = int(m[1]), int(m[2]), float(m[3]), m[4].upper() + v = d + mi/60 + s/3600 + return round(-v if h in ('S','W') else v, 6) + m = _DMS2.search(txt) + if m: + d, mi, h = int(m[1]), float(m[2]), m[3].upper() + v = d + mi/60 + return round(-v if h in ('S','W') else v, 6) + # Unicode grado (JSON de NGA viene con °) + m2 = re.search(r'(\d+)°(\d+)\'([\d.]+)"([NSEW])', txt) + if m2: + d, mi, s, h = int(m2[1]), int(m2[2]), float(m2[3]), m2[4].upper() + v = d + mi/60 + s/3600 + return round(-v if h in ('S','W') else v, 6) + nums = re.findall(r'[-\d.]+', txt) + return float(nums[0]) if nums else None + +def parse_pos(pos: str): + parts = re.split(r'\n', pos) + if len(parts) < 2: + return None, None + return dms_dec(parts[0]), dms_dec(parts[1]) + +# ── Tablas S-57 ─────────────────────────────────────────────────────────────── +LITCHR_FIXED = { + 'F':1, 'Fl':2, 'LFl':4, 'Q':5, 'VQ':6, 'UQ':7, + 'Iso':8, 'Oc':9, 'IQ':10, 'Mo':13, 'FFl':14, + 'Al.Oc':18, 'Al.LFl':19, 'Al.Fl':20, 'Dir':28, + 'Dir.Iso':8, 'Dir.Fl':2, 'Dir.Oc':9, 'Dir.LFl':4, + 'Q+LFl':25, 'VQ+LFl':26, +} + +_COLORES = {'Bu':5, 'Or':11, 'Am':6, 'Vi':7, 'W':1, 'R':3, 'G':4, 'Y':6, 'B':2} + +COLOUR_TXT_MAP = { + '1':'W','2':'B','3':'R','4':'G','5':'Bu','6':'Y','7':'Vi','11':'Or', +} + +_TIPOS = [ + 'Al.Fl','Al.Oc','Al.LFl','Dir.Iso','Dir.Fl','Dir.Oc','Dir.LFl', + 'VQ+LFl','Q+LFl','FFl','LFl','VQ','UQ','IQ','Mo','Iso','Oc','Fl','Q','F', +] + +# ── Parser de tokens de caracteristica ─────────────────────────────────────── +def _tokenize(s: str) -> list: + """'Fl.(4)W.R.' -> ['Fl','(4)','W','R'] sin puntos separadores.""" + tokens, cur, depth = [], '', 0 + for ch in s.rstrip('.'): + if ch == '(': + depth += 1; cur += ch + elif ch == ')': + depth -= 1; cur += ch + if depth == 0: + tokens.append(cur); cur = '' + elif ch == '.' and depth == 0: + if cur: tokens.append(cur); cur = '' + else: + cur += ch + if cur: tokens.append(cur) + return tokens + +_PER_RE = re.compile(r'period\s+([\d.]+)\s*s', re.I) +_FL_RE = re.compile(r'\bfl\.\s*([\d.]+)\s*s', re.I) +_SEC_RE = re.compile( + r'([WRGBY])\.\s*(\d+[°º]\d+[\'`])\s*[–-]\s*(\d+[°º]\d+[\'`])', re.I +) +_VIS_RE = re.compile(r'[Vv]isible\s+([\d.]+)[°º]\s*[–-]\s*([\d.]+)[°º]') +_BRG_RE = re.compile(r'(\d{2,3})\^([\d.]*)', re.I) # "139^18'" formato NGA + +def _deg_min(txt: str) -> str: + txt = txt.strip().replace('`',"'") + m = re.match(r'(\d+)[°º](\d+)', txt) + if m: return str(round(int(m[1]) + int(m[2])/60, 2)) + nums = re.findall(r'[\d.]+', txt) + return nums[0] if nums else txt + +def parse_char(char_str: str, remarks: str = '', name: str = '') -> dict: + """Extrae todos los campos S-57 de la caracteristica + remarks del NGA.""" + out = { + 'LITCHR':'', 'SIGGRP':'', 'COLOUR':'', 'SIGPER':'', + 'SIGPER2':'', 'SECTR1':'', 'SECTR2':'', 'MLTYLT':'', 'ORIENT':'', + } + if not char_str: + return out + + lines = char_str.split('\n') + base = lines[0].strip() + full = ' '.join(lines) + + tokens = _tokenize(base) + remaining = list(tokens) + + # 1) Tipo de luz (compuestos primero) + tipo_found = '' + for tipo in _TIPOS: + tp_tokens = tipo.split('.') + if remaining[:len(tp_tokens)] == tp_tokens: + tipo_found = tipo + remaining = remaining[len(tp_tokens):] + break + if tipo_found: + out['LITCHR'] = LITCHR_FIXED.get(tipo_found, tipo_found) + + # 2) Grupo opcional "(N)" o "(N+M)" + if remaining and remaining[0].startswith('('): + out['SIGGRP'] = remaining.pop(0) + + # 3) Colores: tokens de solo letras + colours = [] + for t in remaining: + if re.match(r'^[A-Za-z]+$', t): + # 2 letras primero, luego 1 + c = _COLORES.get(t) or _COLORES.get(t[:2]) or _COLORES.get(t[:1]) + if c: colours.append(str(c)) + out['COLOUR'] = ','.join(colours) if colours else '1' + + # 4) Periodo + pm = _PER_RE.search(full) + if pm: out['SIGPER'] = pm.group(1) + + # 5) Duracion destello + fm = _FL_RE.search(full) + if fm: out['SIGPER2'] = fm.group(1) + + # 6) Sectores y orientacion desde remarks + rem = (remarks or '').replace('`',"'").replace('',"'") + secs = _SEC_RE.findall(rem) + if secs: + lst = [f"{c.upper()}:{_deg_min(s1)}-{_deg_min(s2)}" + for c, s1, s2 in secs] + out['MLTYLT'] = ' | '.join(lst) + _, s1, s2 = secs[0] + out['SECTR1'] = _deg_min(s1) + out['SECTR2'] = _deg_min(s2) + + if not out['SECTR1']: + vm = _VIS_RE.search(rem + ' ' + full) + if vm: + out['SECTR1'] = vm.group(1) + out['SECTR2'] = vm.group(2) + + # 7) Rumbo de enfilacion desde formato NGA "310 meters 139^18' from front" + bm = _BRG_RE.search(rem + ' ' + full) + if bm: + deg = int(bm.group(1)) + frac = bm.group(2) + if frac: + deg_dec = round(deg + float(frac)/60, 1) + else: + deg_dec = float(deg) + # El rumbo NGA es desde el frente al trasero -> invertir para ORIENT + # (ORIENT = rumbo inbound = desde el mar hacia el muelle) + orient = round((deg_dec + 180) % 360, 1) + out['ORIENT'] = str(orient) + + # 8) Rumbo conocido de tabla local (mas preciso que calculo) + # Buscar el ID de la enfilacion en el nombre + for key, val in KNOWN_ORIENT.items(): + if key in name: + out['ORIENT'] = val + # Si el sector visible es estrecho y ORIENT conocido, derivar sectores + if not out['SECTR1'] and out['LITCHR'] == 28: # Dir light + ang = float(val) + out['SECTR1'] = str(round(ang - 1.5, 1)) + out['SECTR2'] = str(round(ang + 1.5, 1)) + break + + return out + +# ── Altura en metros desde "heightFeetMeters" ─────────────────────────────── +def parse_height(hfm: str) -> str: + if not hfm: return '' + parts = hfm.strip().split('\n') + if len(parts) >= 2 and parts[1].strip(): + return parts[1].strip() + nums = re.findall(r'[\d.]+', parts[0]) + return nums[0] if nums else '' + +# ── Clasificacion S-57 ─────────────────────────────────────────────────────── +def classify(rec: dict) -> str: + name = (rec.get('name') or '').lower() + struc = (rec.get('structure') or '').lower() + aid = (rec.get('aidType') or '').lower() + if 'buoy' in struc or 'buoy' in aid: + if any(w in name+struc for w in ['north','south','east','west','cardinal']): + return 'BOYCAR' + if any(w in name+struc for w in ['safe water','fairway','spherical']): + return 'BOYSAW' + return 'BOYLAT' + if 'beacon' in struc or 'beacon' in aid: + return 'BCNLAT' + return 'LIGHTS' + +# ── Fetch ──────────────────────────────────────────────────────────────────── +def fetch(lat0, lon0, lat1, lon1) -> list: + url = NGA_URL.format(lat0=lat0, lon0=lon0, lat1=lat1, lon1=lon1) + print(f"Consultando NGA MSI...\n{url}\n") + req = urllib.request.Request(url, headers=NGA_HEADERS) + try: + with urllib.request.urlopen(req, timeout=25, context=_SSL_CTX) as r: + data = json.loads(r.read().decode('utf-8')) + except urllib.error.HTTPError as e: + print(f"Error HTTP {e.code}: {e.reason}"); sys.exit(1) + except Exception as e: + print(f"Error conexion: {e}"); sys.exit(1) + recs = data.get('ngalol') or [] + print(f" -> {len(recs)} registros recibidos") + return recs + +# ── Campos CSV (S-57 completo) ─────────────────────────────────────────────── +FIELDS = [ + 'lon','lat', + 'OBJNAM','NOBJNM', + # Luz + 'LITCHR','LITCHR_TXT', + 'SIGGRP', + 'COLOUR','COLOUR_TXT', + 'SIGPER','SIGPER2', + 'VALNMR', + 'HEIGHT', + # Sectores / orientacion + 'SECTR1','SECTR2', + 'MLTYLT', + 'ORIENT', + # Boya + 'BOYSHP','CATLBR','CATCAM', + # Texto + 'INFORM','TXTDSC', + # Referencia NGA + '_nga_feature','_nga_volume','_nga_region', + '_nga_char_raw','_nga_hfm_raw','_nga_range_raw','_nga_notice', +] + +def write_csv(path: Path, rows: list): + with open(path, 'w', newline='', encoding='utf-8-sig') as f: + w = csv.DictWriter(f, fieldnames=FIELDS, extrasaction='ignore') + w.writeheader() + w.writerows(rows) + print(f" OK {path.name} ({len(rows)} registros)") + +# ── Main ───────────────────────────────────────────────────────────────────── +def main(): + lat0, lon0, lat1, lon1 = DEFAULT_LAT0, DEFAULT_LON0, DEFAULT_LAT1, DEFAULT_LON1 + args = sys.argv[1:] + for i, a in enumerate(args): + if a=='--lat0' and i+13} registros") + + # Escribir CSVs + print(f"\nEscribiendo CSVs en: {OUT_DIR}\n") + ts = datetime.now().strftime('%Y%m%d') + for k, rows in buckets.items(): + if rows: + write_csv(OUT_DIR / f'nga_{k}_{ts}.csv', rows) + + # Preview tabla completa + print(f"\n{'OBJNAM':<45} {'CHAR':<15} {'COL':<8} {'PER':>5} {'RNG':>4} {'HGT':>4} {'ORI':>6} {'SEC':}") + print('-'*115) + for r in buckets['LIGHTS']: + sec = f"{r['SECTR1']}-{r['SECTR2']}" if r['SECTR1'] else '' + print(f"{r['OBJNAM'][:44]:<45} {r['LITCHR_TXT'][:14]:<15} " + f"{r['COLOUR_TXT']:<8} {r['SIGPER']:>5}s {r['VALNMR']:>4}NM " + f"{r['HEIGHT']:>4}m {r['ORIENT']:>6} {sec}") + + print("\nListo. En QGIS: Capa -> Anadir capa -> Texto delimitado") + print("X=lon Y=lat CRS=EPSG:4326 Renombre capa con codigo S-57") + print("ATENCION: ORIENT de enfilaciones son aproximados.") + print("Verificar contra DIMAR Lista de Faros antes de publicar carta.") + +if __name__ == '__main__': + main() diff --git a/noaa_ddr_template.bin b/noaa_ddr_template.bin new file mode 100644 index 0000000..804082b --- /dev/null +++ b/noaa_ddr_template.bin @@ -0,0 +1 @@ +015823LE1 0900201 ! 34040000123000000010470123DSID1650170DSSI1130335DSPM1300448VRID0780578ATTV0580656VRPT0760714SG2D0480790SG3D0700838FRID1000908FOID0701008ATTF0591078NATF0681137FFPT0861205FSPT09012910000;& 0001DSIDDSIDDSSI0001DSPM0001VRIDVRIDATTVVRIDVRPTVRIDSG2DVRIDSG3D0001FRIDFRIDFOIDFRIDATTFFRIDNATFFRIDFFPTFRIDFSPT0500;& ISO/IEC 8211 Record Identifier(b12)1600;& Data set identification fieldRCNM!RCID!EXPP!INTU!DSNM!EDTN!UPDN!UADT!ISDT!STED!PRSP!PSDN!PRED!PROF!AGEN!COMT(b11,b14,2b11,3A,2A(8),R(4),b11,2A,b11,b12,A)1600;& Data set structure information fieldDSTR!AALL!NALL!NOMR!NOCR!NOGR!NOLR!NOIN!NOCN!NOED!NOFA(3b11,8b14)1600;& Data set parameter fieldRCNM!RCID!HDAT!VDAT!SDAT!CSCL!DUNI!HUNI!PUNI!COUN!COMF!SOMF!COMT(b11,b14,3b11,b14,4b11,2b14,A)1600;& Vector record identifier fieldRCNM!RCID!RVER!RUIN(b11,b14,b12,b11)2600;& Vector record attribute field*ATTL!ATVL(b12,A)2600;& Vector record pointer field*NAME!ORNT!USAG!TOPI!MASK(B(40),4b11)2500;& 2-D coordinate field*YCOO!XCOO(2b24)2500;& 3-D coordinate (sounding array) field*YCOO!XCOO!VE3D(3b24)1600;& Feature record identifier fieldRCNM!RCID!PRIM!GRUP!OBJL!RVER!RUIN(b11,b14,2b11,2b12,b11)1600;& Feature object identifier fieldAGEN!FIDN!FIDS(b12,b14,b12)2600;&-A Feature record attribute field*ATTL!ATVL(b12,A)2600;&-A Feature record national attribute field*ATTL!ATVL(b12,A)2600;& Feature record to feature object pointer field*LNAM!RIND!COMT(B(64),b11,A)2600;& Feature record to spatial record pointer field*NAME!ORNT!USAG!MASK(B(40),3b11) \ No newline at end of file diff --git a/parse_lista_luces.py b/parse_lista_luces.py new file mode 100644 index 0000000..69c06b9 --- /dev/null +++ b/parse_lista_luces.py @@ -0,0 +1,275 @@ +""" +parse_lista_luces.py +Genera CSV de ayudas a la navegacion de Barranquilla a partir de +Lista de Luces DIMAR 2015 (ya parseada manualmente). +""" +import csv, re, sys, os +from collections import Counter + +def dms_to_dd(deg, minutes): + return float(deg) + float(minutes) / 60.0 + +# ---- DATA ---- +# (no, name, lat_deg, lat_min, lon_deg, lon_min, char, height_m, range_nm, colour, description, feat_type, orient) +records = [ + # FAROS MAYORES + (13, 'Faro F1 Recalada', 11, 6.37, 74, 50.97, 'Iso G 2s', 20, 9, 'G', 'Torre naranja bandas blancas. Faro de Recalada', 'LIGHTS', ''), + (14, 'Faro F2 Recalada', 11, 6.36, 74, 51.28, 'Iso R 2s', 23, 13.4, 'R', 'Torre naranja bandas blancas. Racon B', 'LIGHTS', ''), + (32, 'Faro Morro Hermoso', 10, 57.80, 75, 1.05, 'Fl W 4s', 134, 28, 'W', 'Torre blanca bandas rojas. Giratorio', 'LIGHTS', ''), + (33, 'Faro Galerazamba', 10, 47.12, 75, 15.96, 'Fl W 4s', 14, 11, 'W', 'Torre fibra vidrio blanca bandas rojas. Giratorio', 'LIGHTS', ''), + + # FAROLES X (Tajamares Bocas de Ceniza) + # Estas son balizas laterales fijas en tierra → BCNLAT (no LIGHTS). + # IALA-B: Verde = babor (CATLAM=1), Rojo = estribor (CATLAM=2). + # La luz se extrae automáticamente de LITCHR/SIGGRP/SIGPER/COLOUR. + (15, 'Faro X1', 11, 6.13, 74, 50.97, 'Q(4)G 11s', 6, 6, 'G', 'Torre verde bandas blancas', 'BCNLAT', ''), + (16, 'Faro X2', 11, 6.00, 74, 51.20, 'Q(4)R 11s', 6, 6, 'R', 'Torre roja bandas blancas', 'BCNLAT', ''), + (18, 'Faro X3', 11, 5.48, 74, 50.83, 'Q(4)G 11s', 6, 6, 'G', 'Torre verde bandas blancas', 'BCNLAT', ''), + (17, 'Faro X4', 11, 5.58, 74, 51.10, 'Q(4)R 11s', 6, 6, 'R', 'Torre roja bandas blancas', 'BCNLAT', ''), + (19, 'Faro X5', 11, 5.35, 74, 50.80, 'Q(4)G 11s', 6, 6, 'G', 'Torre verde bandas blancas', 'BCNLAT', ''), + (20, 'Faro X6', 11, 5.26, 74, 51.03, 'Q(4)R 11s', 6, 6, 'R', 'Torre roja bandas blancas', 'BCNLAT', ''), + (21, 'Faro X7', 11, 2.49, 74, 48.86, 'Q(4)G 11s', 8, 6, 'G', 'Baliza enrejado verde bandas blancas', 'BCNLAT', ''), + (22, 'Faro X8', 11, 4.90, 74, 50.95, 'Q(4)R 11s', 6, 6, 'R', 'Torre roja bandas blancas', 'BCNLAT', ''), + (23, 'Faro X9', 11, 2.15, 74, 48.29, 'Q(4)G 11s', 8, 6, 'G', 'Baliza enrejado verde bandas blancas', 'BCNLAT', ''), + (24, 'Faro X10', 11, 4.56, 74, 50.88, 'Q(4)R 11s', 6, 6, 'R', 'Torre roja bandas blancas', 'BCNLAT', ''), + (25, 'Faro X11', 11, 1.79, 74, 47.73, 'Q(4)G 11s', 8, 6, 'G', 'Baliza enrejado verde bandas blancas', 'BCNLAT', ''), + (26, 'Faro X12', 11, 3.90, 74, 50.65, 'Q(4)R 11s', 6, 6, 'R', 'Baliza enrejado roja bandas blancas', 'BCNLAT', ''), + (27, 'Faro X13', 11, 1.55, 74, 47.38, 'Q(4)G 11s', 8, 6, 'G', 'Baliza enrejado verde bandas blancas', 'BCNLAT', ''), + (28, 'Faro X14', 11, 3.47, 74, 50.37, 'Q(4)R 11s', 6, 6, 'R', 'Torre roja bandas blancas', 'BCNLAT', ''), + (30, 'Faro X15', 11, 1.37, 74, 47.13, 'Q(4)G 11s', 6, 6, 'G', 'Baliza enrejado verde bandas blancas', 'BCNLAT', ''), + (29, 'Faro X16', 11, 3.00, 74, 49.98, 'Q(4)R 11s', 6, 6, 'R', 'Torre roja bandas blancas', 'BCNLAT', ''), + (31, 'Faro X17', 11, 1.12, 74, 46.70, 'Q(4)G 11s', 6, 6, 'G', 'Baliza enrejado verde bandas blancas', 'BCNLAT', ''), + + # ENFILACIONES / RANGE LIGHTS + (196, 'Enfilacion E1', 11, 6.22, 74, 50.90, 'Iso Bu 5s', 10, 13, 'W', 'Baliza enrejado naranja y blanco. Rumbo 135.7', 'LIGHTS', '135.7'), + (197, 'Enfilacion E3', 11, 6.10, 74, 50.78, 'Iso Bu 5s', 22, 9, 'W', 'Torre enrejada naranja y blanco. Rumbo 139.3', 'LIGHTS', '139.3'), + (198, 'Enfilacion E3A', 11, 6.02, 74, 50.70, 'Iso W 5s', 20, 12.3, 'W', 'Torre naranja y blanco. Rumbo 135.7', 'LIGHTS', '135.7'), + (201, 'Enfilacion E4', 11, 4.21, 74, 50.81, 'Iso R 4s', 11, 4.5, 'R', 'Baliza enrejado naranja bandas blancas. Rumbo 142.3', 'LIGHTS', '142.3'), + (203, 'Enfilacion E6', 11, 3.78, 74, 50.62, 'Iso Bu 4s', 12, 8, 'R', 'Baliza enrejado roja bandas blancas. Rumbo 167.7', 'LIGHTS', '167.7'), + (204, 'Enfilacion E8', 11, 3.51, 74, 50.51, 'Iso Bu 4s', 25, 14.5, 'W', 'Baliza enrejado naranja bandas blancas. Rumbo 167.7', 'LIGHTS', '167.7'), + (205, 'Enfilacion E10', 11, 3.59, 74, 50.50, 'Iso G 5s', 11, 10, 'G', 'Torre naranja bandas blancas. Rumbo 167.3', 'LIGHTS', '167.3'), + (206, 'Enfilacion E12', 11, 3.37, 74, 50.44, 'Iso G 5s', 22, 8, 'G', 'Baliza tablero blanco franja roja. Rumbo 167.3', 'LIGHTS', '167.3'), + (207, 'Enfilacion E14', 11, 3.37, 74, 50.44, 'Iso Bu 6s', 22, 8, 'W', 'Tablero blanco con franja roja. Rumbo 122', 'LIGHTS', '122'), + (237, 'Enfilacion E16', 11, 3.22, 74, 50.20, 'Iso Bu 6s', 12, 9, 'W', 'Baliza enrejado naranja bandas blancas. Rumbo 122', 'LIGHTS', '122'), + (238, 'Enfilacion E18', 11, 2.58, 74, 49.53, 'Fl WRG 4s', 18, 6, 'WRG', 'Torre roja bandas blancas. Sector 9 grados. Rumbo 142', 'LIGHTS', '142'), + + # BOYAS LATERALES CANAL RIO MAGDALENA + (199, 'Boya No. 1', 11, 5.07, 74, 50.01, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (200, 'Boya No. 3', 11, 4.55, 74, 50.69, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (202, 'Boya No. 5', 11, 3.94, 74, 50.46, 'Q G 1s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (208, 'Boya No. 7', 11, 3.60, 74, 50.25, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (209, 'Boya No. 9', 11, 2.81, 74, 49.44, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (210, 'Boya No. 11', 11, 2.38, 74, 48.73, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (211, 'Boya No. 12', 11, 2.23, 74, 48.81, 'Fl R 3s', 4, 6, 'R', 'Castillete roja', 'BOYLAT', ''), + (212, 'Boya No. 13', 11, 2.06, 74, 48.12, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (213, 'Boya No. 14', 11, 1.31, 74, 47.30, 'Fl R 3s', 3, 6, 'R', 'Castillete roja', 'BOYLAT', ''), + (214, 'Boya No. 15', 11, 1.69, 74, 47.63, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (215, 'Boya No. 16', 11, 1.64, 74, 47.85, 'Fl R 3s', 4, 6, 'R', 'Castillete roja', 'BOYLAT', ''), + (218, 'Boya No. 18', 11, 1.31, 74, 47.30, 'Fl R 3s', 4, 6, 'R', 'Castillete roja', 'BOYLAT', ''), + (219, 'Boya No. 19', 11, 1.05, 74, 46.59, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (220, 'Boya No. 20', 11, 0.93, 74, 46.64, 'Fl R 3s', 4, 6, 'R', 'Castillete roja', 'BOYLAT', ''), + (221, 'Boya No. 21', 11, 0.84, 74, 46.32, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (222, 'Boya No. 22', 11, 0.74, 74, 46.40, 'Q R 1s', 4, 6, 'R', 'Castillete roja', 'BOYLAT', ''), + (223, 'Boya No. 23', 10, 58.50, 74, 45.30, 'Fl G 1.3s', 3, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (224, 'Boya No. 24', 11, 0.55, 74, 46.23, 'Fl R 3s', 4, 6, 'R', 'Castillete roja', 'BOYLAT', ''), + (225, 'Boya No. 25', 11, 0.40, 74, 45.98, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (226, 'Boya No. 26', 11, 0.35, 74, 46.10, 'Fl R 3s', 4, 6, 'R', 'Castillete roja', 'BOYLAT', ''), + (227, 'Boya No. 27', 10, 59.91, 74, 45.72, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (228, 'Boya No. 28', 10, 59.99, 74, 45.92, 'Fl R 3s', 4, 6, 'R', 'Castillete roja', 'BOYLAT', ''), + (229, 'Boya No. 29', 10, 59.24, 74, 45.48, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (230, 'Boya No. 30', 10, 59.24, 74, 45.64, 'Fl R 3s', 4, 6, 'R', 'Castillete roja', 'BOYLAT', ''), + (231, 'Boya No. 31', 10, 58.50, 74, 45.29, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (232, 'Boya No. 33', 10, 57.56, 74, 45.34, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (233, 'Boya No. 35', 10, 56.56, 74, 45.25, 'Fl G 3s', 4, 6, 'G', 'Castillete verde', 'BOYLAT', ''), + (234, 'Boya No. 36', 10, 56.49, 74, 45.40, 'Fl R 3s', 4, 6, 'R', 'Castillete roja', 'BOYLAT', ''), + (235, 'Boya Cardinal Norte', 10, 57.55, 74, 45.21, 'Fl W 1s', 4, 6, 'W', 'Castillete cardinal N negros', 'BOYCAR', ''), + (236, 'Boya Cardinal Sur', 10, 57.55, 74, 45.21, 'Fl W 15s', 4, 6, 'W', 'Castillete cardinal S negros', 'BOYCAR', ''), + (239, 'Boya de Oleaje', 11, 8.04, 74, 45.48, 'Fl Y 20s', 0.5, 4.5, 'Y', 'Esferica amarilla. Recolectora datos oceanograficos', 'BOYSPEC', ''), + (240, 'Boya Peligro Aislado', 10, 57.27, 74, 45.44, 'Fl(2) W 4s', 3.3, 3, 'W', 'Castillete roja bandas negras. Bajo rocoso', 'BOYISD', ''), +] + +# ════════════════════════════════════════════════════════════════════════════ +# IHO S-57 Ed.3.1 — códigos OFICIALES (no inventados) +# Ref: s57attributes.csv distribuido con GDAL/OGR +# ════════════════════════════════════════════════════════════════════════════ + +# COLOUR codes (ATTL 75) +colour_s57 = { + 'W': '1', # White + 'K': '2', # Black + 'R': '3', # Red + 'G': '4', # Green + 'B': '5', # Blue + 'Y': '6', # Yellow + 'Gy': '7', # Grey + 'Br': '8', # Brown + 'Amb': '9', # Amber + 'Vi': '10', # Violet + 'Or': '11', # Orange + 'Mg': '12', # Magenta + 'WRG': '1,3,4', # White/Red/Green (multi-colour sectors) + '': '1', # default white +} +colour_name = { + 'W': 'white', 'K': 'black', 'R': 'red', 'G': 'green', + 'B': 'blue', 'Y': 'yellow', 'Gy': 'grey', 'Br': 'brown', + 'Amb': 'amber','Vi': 'violet','Or': 'orange', 'Mg': 'magenta', + 'WRG': 'white/red/green', '': 'white', +} + +# LITCHR codes (ATTL 107) — S-57 Ed.3.1 official values +char_map = { + 'F': '1', # Fixed + 'Fl': '2', # Flashing + 'LFl': '3', # Long flashing + 'Q': '4', # Quick (50-60/min) + 'VQ': '5', # Very quick (100-120/min) + 'UQ': '6', # Ultra quick (≥160/min) + 'Iso': '7', # Isophase + 'Oc': '8', # Occulting + 'IQ': '9', # Interrupted quick + 'IVQ': '10', # Interrupted very quick + 'IUQ': '11', # Interrupted ultra quick + 'Mo': '12', # Morse code + 'FFl': '13', # Fixed and flashing + 'Fl+LFl': '14', + 'Oc+Fl': '15', + 'Al.Oc': '25', + 'Al.LFl': '26', + 'Al.Fl': '27', + 'Al.Grp': '28', +} + +# BOYSHP codes (ATTL 4) +# 1=Conical 2=Can 3=Sphere 4=Pillar 5=Spar 6=Barrel 7=Super-buoy 8=Ice buoy +# Barranquilla buoys are "castillete" → pillar (4) +BOYSHP_CASTILLETE = '4' + +# BCNSHP codes (ATTL 2) +# 1=Stake 2=Withy 3=Tower 4=Lattice 5=Pile 6=Cairn 7=Buoyant beacon +# "Torre" → 3, "Baliza enrejado" / "celosía" → 4 +def bcnshp_from_desc(desc): + d = desc.lower() + if 'enrejad' in d or 'celosí' in d or 'lattice' in d: + return '4' # Lattice tower + return '3' # Solid tower (default) + +# CATLAM codes (ATTL 36) for IALA-B (Américas): +# Green marks = port side (left hand entering) → CATLAM=1 +# Red marks = starboard side (right hand entering) → CATLAM=2 +def catlam_from_colour(col_letter): + return {'G': '1', 'R': '2'}.get(col_letter, '') + +# CATCAM codes (ATTL 13) for cardinal marks: +# 1=N 2=E 3=S 4=W +def catcam_from_name(name): + nl = name.lower() + if 'norte' in nl or 'north' in nl: return '1' + if 'este' in nl or 'east' in nl: return '2' + if 'sur' in nl or 'south' in nl: return '3' + if 'oeste' in nl or 'west' in nl: return '4' + return '' + +def parse_char(char_str): + """Parse human-readable light character string → (litchr_code, siggrp, sigper). + Examples: 'Q(4)G 11s' → ('4','4','11') + 'Fl W 4s' → ('2','','4') + 'Iso Bu 5s' → ('7','','5') + """ + # Extract base character (longest prefix match, try longest first) + base = re.split(r'[\s\(\.]', char_str)[0] + # Try compound chars first (Al.Fl, Fl+LFl …) + litchr = None + for key in sorted(char_map, key=len, reverse=True): + if char_str.startswith(key): + litchr = char_map[key] + break + if litchr is None: + litchr = char_map.get(base, '2') # default Fl if unknown + mg = re.search(r'\((\d+)\)', char_str) + siggrp = mg.group(1) if mg else '' + mp = re.search(r'([\d.]+)s', char_str) + sigper = mp.group(1) if mp else '' + return litchr, siggrp, sigper + +# ──────────────────────────────────────────────────────────────────────────── +# Campos del CSV de salida — usan nombres de atributos S-57 directamente +# para que el converter los recoja sin ningún mapeo adicional. +# ──────────────────────────────────────────────────────────────────────────── +fields = ['no_dimar', 'OBJNAM', 'lon', 'lat', 'feat_type', + 'LITCHR', 'LITCHR_TXT', 'SIGGRP', 'SIGPER', + 'COLOUR', 'COLOUR_TXT', 'COLPAT', + 'VALNMR', 'HEIGHT', 'ORIENT', + 'CATLAM', 'CATCAM', 'BOYSHP', 'BCNSHP', 'TOPSHP', + 'INFORM', '_dimar_char_raw', '_source'] + +out_path = r'D:\Proyectos Software\QGISS57Converter\dimar_ayudas_barranquilla.csv' + +with open(out_path, 'w', newline='', encoding='utf-8') as f: + w = csv.DictWriter(f, fieldnames=fields) + w.writeheader() + for rec in records: + no, name, lat_d, lat_m, lon_d, lon_m, char, ht, rng, col, desc, ftype, orient = rec + lat = dms_to_dd(lat_d, lat_m) + lon = -dms_to_dd(lon_d, lon_m) # West = negative + litchr, siggrp, sigper = parse_char(char) + + catlam = '' + catcam = '' + boyshp = '' + bcnshp = '' + topshp = '' + colpat = '' + + if ftype == 'BOYLAT': + # Castillete lateral: pillar (4), with CATLAM + boyshp = BOYSHP_CASTILLETE + catlam = catlam_from_colour(col) + elif ftype == 'BCNLAT': + # Faros de orilla (X marks): shore fixed beacon with CATLAM + bcnshp = bcnshp_from_desc(desc) + catlam = catlam_from_colour(col) + elif ftype in ('BOYCAR', 'BCNCAR'): + boyshp = '4' # pillar for cardinal buoys + catcam = catcam_from_name(name) + colpat = '1' # horizontal bands (standard cardinal pattern) + elif ftype == 'BOYISD': + boyshp = '4' + colpat = '1' # horizontal bands (black over red) + elif ftype == 'BOYSAW': + boyshp = '1' # conical or spherical for safe water + colpat = '4' # vertical stripes (white/red) + elif ftype in ('BOYSPEC', 'BOYSPP'): + boyshp = '3' # sphere for special/data buoys + ftype = 'BOYSPP' # normalise to S-57 acronym + + w.writerow({ + 'no_dimar': no, + 'OBJNAM': name, + 'lon': f'{lon:.6f}', + 'lat': f'{lat:.6f}', + 'feat_type': ftype, + 'LITCHR': litchr, + 'LITCHR_TXT': char.split()[0], + 'SIGGRP': siggrp, + 'SIGPER': sigper, + 'COLOUR': colour_s57.get(col, '1'), + 'COLOUR_TXT': colour_name.get(col, 'white'), + 'COLPAT': colpat, + 'VALNMR': rng, + 'HEIGHT': ht, + 'ORIENT': orient, + 'CATLAM': catlam, + 'CATCAM': catcam, + 'BOYSHP': boyshp, + 'BCNSHP': bcnshp, + 'TOPSHP': topshp, + 'INFORM': desc, + '_dimar_char_raw': char, + '_source': 'DIMAR Lista de Luces 2015', + }) + +print(f'Saved {len(records)} records -> {out_path}') +types = Counter(r[11] for r in records) +for t, c in sorted(types.items()): + print(f' {t}: {c}') diff --git a/poc_wabasso.py b/poc_wabasso.py new file mode 100644 index 0000000..b272da9 --- /dev/null +++ b/poc_wabasso.py @@ -0,0 +1,65 @@ +""" +PoC: produce a minimal valid S-57 .000 for the Wabasso test area and verify it +opens cleanly with fiona (i.e., GDAL's S-57 driver). + +Expected result: fiona.listlayers() shows M_COVR + BOYLAT (and no warnings about +broken bounds), and bounds match the envelope we encoded. +""" +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent)) +from s57_writer import ( + S57Cell, OBJL_M_COVR, OBJL_BOYLAT, + ATTL_CATCOV, ATTL_CATLAM, +) + +# Wabasso area envelope (lon, lat): +W, S, E, N = -80.4588, 27.7519, -80.4574, 27.7609 + +OUTPUT = Path(__file__).parent / "test_wabasso.000" + +cell = S57Cell( + dsnm="TESTPOC.000", + edition=1, + intu=5, # Harbour + scale=10000, + agen=999, + comt="QGISS57Converter PoC", + issue_date="20260428", +) + +# 1) Mandatory M_COVR feature: rectangular envelope, CATCOV=1 (data covered) +cell.add_area_feature( + objl=OBJL_M_COVR, + ring=[(W, S), (E, S), (E, N), (W, N), (W, S)], + attrs=[(ATTL_CATCOV, "1")], # CATCOV=1 (coverage present) +) + +# 2) One BOYLAT (lateral buoy, port hand IALA-B = CATLAM=1) at the centre +cx = (W + E) / 2 +cy = (S + N) / 2 +cell.add_point_feature( + objl=OBJL_BOYLAT, + lon=cx, lat=cy, + attrs=[(ATTL_CATLAM, "1")], # CATLAM=1 (port-hand lateral mark) +) + +cell.write(OUTPUT) +print(f"Wrote {OUTPUT} ({OUTPUT.stat().st_size} bytes)") + +# ── Verify with fiona ──────────────────────────────────────────────────────── +print() +print("Verifying with fiona...") +try: + import fiona + layers = fiona.listlayers(str(OUTPUT)) + print(f" fiona.listlayers: {layers}") + for L in layers: + try: + with fiona.open(str(OUTPUT), layer=L) as src: + print(f" {L:10s} bounds={src.bounds} features={len(src)}") + except Exception as e: + print(f" {L}: open ERROR: {e}") +except Exception as e: + print(f" fiona import or listlayers failed: {e}") diff --git a/s57_objects.json b/s57_objects.json new file mode 100644 index 0000000..6df1b87 --- /dev/null +++ b/s57_objects.json @@ -0,0 +1,117 @@ +{ + "_comment": "S-57 object class catalog — IHO S-57 Ed 3.1. Key: S-57 acronym. geom: A=Area, L=Line, P=Point, C=Collection", + "COALNE": { "desc": "Coastline", "geom": ["L"], "attrs": [] }, + "LNDARE": { "desc": "Land Area", "geom": ["A"], "attrs": [] }, + "DEPARE": { "desc": "Depth Area", "geom": ["A"], "attrs": ["DRVAL1","DRVAL2"] }, + "DEPCNT": { "desc": "Depth Contour", "geom": ["L"], "attrs": ["VALDCO"] }, + "SOUNDG": { "desc": "Sounding", "geom": ["P"], "attrs": ["VALSOU","QUASOU","SOUACC"] }, + "DRGARE": { "desc": "Dredged Area", "geom": ["A"], "attrs": ["DRVAL1","DRVAL2"] }, + "SBDARE": { "desc": "Seabed Area", "geom": ["A","P"], "attrs": ["NATSUR","NATQUA"] }, + "OBSTRN": { "desc": "Obstruction", "geom": ["A","L","P"], "attrs": ["VALSOU","CATOBS"] }, + "WRECKS": { "desc": "Wreck", "geom": ["A","P"], "attrs": ["VALSOU","CATWRK"] }, + "UWTROC": { "desc": "Underwater / Awash Rock","geom": ["P"], "attrs": ["VALSOU","WATLEV"] }, + "LIGHTS": { "desc": "Light", "geom": ["P"], "attrs": ["LITCHR","SIGPER","HEIGHT","VALNMR","COLOUR","ORIENT","SIGGRP","SECTR1","SECTR2"] }, + "LNDMRK": { "desc": "Landmark", "geom": ["A","P"], "attrs": ["CATLMK","HEIGHT"] }, + "BOYLAT": { "desc": "Lateral Buoy", "geom": ["P"], "attrs": ["CATLAM","COLOUR","COLPAT","LITCHR","SIGPER","SIGGRP","VALNMR"] }, + "BOYCAR": { "desc": "Cardinal Buoy", "geom": ["P"], "attrs": ["CATCAM","COLOUR","LITCHR","SIGPER","VALNMR"] }, + "BOYISD": { "desc": "Isolated Danger Buoy", "geom": ["P"], "attrs": ["COLOUR","LITCHR","SIGPER","SIGGRP","VALNMR"] }, + "BOYSPP": { "desc": "Special Purpose Buoy", "geom": ["P"], "attrs": ["CATSPM","COLOUR","LITCHR","SIGPER","VALNMR"] }, + "BOYSPEC":{ "desc": "Special Purpose Buoy (alias→BOYSPP)", "geom": ["P"], "attrs": ["CATSPM","COLOUR","LITCHR","SIGPER","VALNMR"] }, + "BOYSAW": { "desc": "Safe Water Buoy", "geom": ["P"], "attrs": ["COLOUR","LITCHR","SIGPER","VALNMR"] }, + "BCNLAT": { "desc": "Lateral Beacon", "geom": ["P"], "attrs": ["CATLAM","COLOUR"] }, + "BCNCAR": { "desc": "Cardinal Beacon", "geom": ["P"], "attrs": ["CATCAM","COLOUR"] }, + "BCNISD": { "desc": "Isolated Danger Beacon", "geom": ["P"], "attrs": ["COLOUR"] }, + "BCNSAW": { "desc": "Safe Water Beacon", "geom": ["P"], "attrs": ["COLOUR"] }, + "BCNSPP": { "desc": "Special Purpose Beacon", "geom": ["P"], "attrs": ["CATSPM","COLOUR"] }, + "LITFLT": { "desc": "Light Float", "geom": ["P"], "attrs": ["LITCHR","SIGPER","COLOUR"] }, + "LITVES": { "desc": "Light Vessel", "geom": ["P"], "attrs": ["LITCHR","SIGPER"] }, + "TOPMAR": { "desc": "Topmark", "geom": ["P"], "attrs": ["TOPSHP","COLOUR"] }, + "DAYMAR": { "desc": "Daymark", "geom": ["P"], "attrs": ["CATLAM","COLOUR"] }, + "FOGSIG": { "desc": "Fog Signal", "geom": ["P"], "attrs": ["CATFOG","SIGPER"] }, + "NAVLNE": { "desc": "Navigation Line", "geom": ["L"], "attrs": ["CATNAV"] }, + "LDLINE": { "desc": "Leading Line", "geom": ["L"], "attrs": [] }, + "TSSLPT": { "desc": "Traffic Separation Lane Pt", "geom": ["A"], "attrs": [] }, + "TSSRON": { "desc": "Traffic Separation Roundabout", "geom": ["A"], "attrs": [] }, + "TSSBND": { "desc": "Traffic Separation Boundary", "geom": ["L"], "attrs": [] }, + "TSSCRS": { "desc": "Traffic Separation Crossing", "geom": ["A"], "attrs": [] }, + "TSELNE": { "desc": "Traffic Separation Line", "geom": ["L"], "attrs": [] }, + "DWRTCL": { "desc": "Deep Water Route Centre Line", "geom": ["L"], "attrs": [] }, + "DWRTPT": { "desc": "Deep Water Route Part", "geom": ["A"], "attrs": [] }, + "TWRTPT": { "desc": "Two-way Route Part", "geom": ["A"], "attrs": [] }, + "SUBTLN": { "desc": "Submarine Transit Lane","geom": ["A"], "attrs": [] }, + "ISTZNE": { "desc": "Inshore Traffic Zone", "geom": ["A"], "attrs": [] }, + "RECTRC": { "desc": "Recommended Track", "geom": ["L"], "attrs": ["CATTRK"] }, + "FERYRT": { "desc": "Ferry Route", "geom": ["A","L"], "attrs": ["CATFRY"] }, + "FAIRWY": { "desc": "Fairway", "geom": ["A"], "attrs": ["CATFWY"] }, + "ACHARE": { "desc": "Anchorage Area", "geom": ["A","P"], "attrs": ["CATACH"] }, + "ACHBRT": { "desc": "Anchor Berth", "geom": ["A","P"], "attrs": ["CATACH"] }, + "BERTHS": { "desc": "Berth", "geom": ["A","L","P"], "attrs": ["QUAPOS"] }, + "HRBARE": { "desc": "Harbour Area", "geom": ["A"], "attrs": ["CATHAF"] }, + "HRBFAC": { "desc": "Harbour Facility", "geom": ["A","P"], "attrs": ["CATHAF"] }, + "PILBOP": { "desc": "Pilot Boarding Place", "geom": ["A","P"], "attrs": ["CATPIL"] }, + "MORFAC": { "desc": "Mooring / Warping Facility", "geom": ["A","L","P"], "attrs": ["CATMOR"] }, + "DOCARE": { "desc": "Dock Area", "geom": ["A"], "attrs": [] }, + "DRYDOC": { "desc": "Dry Dock", "geom": ["A"], "attrs": [] }, + "HULKES": { "desc": "Hulk", "geom": ["A","P"], "attrs": ["CATHLK"] }, + "CRANES": { "desc": "Crane", "geom": ["A","P"], "attrs": ["CATCRN","HEIGHT"] }, + "PILPNT": { "desc": "Pile / Bollard", "geom": ["L","P"], "attrs": ["CATPLE"] }, + "PONTON": { "desc": "Pontoon", "geom": ["A","L","P"], "attrs": [] }, + "CBLSUB": { "desc": "Submarine Cable", "geom": ["A","L"], "attrs": ["CATCBL"] }, + "CBLOHD": { "desc": "Overhead Cable", "geom": ["L"], "attrs": ["CATCBL","VERCLR"] }, + "PIPOHD": { "desc": "Overhead Pipe", "geom": ["L"], "attrs": ["CATPIP","VERCLR"] }, + "PIPSOL": { "desc": "Pipeline on Land", "geom": ["A","L"], "attrs": ["CATPIP"] }, + "BRIDGE": { "desc": "Bridge", "geom": ["A","L","P"], "attrs": ["VERCLR","HORACC"] }, + "TUNNEL": { "desc": "Tunnel", "geom": ["A","L"], "attrs": ["CATTNL"] }, + "GATCON": { "desc": "Gate / Sluice", "geom": ["A","L","P"], "attrs": ["CATGAT"] }, + "LOKBSN": { "desc": "Lock Basin", "geom": ["A"], "attrs": [] }, + "DMPGRD": { "desc": "Dumping Ground", "geom": ["A"], "attrs": ["CATDPG"] }, + "SWPARE": { "desc": "Swept Area", "geom": ["A"], "attrs": ["DRVAL1"] }, + "OSPARE": { "desc": "Offshore Production Area","geom": ["A"], "attrs": [] }, + "OFSPLF": { "desc": "Offshore Platform", "geom": ["A","P"], "attrs": ["CATOFP"] }, + "MARCUL": { "desc": "Marine Farm / Culture", "geom": ["A","P"], "attrs": ["CATMAC"] }, + "PIPARE": { "desc": "Pipeline Area", "geom": ["A"], "attrs": ["CATPIP"] }, + "RIVERS": { "desc": "River", "geom": ["A","L"], "attrs": [] }, + "RIVBNK": { "desc": "River Bank", "geom": ["L"], "attrs": [] }, + "TIDEWY": { "desc": "Tideway", "geom": ["A"], "attrs": [] }, + "WATTUR": { "desc": "Water Turbulence", "geom": ["P"], "attrs": [] }, + "BUISGL": { "desc": "Building, Single", "geom": ["A","P"], "attrs": [] }, + "BUAARE": { "desc": "Built-up Area", "geom": ["A"], "attrs": [] }, + "LAKARE": { "desc": "Lake", "geom": ["A"], "attrs": [] }, + "CANALS": { "desc": "Canal", "geom": ["A","L"], "attrs": [] }, + "ROADWY": { "desc": "Road", "geom": ["A","L"], "attrs": ["CATROD"] }, + "RUNWAY": { "desc": "Runway", "geom": ["A","L"], "attrs": [] }, + "SLOTOP": { "desc": "Slope Topline", "geom": ["L"], "attrs": [] }, + "SLOGRD": { "desc": "Slope", "geom": ["A"], "attrs": [] }, + "VEGATN": { "desc": "Vegetation", "geom": ["A","P"], "attrs": ["CATVEG"] }, + "WEDKLP": { "desc": "Weed / Kelp", "geom": ["A","P"], "attrs": ["CATWED"] }, + "LNDRGN": { "desc": "Land Region", "geom": ["A"], "attrs": ["CATLND"] }, + "SEAARE": { "desc": "Sea Area / Named Water","geom": ["A","P"], "attrs": ["CATSEA"] }, + "RESARE": { "desc": "Restricted Area", "geom": ["A"], "attrs": ["CATREA"] }, + "PRCARE": { "desc": "Precautionary Area", "geom": ["A"], "attrs": [] }, + "SPLARE": { "desc": "Special Purpose Area", "geom": ["A"], "attrs": ["CATSPM"] }, + "MIPARE": { "desc": "Military Practice Area","geom": ["A"], "attrs": [] }, + "CTSARE": { "desc": "Cargo Transhipment Area","geom": ["A"], "attrs": [] }, + "FSHZNE": { "desc": "Fishery Zone", "geom": ["A"], "attrs": [] }, + "FRPARE": { "desc": "Free Port Area", "geom": ["A"], "attrs": [] }, + "TESARE": { "desc": "Territorial Sea Area", "geom": ["A"], "attrs": [] }, + "EXEZNE": { "desc": "Exclusive Economic Zone","geom": ["A"], "attrs": [] }, + "ISTZNE": { "desc": "ISPS Zone", "geom": ["A"], "attrs": [] }, + "ADDMRN": { "desc": "Admiralty Note", "geom": ["A","L","P"], "attrs": [] }, + "RDOCAL": { "desc": "Radio Calling-In Point","geom": ["L","P"], "attrs": [] }, + "RDOSTA": { "desc": "Radio Station", "geom": ["P"], "attrs": ["CATROS"] }, + "RADRFL": { "desc": "Radar Reflector", "geom": ["P"], "attrs": [] }, + "RADSTA": { "desc": "Radar Station", "geom": ["P"], "attrs": ["CATRAS"] }, + "RETRFL": { "desc": "Retro-reflector", "geom": ["P"], "attrs": [] }, + "MAGVAR": { "desc": "Magnetic Variation", "geom": ["P"], "attrs": ["VALMAG","VALACM"] }, + "MONUMT": { "desc": "Monument", "geom": ["P"], "attrs": ["CATLMK","HEIGHT"] }, + "TIDALP": { "desc": "Tidal Stream Panel", "geom": ["P"], "attrs": [] }, + "STSLNE": { "desc": "Straight Territorial Sea Baseline", "geom": ["L"], "attrs": [] }, + "M_COVR": { "desc": "Coverage", "geom": ["A"], "attrs": ["CATCOV"] }, + "M_NSYS": { "desc": "Navigation System of Marks", "geom": ["A"], "attrs": ["MARSYS"] }, + "M_QUAL": { "desc": "Quality of Data", "geom": ["A"], "attrs": ["CATZOC"] }, + "M_ACCY": { "desc": "Accuracy of Data", "geom": ["A"], "attrs": [] }, + "M_CSCL": { "desc": "Compilation Scale", "geom": ["A"], "attrs": ["CSCALE"] }, + "M_SDAT": { "desc": "Sounding Datum", "geom": ["A"], "attrs": ["VERDAT"] }, + "M_HDAT": { "desc": "Horizontal Datum", "geom": ["A"], "attrs": ["HORDAT"] }, + "M_UNIT": { "desc": "Units of Measurement", "geom": ["A"], "attrs": [] } +} diff --git a/s57_writer.py b/s57_writer.py new file mode 100644 index 0000000..596e99f --- /dev/null +++ b/s57_writer.py @@ -0,0 +1,674 @@ +""" +S-57 ENC writer — produces structurally valid `.000` files that the GDAL +S-57 driver can read. Reuses a NOAA DDR template verbatim (the schema is fixed +by IHO S-57 ed. 3.1) and only encodes data records. + +ISO 8211 record format (data records, leader entry-map "3404"): + Leader (24 bytes) + Directory (11 bytes per entry + FT) + Field area. + Directory entry layout: tag(4) + length(3) + position(4). +""" +from __future__ import annotations + +import struct +from pathlib import Path + +# ── ISO 8211 control characters ──────────────────────────────────────────────── +FT = b"\x1e" # Field terminator +UT = b"\x1f" # Unit terminator (subfield delimiter) + +DDR_TEMPLATE = Path(__file__).parent / "noaa_ddr_template.bin" + +# S-57 record names (RCNM) +RCNM_VI = 110 # Isolated Node (point primitive) +RCNM_VC = 120 # Connected Node +RCNM_VE = 130 # Edge (line primitive) +RCNM_VF = 140 # Face +RCNM_FE = 100 # Feature + +# Object class codes — from s57objectclasses.csv (IHO S-57 Ed.3.1, Google Earth GDAL copy) +# fmt: off +OBJL_ACHBRT = 3 # Anchor berth +OBJL_ACHARE = 4 # Anchorage area +OBJL_BCNCAR = 5 # Beacon, cardinal +OBJL_BCNISD = 6 # Beacon, isolated danger +OBJL_BCNLAT = 7 # Beacon, lateral +OBJL_BCNSAW = 8 # Beacon, safe water +OBJL_BCNSPP = 9 # Beacon, special purpose/general +OBJL_BERTHS = 10 # Berth +OBJL_BRIDGE = 11 # Bridge +OBJL_BUISGL = 12 # Building, single +OBJL_BUAARE = 13 # Built-up area +OBJL_BOYCAR = 14 # Buoy, cardinal +OBJL_BOYISD = 16 # Buoy, isolated danger +OBJL_BOYLAT = 17 # Buoy, lateral +OBJL_BOYSAW = 18 # Buoy, safe water ← was 19 (BUG fixed) +OBJL_BOYSPP = 19 # Buoy, special purpose/general (S-57 std acronym) +OBJL_CBLOHD = 21 # Cable, overhead +OBJL_CBLSUB = 22 # Cable, submarine +OBJL_CANALS = 23 # Canal +OBJL_CTSARE = 25 # Cargo transshipment area +OBJL_COALNE = 30 # Coastline +OBJL_CRANES = 35 # Crane +OBJL_DAYMAR = 39 # Daymark +OBJL_DWRTCL = 40 # Deep water route centre line +OBJL_DWRTPT = 41 # Deep water route part +OBJL_DEPARE = 42 # Depth area +OBJL_DEPCNT = 43 # Depth contour +OBJL_DOCARE = 45 # Dock area +OBJL_DRGARE = 46 # Dredged area +OBJL_DRYDOC = 47 # Dry dock +OBJL_DMPGRD = 48 # Dumping ground +OBJL_EXEZNE = 50 # Exclusive Economic Zone +OBJL_FAIRWY = 51 # Fairway +OBJL_FERYRT = 53 # Ferry route +OBJL_FSHZNE = 54 # Fishery zone +OBJL_FOGSIG = 58 # Fog signal +OBJL_FRPARE = 60 # Free port area +OBJL_GATCON = 61 # Gate / sluice +OBJL_HRBARE = 63 # Harbour area (administrative) +OBJL_HRBFAC = 64 # Harbour facility +OBJL_HULKES = 65 # Hulk +OBJL_ISTZNE = 68 # Inshore traffic zone +OBJL_LAKARE = 69 # Lake +OBJL_LNDARE = 71 # Land area +OBJL_LNDRGN = 73 # Land region +OBJL_LNDMRK = 74 # Landmark +OBJL_LIGHTS = 75 # Light +OBJL_LITFLT = 76 # Light float +OBJL_LITVES = 77 # Light vessel +OBJL_LOKBSN = 79 # Lock basin +OBJL_MAGVAR = 81 # Magnetic variation +OBJL_MARCUL = 82 # Marine farm/culture +OBJL_MIPARE = 83 # Military practice area +OBJL_MORFAC = 84 # Mooring/warping facility +OBJL_NAVLNE = 85 # Navigation line +OBJL_OBSTRN = 86 # Obstruction +OBJL_OFSPLF = 87 # Offshore platform +OBJL_OSPARE = 88 # Offshore production area +OBJL_PILPNT = 90 # Pile +OBJL_PILBOP = 91 # Pilot boarding place +OBJL_PIPARE = 92 # Pipeline area +OBJL_PIPOHD = 93 # Pipeline, overhead +OBJL_PIPSOL = 94 # Pipeline, submarine/on land +OBJL_PONTON = 95 # Pontoon +OBJL_PRCARE = 96 # Precautionary area +OBJL_PYLONS = 98 # Pylon/bridge support +OBJL_RADRFL = 101 # Radar reflector +OBJL_RADSTA = 102 # Radar station +OBJL_RDOCAL = 104 # Radio calling-in point +OBJL_RDOSTA = 105 # Radio station +OBJL_RECTRC = 109 # Recommended track +OBJL_RESARE = 112 # Restricted area +OBJL_RETRFL = 113 # Retro-reflector +OBJL_RIVERS = 114 # River +OBJL_RIVBNK = 115 # River bank +OBJL_ROADWY = 116 # Road +OBJL_RUNWAY = 117 # Runway +OBJL_SEAARE = 119 # Sea area / named water area +OBJL_SPLARE = 120 # Sea-plane landing area (SPLARE) +OBJL_SBDARE = 121 # Seabed area +OBJL_SLCONS = 122 # Shoreline construction +OBJL_SLOTOP = 126 # Slope topline +OBJL_SLOGRD = 127 # Sloping ground +OBJL_SOUNDG = 129 # Sounding +OBJL_STSLNE = 132 # Straight territorial sea baseline +OBJL_SUBTLN = 133 # Submarine transit lane +OBJL_SWPARE = 134 # Swept area +OBJL_TESARE = 135 # Territorial sea area +OBJL_TIDEWY = 143 # Tideway +OBJL_TOPMAR = 144 # Top mark +OBJL_TSELNE = 145 # Traffic Separation Line +OBJL_TSSBND = 146 # Traffic Separation Scheme Boundary +OBJL_TSSCRS = 147 # Traffic Separation Scheme Crossing +OBJL_TSSLPT = 148 # Traffic Separation Scheme Lane part +OBJL_TSSRON = 149 # Traffic Separation Scheme Roundabout +OBJL_TUNNEL = 151 # Tunnel +OBJL_TWRTPT = 152 # Two-way route part +OBJL_UWTROC = 153 # Underwater rock / awash rock +OBJL_VEGATN = 155 # Vegetation +OBJL_WATTUR = 156 # Water turbulence +OBJL_WEDKLP = 158 # Weed/Kelp +OBJL_WRECKS = 159 # Wreck +OBJL_M_ACCY = 300 # Accuracy of data (meta) +OBJL_M_CSCL = 301 # Compilation scale (meta) +OBJL_M_COVR = 302 # Coverage (meta) +OBJL_M_HDAT = 303 # Horizontal datum (meta) +OBJL_M_NSYS = 306 # Navigational system of marks (meta) +OBJL_M_QUAL = 308 # Quality of data (meta) +OBJL_M_SDAT = 309 # Sounding datum (meta) +OBJL_M_UNIT = 311 # Units of measurement (meta) +# fmt: on + +# Lookup: S-57 acronym → OBJL code (complete IHO S-57 Ed 3.1 set) +OBJL_BY_ACRONYM: dict[str, int] = { + "ACHBRT": OBJL_ACHBRT, "ACHARE": OBJL_ACHARE, + "BCNCAR": OBJL_BCNCAR, "BCNISD": OBJL_BCNISD, "BCNLAT": OBJL_BCNLAT, + "BCNSAW": OBJL_BCNSAW, "BCNSPP": OBJL_BCNSPP, + "BERTHS": OBJL_BERTHS, "BRIDGE": OBJL_BRIDGE, + "BUISGL": OBJL_BUISGL, "BUAARE": OBJL_BUAARE, + "BOYCAR": OBJL_BOYCAR, "BOYISD": OBJL_BOYISD, "BOYLAT": OBJL_BOYLAT, + "BOYSAW": OBJL_BOYSAW, "BOYSPP": OBJL_BOYSPP, + "BOYSPEC": OBJL_BOYSPP, # user alias → BOYSPP + "CBLOHD": OBJL_CBLOHD, "CBLSUB": OBJL_CBLSUB, + "CANALS": OBJL_CANALS, "CTSARE": OBJL_CTSARE, + "COALNE": OBJL_COALNE, "CRANES": OBJL_CRANES, + "DAYMAR": OBJL_DAYMAR, "DWRTCL": OBJL_DWRTCL, "DWRTPT": OBJL_DWRTPT, + "DEPARE": OBJL_DEPARE, "DEPCNT": OBJL_DEPCNT, + "DOCARE": OBJL_DOCARE, "DRGARE": OBJL_DRGARE, "DRYDOC": OBJL_DRYDOC, + "DMPGRD": OBJL_DMPGRD, + "EXEZNE": OBJL_EXEZNE, "FAIRWY": OBJL_FAIRWY, + "FERYRT": OBJL_FERYRT, "FSHZNE": OBJL_FSHZNE, "FRPARE": OBJL_FRPARE, + "FOGSIG": OBJL_FOGSIG, "GATCON": OBJL_GATCON, + "HRBARE": OBJL_HRBARE, "HRBFAC": OBJL_HRBFAC, "HULKES": OBJL_HULKES, + "ISTZNE": OBJL_ISTZNE, + "LAKARE": OBJL_LAKARE, "LNDARE": OBJL_LNDARE, "LNDRGN": OBJL_LNDRGN, + "LNDMRK": OBJL_LNDMRK, "LIGHTS": OBJL_LIGHTS, + "LITFLT": OBJL_LITFLT, "LITVES": OBJL_LITVES, + "LOKBSN": OBJL_LOKBSN, "MAGVAR": OBJL_MAGVAR, + "MARCUL": OBJL_MARCUL, "MIPARE": OBJL_MIPARE, "MORFAC": OBJL_MORFAC, + "NAVLNE": OBJL_NAVLNE, "OBSTRN": OBJL_OBSTRN, + "OFSPLF": OBJL_OFSPLF, "OSPARE": OBJL_OSPARE, + "PILPNT": OBJL_PILPNT, "PILBOP": OBJL_PILBOP, "PIPARE": OBJL_PIPARE, + "PIPOHD": OBJL_PIPOHD, "PIPSOL": OBJL_PIPSOL, "PONTON": OBJL_PONTON, + "PRCARE": OBJL_PRCARE, "PYLONS": OBJL_PYLONS, + "RADRFL": OBJL_RADRFL, "RADSTA": OBJL_RADSTA, + "RDOCAL": OBJL_RDOCAL, "RDOSTA": OBJL_RDOSTA, + "RECTRC": OBJL_RECTRC, "RESARE": OBJL_RESARE, "RETRFL": OBJL_RETRFL, + "RIVERS": OBJL_RIVERS, "RIVBNK": OBJL_RIVBNK, + "ROADWY": OBJL_ROADWY, "RUNWAY": OBJL_RUNWAY, + "SEAARE": OBJL_SEAARE, "SPLARE": OBJL_SPLARE, "SBDARE": OBJL_SBDARE, + "SLCONS": OBJL_SLCONS, "SLOTOP": OBJL_SLOTOP, "SLOGRD": OBJL_SLOGRD, + "SOUNDG": OBJL_SOUNDG, "STSLNE": OBJL_STSLNE, "SUBTLN": OBJL_SUBTLN, + "SWPARE": OBJL_SWPARE, "TESARE": OBJL_TESARE, "TIDEWY": OBJL_TIDEWY, + "TOPMAR": OBJL_TOPMAR, + "TSELNE": OBJL_TSELNE, "TSSBND": OBJL_TSSBND, "TSSCRS": OBJL_TSSCRS, + "TSSLPT": OBJL_TSSLPT, "TSSRON": OBJL_TSSRON, + "TUNNEL": OBJL_TUNNEL, "TWRTPT": OBJL_TWRTPT, + "UWTROC": OBJL_UWTROC, "VEGATN": OBJL_VEGATN, + "WATTUR": OBJL_WATTUR, "WEDKLP": OBJL_WEDKLP, "WRECKS": OBJL_WRECKS, + "M_ACCY": OBJL_M_ACCY, "M_CSCL": OBJL_M_CSCL, "M_COVR": OBJL_M_COVR, + "M_HDAT": OBJL_M_HDAT, "M_NSYS": OBJL_M_NSYS, "M_QUAL": OBJL_M_QUAL, + "M_SDAT": OBJL_M_SDAT, "M_UNIT": OBJL_M_UNIT, +} + +# PRIM codes for FRID +PRIM_POINT = 1 +PRIM_LINE = 2 +PRIM_AREA = 3 + +# ── S-57 attribute codes (ATTL) — from s57attributes.csv (IHO S-57 Ed.3.1) ─── +# fmt: off +ATTL_AGENCY = 1 # AGENCY — agency responsible for production +ATTL_BCNSHP = 2 # BCNSHP — beacon shape +ATTL_BOYSHP = 4 # BOYSHP — buoy shape +ATTL_BURDEP = 5 # BURDEP — buried depth +ATTL_CATCAM = 13 # CATCAM — category of cardinal mark +ATTL_CATCOV = 18 # CATCOV — category of coverage (1=coverage, 2=no coverage) +ATTL_CATFOG = 27 # CATFOG — category of fog signal +ATTL_CATLAM = 36 # CATLAM — category of lateral mark (1=port, 2=starboard) +ATTL_CATLMK = 35 # CATLMK — category of landmark +ATTL_CATLIT = 37 # CATLIT — category of light +ATTL_CATOBS = 42 # CATOBS — category of obstruction +ATTL_CATREA = 54 # CATREA — category of restricted area +ATTL_CATSIW = 63 # CATSIW — category of signal station warning +ATTL_COLOUR = 75 # COLOUR — colour (list) +ATTL_COLPAT = 76 # COLPAT — colour pattern +ATTL_CONDTN = 81 # CONDTN — condition +ATTL_DRVAL1 = 90 # DRVAL1 — draft value 1 +ATTL_DRVAL2 = 91 # DRVAL2 — draft value 2 +ATTL_HEIGHT = 95 # HEIGHT — height above sea surface +ATTL_LITCHR = 107 # LITCHR — light characteristic +ATTL_MLTYLT = 110 # MLTYLT — multiplicity of lights +ATTL_NATCON = 112 # NATCON — nature of construction +ATTL_OBJNAM = 116 # OBJNAM — object name (free text label) +ATTL_ORIENT = 117 # ORIENT — orientation (degrees) +ATTL_PEREND = 119 # PEREND — period end (season) +ATTL_PERSTA = 120 # PERSTA — period start (season) +ATTL_QUASOU = 126 # QUASOU — quality of sounding measurement +ATTL_SIGGRP = 141 # SIGGRP — signal group ("(2)", etc.) +ATTL_SIGPER = 142 # SIGPER — signal period (seconds) +ATTL_STATUS = 149 # STATUS — status (permanent, occasional…) +ATTL_VALSOU = 179 # VALSOU — value of sounding (metres) +ATTL_VERACC = 180 # VERACC — vertical accuracy +ATTL_VERCLR = 181 # VERCLR — vertical clearance +ATTL_VALNMR = 178 # VALNMR — value of nominal range (nautical miles) +ATTL_VERDAT = 187 # VERDAT — vertical datum +# fmt: on + +# Convenience dict: acronym → ATTL code (superset of the named constants above) +ATTR_CODE: dict[str, int] = { + "AGENCY": 1, "BCNSHP": 2, "BUISHP": 3, "BOYSHP": 4, "BURDEP": 5, + "CALSGN": 6, "CATAIR": 7, "CATACH": 8, "CATBRG": 9, "CATBUA": 10, + "CATCBL": 11, "CATCAN": 12, "CATCAM": 13, "CATCHP": 14, "CATCOA": 15, + "CATCTR": 16, "CATCON": 17, "CATCOV": 18, "CATCRN": 19, "CATDAM": 20, + "CATDIS": 21, "CATDOC": 22, "CATDPG": 23, "CATFNC": 24, "CATFRY": 25, + "CATFIF": 26, "CATFOG": 27, "CATFOR": 28, "CATGAT": 29, "CATHAF": 30, + "CATHLK": 31, "CATICE": 32, "CATINB": 33, "CATLND": 34, "CATLMK": 35, + "CATLAM": 36, "CATLIT": 37, "CATMFA": 38, "CATMPA": 39, "CATMOR": 40, + "CATNAV": 41, "CATOBS": 42, "CATOFP": 43, "CATOLB": 44, "CATPLE": 45, + "CATPIL": 46, "CATPIP": 47, "CATPRA": 48, "CATPYL": 49, "CATQUA": 50, + "CATRAS": 51, "CATRTB": 52, "CATROS": 53, "CATREA": 54, "CATSEA": 55, + "CATSIT": 56, "CATSLC": 57, "CATSPM": 58, "CATSCF": 59, "CATSUB": 60, + "CAATTS": 61, "CATTSS": 62, "CATSIW": 63, "CATTRK": 64, "CATVEG": 65, + "CATWED": 66, "CATWRK": 67, "CATZOC": 68, "CATWAT": 69, "COLOUR": 75, + "COLPAT": 76, "CONDTN": 81, "CONRAD": 82, "CONVIS": 83, "CURVEL": 84, + "DATEND": 85, "DATSTA": 86, "DRVAL1": 90, "DRVAL2": 91, "ELEVAT": 93, + "ESTRNG": 94, "HEIGHT": 95, "HORACC": 96, "HORCLR": 97, "HORLEN": 98, + "HORWID": 99, "ICEFAC": 100,"INFORM": 101,"JRSDTN": 102,"LIFCAP": 103, + "LITCHR": 107, "LITVIS": 108,"MARSYS": 109,"MLTYLT": 110,"NATION": 111, + "NATCON": 112, "NATQUA": 113,"NATSUR": 114,"NOBJNM": 115,"OBJNAM": 116, + "ORIENT": 117, "PEREND": 119,"PERSTA": 120,"PICREP": 121,"POSACC": 122, + "PRCTRY": 124, "PRODCT": 125,"QUASOU": 126,"RADWAL": 127,"RADIUS": 128, + "RYRMGV": 133, "SCAMAX": 134,"SCAMIN": 135,"SCVAL1": 136,"SCVAL2": 137, + "SECTR1": 138, "SECTR2": 139,"SHIPAM": 140,"SIGGRP": 141,"SIGPER": 142, + "SIGSEQ": 143, "SOUACC": 144,"SDISMX": 145,"SDISMN": 146,"SORDAT": 147, + "SORIND": 148, "STATUS": 149,"SURATH": 150,"SUREND": 151,"SURSTA": 152, + "SURTYP": 153, "TECSOU": 156,"TXTDSC": 157,"TS_TSP": 158,"TS_TSV": 159, + "T_ACWL": 160, "T_HWLW": 161,"T_MTOD": 162,"T_THDF": 163,"T_TIMS": 164, + "T_TRNP": 165, "T_VAHF": 166,"T_VAVL": 167,"TIMEND": 168,"TIMSTA": 169, + "TOPSHP": 170, "TRAFIC": 171,"VALDCO": 172,"VERACC": 180,"VERCLR": 181, + "VERCCL": 182, "VERCOP": 183,"VERCSA": 184,"VERDAT": 187,"VERLEN": 188, + "WATLEV": 187, "VALSOU": 179, "VALNMR": 178, +} + + +# ── Binary primitives (little-endian, S-57 convention) ───────────────────────── +def b11(n): return int(n).to_bytes(1, "little", signed=False) +def b12(n): return int(n).to_bytes(2, "little", signed=False) +def b14(n): return int(n).to_bytes(4, "little", signed=False) +def b21(n): return int(n).to_bytes(1, "little", signed=True) +def b22(n): return int(n).to_bytes(2, "little", signed=True) +def b24(n): return int(n).to_bytes(4, "little", signed=True) + + +def A_var(s: str) -> bytes: + """Variable-length ASCII subfield, delimited by UT.""" + return s.encode("latin-1") + UT + + +def A_fixed(s: str, n: int) -> bytes: + """Fixed-width ASCII (no UT).""" + return s.encode("latin-1").ljust(n, b" ")[:n] + + +def R_fixed(value: float, n: int = 4) -> bytes: + """Fixed-width ASCII real, e.g. R(4) = '03.1'.""" + s = f"{value:.{max(0, n-2)}f}" + return s.encode("latin-1").ljust(n, b" ")[:n] + + +def name_5(rcnm: int, rcid: int) -> bytes: + """S-57 NAME field: RCNM (1 byte) + RCID (4 bytes LE) = 5 bytes (= 40 bits).""" + return b11(rcnm) + b14(rcid) + + +# ── Field builders (one per S-57 field tag) ──────────────────────────────────── +def field_0001(rcid: int) -> bytes: + """Record header (b12 RCID) + FT.""" + return b12(rcid) + FT + + +def field_DSID(*, rcnm=10, rcid=1, expp=1, intu=3, + dsnm="", edtn="1", updn="0", + uadt="", isdt="", sted=3.1, + prsp=1, psdn="", pred="2.0", + prof=1, agen=999, comt="") -> bytes: + return (b11(rcnm) + b14(rcid) + b11(expp) + b11(intu) + + A_var(dsnm) + A_var(edtn) + A_var(updn) + + A_fixed(uadt, 8) + A_fixed(isdt, 8) + + R_fixed(sted, 4) + + b11(prsp) + A_var(psdn) + A_var(pred) + + b11(prof) + b12(agen) + + A_var(comt) + FT) + + +def field_DSSI(*, dstr=2, aall=1, nall=1, nomr=0, nocr=0, + nogr=0, nolr=0, noin=0, nocn=0, noed=0, nofa=0) -> bytes: + """Format: (3b11, 8b14)""" + return (b11(dstr) + b11(aall) + b11(nall) + + b14(nomr) + b14(nocr) + b14(nogr) + b14(nolr) + + b14(noin) + b14(nocn) + b14(noed) + b14(nofa) + FT) + + +def field_DSPM(*, rcnm=20, rcid=1, hdat=2, vdat=17, sdat=23, + cscl=50000, duni=1, huni=1, puni=1, coun=1, + comf=10000000, somf=10, comt="") -> bytes: + """Format: (b11, b14, 3b11, b14, 4b11, 2b14, A) + HDAT=2 (WGS84), VDAT=17 (MLLW), SDAT=23 (MLLW), + DUNI=1 (m), HUNI=1 (m), PUNI=1 (m), COUN=1 (lat/long) + COMF=10^7 means coords stored as int = (degrees * 10^7). + SOMF=10 means depths stored as int = (metres * 10).""" + return (b11(rcnm) + b14(rcid) + + b11(hdat) + b11(vdat) + b11(sdat) + + b14(cscl) + + b11(duni) + b11(huni) + b11(puni) + b11(coun) + + b14(comf) + b14(somf) + + A_var(comt) + FT) + + +def field_VRID(*, rcnm: int, rcid: int, rver: int = 1, ruin: int = 1) -> bytes: + """Format: (b11, b14, b12, b11). RUIN=1 means insert.""" + return b11(rcnm) + b14(rcid) + b12(rver) + b11(ruin) + FT + + +def field_SG2D(coords_deg: list[tuple[float, float]], comf: int) -> bytes: + """Format: *(b24 YCOO, b24 XCOO). Coords are (lon, lat) in degrees → ints + multiplied by COMF (typically 10^7). Note S-57 stores YCOO (lat) before + XCOO (lon) for each pair.""" + out = b"" + for lon, lat in coords_deg: + y_int = int(round(lat * comf)) + x_int = int(round(lon * comf)) + out += b24(y_int) + b24(x_int) + return out + FT + + +def field_VRPT(pointers: list[tuple[int, int, int, int, int, int]]) -> bytes: + """Format: *(B(40) NAME, b11 ORNT, b11 USAG, b11 TOPI, b11 MASK). + Each item: (rcnm, rcid, ornt, usag, topi, mask).""" + out = b"" + for rcnm, rcid, ornt, usag, topi, mask in pointers: + out += name_5(rcnm, rcid) + b11(ornt) + b11(usag) + b11(topi) + b11(mask) + return out + FT + + +def field_FRID(*, rcnm: int = RCNM_FE, rcid: int, prim: int, grup: int = 1, + objl: int, rver: int = 1, ruin: int = 1) -> bytes: + """Format: (b11, b14, 2b11, 2b12, b11).""" + return (b11(rcnm) + b14(rcid) + + b11(prim) + b11(grup) + + b12(objl) + b12(rver) + + b11(ruin) + FT) + + +def field_FOID(*, agen: int = 999, fidn: int, fids: int = 1) -> bytes: + """Format: (b12, b14, b12).""" + return b12(agen) + b14(fidn) + b12(fids) + FT + + +def field_ATTF(attrs: list[tuple[int, str]]) -> bytes: + """Format: *(b12 ATTL, A ATVL). Empty list → no field at all (caller skips).""" + out = b"" + for attl, atvl in attrs: + out += b12(attl) + A_var(str(atvl)) + return out + FT + + +def field_FSPT(pointers: list[tuple[int, int, int, int, int]]) -> bytes: + """Format: *(B(40) NAME, b11 ORNT, b11 USAG, b11 MASK). + Each: (rcnm, rcid, ornt, usag, mask). ORNT=1=forward,2=reverse,255=null; + USAG=1=exterior,2=interior,3=both,255=null; MASK=1=mask,2=show,255=null.""" + out = b"" + for rcnm, rcid, ornt, usag, mask in pointers: + out += name_5(rcnm, rcid) + b11(ornt) + b11(usag) + b11(mask) + return out + FT + + +# ── Record builder (data record with entry-map 5504) ─────────────────────────── +def build_record(fields: list[tuple[str, bytes]]) -> bytes: + """fields: list of (4-char tag, field bytes). Field bytes include trailing FT. + + Uses entry-map "5504": field-length=5 digits (max 99999 bytes per field), + position=5 digits (max 99999). Each directory entry is 14 bytes: tag(4)+len(5)+pos(5). + + History of bugs: + "3404" (original): 3-digit length = max 999 bytes → LNDARE polygons produce + SG2D fields of 2000-53000 bytes → length truncated → GDAL: "Not enough byte + to initialize field 'SG2D'". + "4404": 4-digit = max 9999 bytes → still not enough for large coastal outlines. + "5504": 5-digit = max 99999 bytes → handles any realistic polygon after RDP. + + The DDR template uses "3404" for its own directory, but that only applies to + how the DDR is parsed. GDAL reads each data record's leader independently, so + data records may use a different entry-map than the DDR — this is valid ISO 8211. + """ + field_area = b"" + dir_str = "" + pos = 0 + for tag, data in fields: + assert len(tag) == 4, f"tag must be 4 chars: {tag!r}" + flen = len(data) + assert flen <= 99999, f"field {tag} too large ({flen} bytes); apply more RDP" + assert pos <= 99999, f"field area position overflow at {tag} (pos={pos})" + dir_str += f"{tag:<4s}{flen:05d}{pos:05d}" + field_area += data + pos += flen + directory = dir_str.encode("latin-1") + FT + base_addr = 24 + len(directory) + record_len = base_addr + len(field_area) + assert record_len <= 99999, f"record too large ({record_len} bytes)" + + leader = ( + f"{record_len:05d}" # 0-4: record length + " " # 5: interchange level (space for DR) + "D" # 6: leader id + " " # 7: in-line code extension (space for DR) + " " # 8: version + " " # 9: app indicator + " " # 10-11: field control length (00 for DR) + f"{base_addr:05d}" # 12-16: base address of field area + " " # 17-19: extended character set indicator + "5504" # 20-23: entry map (len=5, pos=5, reserved=0, tag=4) + ).encode("latin-1") + assert len(leader) == 24, f"leader len {len(leader)}" + return leader + directory + field_area + + +# ── High-level builder for a complete `.000` ────────────────────────────────── +class S57Cell: + """Builds a complete S-57 cell. Call add_* methods then write().""" + + def __init__(self, *, dsnm: str, edition: int = 1, intu: int = 5, + scale: int = 50000, agen: int = 999, comt: str = "AR ECDIS custom", + issue_date: str = "20260428"): + self.dsnm = dsnm + self.edition = edition + self.intu = intu + self.scale = scale + self.agen = agen + self.comt = comt + self.issue_date = issue_date + self.comf = 10_000_000 # 10^7 — coords stored as int(deg * COMF) + self.somf = 10 # depths × 10 + + # Spatial primitives & features accumulated by callers + self._next_vi = 1 + self._next_vc = 1 + self._next_ve = 1 + self._next_fid = 1 + self._records: list[tuple[str, list[tuple[str, bytes]]]] = [] + + # Counters for DSSI + self._n_meta = 0 + self._n_pt = 0 # NOMR isolated nodes + self._n_cn = 0 # NOCN connected nodes + self._n_ed = 0 # NOED edges + self._n_geo = 0 + self._n_col = 0 # NOCR collection + self._n_in = 0 # NOIN isolated nodes alt count + self._n_lr = 0 # NOLR + self._n_gr = 0 # NOGR + self._n_fa = 0 # NOFA faces + + # --- Add an isolated node (for points: buoys, lights, landmarks) --- + def add_isolated_node(self, lon: float, lat: float) -> tuple[int, int]: + rcid = self._next_vi + self._next_vi += 1 + fields = [ + ("0001", field_0001(rcid)), + ("VRID", field_VRID(rcnm=RCNM_VI, rcid=rcid)), + ("SG2D", field_SG2D([(lon, lat)], self.comf)), + ] + self._records.append(("VI", fields)) + self._n_pt += 1 + self._n_in += 1 + return (RCNM_VI, rcid) + + # --- Add an edge connecting (start_lon, start_lat) → (end_lon, end_lat) --- + # First creates two connected nodes, then the edge that points to them with + # intermediate coordinates. + def add_edge(self, coords: list[tuple[float, float]]) -> tuple[int, int]: + if len(coords) < 2: + raise ValueError("edge needs ≥2 coordinates") + # Create connected nodes for the endpoints + cn_start = self._add_connected_node(*coords[0]) + cn_end = self._add_connected_node(*coords[-1]) + # The edge: VRID + VRPT (pointers to both CNs) + SG2D (intermediate coords) + rcid = self._next_ve + self._next_ve += 1 + # Intermediate points only (S-57 stores edges as: start_CN, intermediates..., end_CN) + intermediate = coords[1:-1] + fields = [ + ("0001", field_0001(rcid)), + ("VRID", field_VRID(rcnm=RCNM_VE, rcid=rcid)), + # VRPT: two pointers — to start node, to end node + ("VRPT", field_VRPT([ + (RCNM_VC, cn_start[1], 1, 1, 1, 255), # ORNT=1, USAG=1=ext, TOPI=1=begin, MASK=255 + (RCNM_VC, cn_end[1], 2, 1, 2, 255), # ORNT=2=reverse, TOPI=2=end + ])), + ] + if intermediate: + fields.append(("SG2D", field_SG2D(intermediate, self.comf))) + self._records.append(("VE", fields)) + self._n_ed += 1 + return (RCNM_VE, rcid) + + def _add_connected_node(self, lon: float, lat: float) -> tuple[int, int]: + rcid = self._next_vc + self._next_vc += 1 + fields = [ + ("0001", field_0001(rcid)), + ("VRID", field_VRID(rcnm=RCNM_VC, rcid=rcid)), + ("SG2D", field_SG2D([(lon, lat)], self.comf)), + ] + self._records.append(("VC", fields)) + self._n_cn += 1 + return (RCNM_VC, rcid) + + # --- Feature: point --- + def add_point_feature(self, *, objl: int, + lon: float, lat: float, + attrs: list[tuple[int, str]] | None = None) -> int: + rcid = self._next_fid + self._next_fid += 1 + fid = rcid + node_name = self.add_isolated_node(lon, lat) + + fields = [ + ("0001", field_0001(rcid)), + ("FRID", field_FRID(rcid=rcid, prim=PRIM_POINT, objl=objl)), + ("FOID", field_FOID(agen=self.agen, fidn=fid)), + ] + if attrs: + fields.append(("ATTF", field_ATTF(attrs))) + fields.append(("FSPT", field_FSPT([ + (node_name[0], node_name[1], 255, 255, 255), # ORNT/USAG/MASK = NULL for point + ]))) + self._records.append(("FE", fields)) + self._n_geo += 1 + return rcid + + # --- Feature: line (e.g. COALNE, DEPCNT) --- + def add_line_feature(self, *, objl: int, + coords: list[tuple[float, float]], + attrs: list[tuple[int, str]] | None = None) -> int: + """coords: ordered list of (lon, lat) vertices (at least 2). The whole + polyline is encoded as a single VE edge so that GDAL reassembles it into + a LineString geometry. For very long coastlines you can split into multiple + calls, each producing a separate feature.""" + if len(coords) < 2: + raise ValueError("line feature needs ≥2 coordinates") + edge_name = self.add_edge(coords) + rcid = self._next_fid + self._next_fid += 1 + fields = [ + ("0001", field_0001(rcid)), + ("FRID", field_FRID(rcid=rcid, prim=PRIM_LINE, objl=objl)), + ("FOID", field_FOID(agen=self.agen, fidn=rcid)), + ] + if attrs: + fields.append(("ATTF", field_ATTF(attrs))) + fields.append(("FSPT", field_FSPT([ + (edge_name[0], edge_name[1], 1, 255, 255), # ORNT=1=fwd, USAG=null, MASK=null + ]))) + self._records.append(("FE", fields)) + self._n_geo += 1 + return rcid + + # --- Feature: area defined by closed boundary polygon --- + def add_area_feature(self, *, objl: int, + ring: list[tuple[float, float]], + attrs: list[tuple[int, str]] | None = None) -> int: + """ring: list of (lon, lat) — must be closed (first == last) or will be.""" + if ring[0] != ring[-1]: + ring = ring + [ring[0]] + edge_name = self.add_edge(ring) + rcid = self._next_fid + self._next_fid += 1 + fields = [ + ("0001", field_0001(rcid)), + ("FRID", field_FRID(rcid=rcid, prim=PRIM_AREA, objl=objl)), + ("FOID", field_FOID(agen=self.agen, fidn=rcid)), + ] + if attrs: + fields.append(("ATTF", field_ATTF(attrs))) + fields.append(("FSPT", field_FSPT([ + (edge_name[0], edge_name[1], 1, 1, 255), # ORNT=1=fwd, USAG=1=exterior, MASK=null + ]))) + self._records.append(("FE", fields)) + self._n_geo += 1 + return rcid + + # --- Build the full file --- + def write(self, output_path: str | Path) -> None: + if not DDR_TEMPLATE.exists(): + raise FileNotFoundError( + f"DDR template not found: {DDR_TEMPLATE}. " + "Extract noaa_ddr_template.bin from a real ENC first." + ) + with open(DDR_TEMPLATE, "rb") as f: + ddr = f.read() + + # Build DSID record (record 2) + dsid_record = build_record([ + ("0001", field_0001(2)), + ("DSID", field_DSID( + rcnm=10, rcid=1, expp=1, intu=self.intu, + dsnm=self.dsnm, edtn=str(self.edition), updn="0", + uadt=self.issue_date, isdt=self.issue_date, + sted=3.1, prsp=1, psdn="", pred="2.0", + prof=1, agen=self.agen, comt=self.comt, + )), + ("DSSI", field_DSSI( + dstr=2, aall=1, nall=1, + nomr=self._n_meta, nocr=self._n_col, nogr=self._n_gr, + nolr=self._n_lr, noin=self._n_pt, nocn=self._n_cn, + noed=self._n_ed, nofa=self._n_fa, + )), + ]) + + # Build DSPM record (record 3) + dspm_record = build_record([ + ("0001", field_0001(3)), + ("DSPM", field_DSPM( + rcnm=20, rcid=1, hdat=2, vdat=17, sdat=23, + cscl=self.scale, duni=1, huni=1, puni=1, coun=1, + comf=self.comf, somf=self.somf, comt="", + )), + ]) + + # Build all data records collected by the caller + rcid_seq = 4 # records 1=DDR, 2=DSID, 3=DSPM, then sequential + body_records = b"" + for kind, fields in self._records: + # Replace the 0001 RCID with global record sequence (per-record-type + # numbering would also be valid; simplest is global sequence) + new_fields = [ + ("0001", field_0001(rcid_seq)) if t == "0001" else (t, d) + for t, d in fields + ] + body_records += build_record(new_fields) + rcid_seq += 1 + + with open(output_path, "wb") as f: + f.write(ddr + dsid_record + dspm_record + body_records) diff --git a/shapefile.py b/shapefile.py new file mode 100644 index 0000000..91e8e62 --- /dev/null +++ b/shapefile.py @@ -0,0 +1,4100 @@ +""" +shapefile.py +Provides read and write support for ESRI Shapefiles. +authors: jlawheadgeospatialpython.com +maintainer: karim.bahgat.norwaygmail.com +Compatible with Python versions >=3.9 +""" + +from __future__ import annotations + +__version__ = "3.0.3" + +import array +import doctest +import io +import logging +import os +import sys +import tempfile +import time +import zipfile +from collections.abc import Container, Iterable, Iterator, Reversible, Sequence +from datetime import date +from os import PathLike +from struct import Struct, calcsize, error, pack, unpack +from types import TracebackType +from typing import ( + IO, + Any, + Final, + Generic, + Literal, + NamedTuple, + NoReturn, + Optional, + Protocol, + SupportsIndex, + TypedDict, + TypeVar, + Union, + cast, + overload, +) +from urllib.error import HTTPError +from urllib.parse import urlparse, urlunparse +from urllib.request import Request, urlopen + +# Create named logger +logger = logging.getLogger(__name__) + +# Module settings +VERBOSE = True + +# Test config (for the Doctest runner and test_shapefile.py) +REPLACE_REMOTE_URLS_WITH_LOCALHOST = ( + os.getenv("REPLACE_REMOTE_URLS_WITH_LOCALHOST", "").lower() == "yes" +) + +# Constants for shape types +NULL = 0 +POINT = 1 +POLYLINE = 3 +POLYGON = 5 +MULTIPOINT = 8 +POINTZ = 11 +POLYLINEZ = 13 +POLYGONZ = 15 +MULTIPOINTZ = 18 +POINTM = 21 +POLYLINEM = 23 +POLYGONM = 25 +MULTIPOINTM = 28 +MULTIPATCH = 31 + +SHAPETYPE_LOOKUP = { + NULL: "NULL", + POINT: "POINT", + POLYLINE: "POLYLINE", + POLYGON: "POLYGON", + MULTIPOINT: "MULTIPOINT", + POINTZ: "POINTZ", + POLYLINEZ: "POLYLINEZ", + POLYGONZ: "POLYGONZ", + MULTIPOINTZ: "MULTIPOINTZ", + POINTM: "POINTM", + POLYLINEM: "POLYLINEM", + POLYGONM: "POLYGONM", + MULTIPOINTM: "MULTIPOINTM", + MULTIPATCH: "MULTIPATCH", +} + +SHAPETYPENUM_LOOKUP = {name: code for code, name in SHAPETYPE_LOOKUP.items()} + +TRIANGLE_STRIP = 0 +TRIANGLE_FAN = 1 +OUTER_RING = 2 +INNER_RING = 3 +FIRST_RING = 4 +RING = 5 + +PARTTYPE_LOOKUP = { + 0: "TRIANGLE_STRIP", + 1: "TRIANGLE_FAN", + 2: "OUTER_RING", + 3: "INNER_RING", + 4: "FIRST_RING", + 5: "RING", +} + +## Custom type variables + +T = TypeVar("T") +Point2D = tuple[float, float] +Point3D = tuple[float, float, float] +PointMT = tuple[float, float, Optional[float]] +PointZT = tuple[float, float, float, Optional[float]] + +Coord = Union[Point2D, Point3D] +Coords = list[Coord] + +PointT = Union[Point2D, PointMT, PointZT] +PointsT = list[PointT] + +BBox = tuple[float, float, float, float] +MBox = tuple[float, float] +ZBox = tuple[float, float] + + +class WriteableBinStream(Protocol): + def write(self, b: bytes) -> int: ... + + +class ReadableBinStream(Protocol): + def read(self, size: int = -1) -> bytes: ... + + +class WriteSeekableBinStream(Protocol): + def write(self, b: bytes) -> int: ... + def seek(self, offset: int, whence: int = 0) -> int: ... + def tell(self) -> int: ... + + +class ReadSeekableBinStream(Protocol): + def seek(self, offset: int, whence: int = 0) -> int: ... + def tell(self) -> int: ... + def read(self, size: int = -1) -> bytes: ... + + +class ReadWriteSeekableBinStream(Protocol): + def write(self, b: bytes) -> int: ... + def seek(self, offset: int, whence: int = 0) -> int: ... + def tell(self) -> int: ... + def read(self, size: int = -1) -> bytes: ... + + +# File name, file object or anything with a read() method that returns bytes. +BinaryFileT = Union[str, PathLike[Any], IO[bytes]] +BinaryFileStreamT = Union[IO[bytes], io.BytesIO, WriteSeekableBinStream] + +FieldTypeT = Literal["C", "D", "F", "L", "M", "N"] + + +# https://en.wikipedia.org/wiki/.dbf#Database_records +class FieldType: + """A bare bones 'enum', as the enum library noticeably slows performance.""" + + C: Final = "C" # "Character" # (str) + D: Final = "D" # "Date" + F: Final = "F" # "Floating point" + L: Final = "L" # "Logical" # (bool) + M: Final = "M" # "Memo" # Legacy. (10 digit str, starting block in an .dbt file) + N: Final = "N" # "Numeric" # (int) + __members__: set[FieldTypeT] = { + "C", + "D", + "F", + "L", + "M", + "N", + } + + +FIELD_TYPE_ALIASES: dict[str | bytes, FieldTypeT] = {} +for c in FieldType.__members__: + FIELD_TYPE_ALIASES[c.upper()] = c + FIELD_TYPE_ALIASES[c.lower()] = c + FIELD_TYPE_ALIASES[c.encode("ascii").lower()] = c + FIELD_TYPE_ALIASES[c.encode("ascii").upper()] = c + + +# Use functional syntax to have an attribute named type, a Python keyword +class Field(NamedTuple): + name: str + field_type: FieldTypeT + size: int + decimal: int + + @classmethod + def from_unchecked( + cls, + name: str, + field_type: str | bytes | FieldTypeT = "C", + size: int = 50, + decimal: int = 0, + ) -> Field: + try: + type_ = FIELD_TYPE_ALIASES[field_type] + except KeyError: + raise ShapefileException( + f"field_type must be in {{FieldType.__members__}}. Got: {field_type=}. " + ) + + if type_ is FieldType.D: + size = 8 + decimal = 0 + elif type_ is FieldType.L: + size = 1 + decimal = 0 + + # A doctest in README.md previously passed in a string ('40') for size, + # so explictly convert name to str, and size and decimal to ints. + return cls( + name=str(name), field_type=type_, size=int(size), decimal=int(decimal) + ) + + def __repr__(self) -> str: + return f'Field(name="{self.name}", field_type=FieldType.{self.field_type}, size={self.size}, decimal={self.decimal})' + + +RecordValueNotDate = Union[bool, int, float, str] + +# A Possible value in a Shapefile dbf record, i.e. L, N, M, F, C, or D types +RecordValue = Union[RecordValueNotDate, date] + + +class HasGeoInterface(Protocol): + @property + def __geo_interface__(self) -> GeoJSONHomogeneousGeometryObject: ... + + +class GeoJSONPoint(TypedDict): + type: Literal["Point"] + # We fix to a tuple (to statically check the length is 2, 3 or 4) but + # RFC7946 only requires: "A position is an array of numbers. There MUST be two or more + # elements. " + # RFC7946 also requires long/lat easting/northing which we do not enforce, + # and despite the SHOULD NOT, we may use a 4th element for Shapefile M Measures. + coordinates: PointT | tuple[()] + + +class GeoJSONMultiPoint(TypedDict): + type: Literal["MultiPoint"] + coordinates: PointsT + + +class GeoJSONLineString(TypedDict): + type: Literal["LineString"] + # "Two or more positions" not enforced by type checker + # https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.4 + coordinates: PointsT + + +class GeoJSONMultiLineString(TypedDict): + type: Literal["MultiLineString"] + coordinates: list[PointsT] + + +class GeoJSONPolygon(TypedDict): + type: Literal["Polygon"] + # Other requirements for Polygon not enforced by type checker + # https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6 + coordinates: list[PointsT] + + +class GeoJSONMultiPolygon(TypedDict): + type: Literal["MultiPolygon"] + coordinates: list[list[PointsT]] + + +GeoJSONHomogeneousGeometryObject = Union[ + GeoJSONPoint, + GeoJSONMultiPoint, + GeoJSONLineString, + GeoJSONMultiLineString, + GeoJSONPolygon, + GeoJSONMultiPolygon, +] + +GEOJSON_TO_SHAPETYPE: dict[str, int] = { + "Null": NULL, + "Point": POINT, + "LineString": POLYLINE, + "Polygon": POLYGON, + "MultiPoint": MULTIPOINT, + "MultiLineString": POLYLINE, + "MultiPolygon": POLYGON, +} + + +class GeoJSONGeometryCollection(TypedDict): + type: Literal["GeometryCollection"] + geometries: list[GeoJSONHomogeneousGeometryObject] + + +# RFC7946 3.1 +GeoJSONObject = Union[GeoJSONHomogeneousGeometryObject, GeoJSONGeometryCollection] + + +class GeoJSONFeature(TypedDict): + type: Literal["Feature"] + properties: ( + dict[str, Any] | None + ) # RFC7946 3.2 "(any JSON object or a JSON null value)" + geometry: GeoJSONObject | None + + +class GeoJSONFeatureCollection(TypedDict): + type: Literal["FeatureCollection"] + features: list[GeoJSONFeature] + + +class GeoJSONFeatureCollectionWithBBox(GeoJSONFeatureCollection): + # bbox is technically optional under the spec but this seems + # a very minor improvement that would require NotRequired + # from the typing-extensions backport for Python 3.9 + # (PyShp's resisted having any other dependencies so far!) + bbox: list[float] + + +# Helpers + +MISSING = (None, "") # Don't make a set, as user input may not be Hashable +NODATA = -10e38 # as per the ESRI shapefile spec, only used for m-values. + +unpack_2_int32_be = Struct(">2i").unpack + + +@overload +def fsdecode_if_pathlike(path: PathLike[Any]) -> str: ... +@overload +def fsdecode_if_pathlike(path: T) -> T: ... +def fsdecode_if_pathlike(path: Any) -> Any: + if isinstance(path, PathLike): + return os.fsdecode(path) # str + + return path + + +# Begin + +ARR_TYPE = TypeVar("ARR_TYPE", int, float) + + +# In Python 3.12 we can do: +# class _Array(array.array[ARR_TYPE], Generic[ARR_TYPE]): +class _Array(array.array, Generic[ARR_TYPE]): # type: ignore[type-arg] + """Converts python tuples to lists of the appropriate type. + Used to unpack different shapefile header parts.""" + + def __repr__(self) -> str: + return str(self.tolist()) + + +def signed_area( + coords: PointsT, + fast: bool = False, +) -> float: + """Return the signed area enclosed by a ring using the linear time + algorithm. A value >= 0 indicates a counter-clockwise oriented ring. + A faster version is possible by setting 'fast' to True, which returns + 2x the area, e.g. if you're only interested in the sign of the area. + """ + xs, ys = map(list, list(zip(*coords))[:2]) # ignore any z or m values + xs.append(xs[1]) + ys.append(ys[1]) + area2: float = sum(xs[i] * (ys[i + 1] - ys[i - 1]) for i in range(1, len(coords))) + if fast: + return area2 + + return area2 / 2.0 + + +def is_cw(coords: PointsT) -> bool: + """Returns True if a polygon ring has clockwise orientation, determined + by a negatively signed area. + """ + area2 = signed_area(coords, fast=True) + return area2 < 0 + + +def rewind(coords: Reversible[PointT]) -> PointsT: + """Returns the input coords in reversed order.""" + return list(reversed(coords)) + + +def ring_bbox(coords: PointsT) -> BBox: + """Calculates and returns the bounding box of a ring.""" + xs, ys = map(list, list(zip(*coords))[:2]) # ignore any z or m values + # bbox = BBox(xmin=min(xs), ymin=min(ys), xmax=max(xs), ymax=max(ys)) + # return bbox + return min(xs), min(ys), max(xs), max(ys) + + +def bbox_overlap(bbox1: BBox, bbox2: BBox) -> bool: + """Tests whether two bounding boxes overlap.""" + xmin1, ymin1, xmax1, ymax1 = bbox1 + xmin2, ymin2, xmax2, ymax2 = bbox2 + overlap = xmin1 <= xmax2 and xmin2 <= xmax1 and ymin1 <= ymax2 and ymin2 <= ymax1 + return overlap + + +def bbox_contains(bbox1: BBox, bbox2: BBox) -> bool: + """Tests whether bbox1 fully contains bbox2.""" + xmin1, ymin1, xmax1, ymax1 = bbox1 + xmin2, ymin2, xmax2, ymax2 = bbox2 + contains = xmin1 < xmin2 and xmax2 < xmax1 and ymin1 < ymin2 and ymax2 < ymax1 + return contains + + +def ring_contains_point(coords: PointsT, p: Point2D) -> bool: + """Fast point-in-polygon crossings algorithm, MacMartin optimization. + + Adapted from code by Eric Haynes + http://www.realtimerendering.com/resources/GraphicsGems//gemsiv/ptpoly_haines/ptinpoly.c + + Original description: + Shoot a test ray along +X axis. The strategy, from MacMartin, is to + compare vertex Y values to the testing point's Y and quickly discard + edges which are entirely to one side of the test ray. + """ + tx, ty = p + + # get initial test bit for above/below X axis + vtx0 = coords[0] + yflag0 = vtx0[1] >= ty + + inside_flag = False + for vtx1 in coords[1:]: + yflag1 = vtx1[1] >= ty + # check if endpoints straddle (are on opposite sides) of X axis + # (i.e. the Y's differ); if so, +X ray could intersect this edge. + if yflag0 != yflag1: + xflag0 = vtx0[0] >= tx + # check if endpoints are on same side of the Y axis (i.e. X's + # are the same); if so, it's easy to test if edge hits or misses. + if xflag0 == (vtx1[0] >= tx): + # if edge's X values both right of the point, must hit + if xflag0: + inside_flag = not inside_flag + else: + # compute intersection of pgon segment with +X ray, note + # if >= point's X; if so, the ray hits it. + if ( + vtx1[0] - (vtx1[1] - ty) * (vtx0[0] - vtx1[0]) / (vtx0[1] - vtx1[1]) + ) >= tx: + inside_flag = not inside_flag + + # move to next pair of vertices, retaining info as possible + yflag0 = yflag1 + vtx0 = vtx1 + + return inside_flag + + +class RingSamplingError(Exception): + pass + + +def ring_sample(coords: PointsT, ccw: bool = False) -> Point2D: + """Return a sample point guaranteed to be within a ring, by efficiently + finding the first centroid of a coordinate triplet whose orientation + matches the orientation of the ring and passes the point-in-ring test. + The orientation of the ring is assumed to be clockwise, unless ccw + (counter-clockwise) is set to True. + """ + triplet = [] + + def itercoords() -> Iterator[PointT]: + # iterate full closed ring + yield from coords + # finally, yield the second coordinate to the end to allow checking the last triplet + yield coords[1] + + for p in itercoords(): + # add point to triplet (but not if duplicate) + if p not in triplet: + triplet.append(p) + + # new triplet, try to get sample + if len(triplet) == 3: + # check that triplet does not form a straight line (not a triangle) + is_straight_line = (triplet[0][1] - triplet[1][1]) * ( + triplet[0][0] - triplet[2][0] + ) == (triplet[0][1] - triplet[2][1]) * (triplet[0][0] - triplet[1][0]) + if not is_straight_line: + # get triplet orientation + closed_triplet = triplet + [triplet[0]] + triplet_ccw = not is_cw(closed_triplet) + # check that triplet has the same orientation as the ring (means triangle is inside the ring) + if ccw == triplet_ccw: + # get triplet centroid + xs, ys = zip(*triplet) + xmean, ymean = sum(xs) / 3.0, sum(ys) / 3.0 + # check that triplet centroid is truly inside the ring + if ring_contains_point(coords, (xmean, ymean)): + return xmean, ymean + + # failed to get sample point from this triplet + # remove oldest triplet coord to allow iterating to next triplet + triplet.pop(0) + + raise RingSamplingError( + f"Unexpected error: Unable to find a ring sample point in: {coords}." + "Ensure the ring's coordinates are oriented clockwise, " + "and ensure the area enclosed is non-zero. " + ) + + +def ring_contains_ring(coords1: PointsT, coords2: list[PointT]) -> bool: + """Returns True if all vertexes in coords2 are fully inside coords1.""" + # Ignore Z and M values in coords2 + return all(ring_contains_point(coords1, p2[:2]) for p2 in coords2) + + +def organize_polygon_rings( + rings: Iterable[PointsT], return_errors: dict[str, int] | None = None +) -> list[list[PointsT]]: + """Organize a list of coordinate rings into one or more polygons with holes. + Returns a list of polygons, where each polygon is composed of a single exterior + ring, and one or more interior holes. If a return_errors dict is provided (optional), + any errors encountered will be added to it. + + Rings must be closed, and cannot intersect each other (non-self-intersecting polygon). + Rings are determined as exteriors if they run in clockwise direction, or interior + holes if they run in counter-clockwise direction. This method is used to construct + GeoJSON (multi)polygons from the shapefile polygon shape type, which does not + explicitly store the structure of the polygons beyond exterior/interior ring orientation. + """ + # first iterate rings and classify as exterior or hole + exteriors = [] + holes = [] + for ring in rings: + # shapefile format defines a polygon as a sequence of rings + # where exterior rings are clockwise, and holes counterclockwise + if is_cw(ring): + # ring is exterior + exteriors.append(ring) + else: + # ring is a hole + holes.append(ring) + + # if only one exterior, then all holes belong to that exterior + if len(exteriors) == 1: + # exit early + poly = [exteriors[0]] + holes + polys = [poly] + return polys + + # multiple exteriors, ie multi-polygon, have to group holes with correct exterior + # shapefile format does not specify which holes belong to which exteriors + # so have to do efficient multi-stage checking of hole-to-exterior containment + if len(exteriors) > 1: + # exit early if no holes + if not holes: + polys = [] + for ext in exteriors: + poly = [ext] + polys.append(poly) + return polys + + # first determine each hole's candidate exteriors based on simple bbox contains test + hole_exteriors: dict[int, list[int]] = { + hole_i: [] for hole_i in range(len(holes)) + } + exterior_bboxes = [ring_bbox(ring) for ring in exteriors] + for hole_i in hole_exteriors.keys(): + hole_bbox = ring_bbox(holes[hole_i]) + for ext_i, ext_bbox in enumerate(exterior_bboxes): + if bbox_contains(ext_bbox, hole_bbox): + hole_exteriors[hole_i].append(ext_i) + + # then, for holes with still more than one possible exterior, do more detailed hole-in-ring test + for hole_i, exterior_candidates in hole_exteriors.items(): + if len(exterior_candidates) > 1: + # get hole sample point + ccw = not is_cw(holes[hole_i]) + hole_sample = ring_sample(holes[hole_i], ccw=ccw) + # collect new exterior candidates + new_exterior_candidates = [] + for ext_i in exterior_candidates: + # check that hole sample point is inside exterior + hole_in_exterior = ring_contains_point( + exteriors[ext_i], hole_sample + ) + if hole_in_exterior: + new_exterior_candidates.append(ext_i) + + # set new exterior candidates + hole_exteriors[hole_i] = new_exterior_candidates + + # if still holes with more than one possible exterior, means we have an exterior hole nested inside another exterior's hole + for hole_i, exterior_candidates in hole_exteriors.items(): + if len(exterior_candidates) > 1: + # exterior candidate with the smallest area is the hole's most immediate parent + ext_i = sorted( + exterior_candidates, + key=lambda x: abs(signed_area(exteriors[x], fast=True)), + )[0] + hole_exteriors[hole_i] = [ext_i] + + # separate out holes that are orphaned (not contained by any exterior) + orphan_holes = [] + for hole_i, exterior_candidates in list(hole_exteriors.items()): + if not exterior_candidates: + orphan_holes.append(hole_i) + del hole_exteriors[hole_i] + continue + + # each hole should now only belong to one exterior, group into exterior-holes polygons + polys = [] + for ext_i, ext in enumerate(exteriors): + poly = [ext] + # find relevant holes + poly_holes = [] + for hole_i, exterior_candidates in list(hole_exteriors.items()): + # hole is relevant if previously matched with this exterior + if exterior_candidates[0] == ext_i: + poly_holes.append(holes[hole_i]) + poly += poly_holes + polys.append(poly) + + # add orphan holes as exteriors + for hole_i in orphan_holes: + ext = holes[hole_i] + # add as single exterior without any holes + poly = [ext] + polys.append(poly) + + if orphan_holes and return_errors is not None: + return_errors["polygon_orphaned_holes"] = len(orphan_holes) + + return polys + + # no exteriors, be nice and assume due to incorrect winding order + if return_errors is not None: + return_errors["polygon_only_holes"] = len(holes) + exteriors = holes + # add as single exterior without any holes + polys = [[ext] for ext in exteriors] + return polys + + +class GeoJSON_Error(Exception): + pass + + +class _NoShapeTypeSentinel: + """For use as a default value for Shape.__init__ to + preserve old behaviour for anyone who explictly + called Shape(shapeType=None). + """ + + +_NO_SHAPE_TYPE_SENTINEL: Final = _NoShapeTypeSentinel() + + +def _m_from_point(point: PointMT | PointZT, mpos: int) -> float | None: + if len(point) > mpos and point[mpos] is not None: + return cast(float, point[mpos]) + return None + + +def _ms_from_points( + points: list[PointMT] | list[PointZT], mpos: int +) -> Iterator[float | None]: + return (_m_from_point(p, mpos) for p in points) + + +def _z_from_point(point: PointZT) -> float: + if len(point) >= 3 and point[2] is not None: + return point[2] + return 0.0 + + +def _zs_from_points(points: Iterable[PointZT]) -> Iterator[float]: + return (_z_from_point(p) for p in points) + + +class CanHaveBboxNoLinesKwargs(TypedDict, total=False): + oid: int | None + points: PointsT | None + parts: Sequence[int] | None # index of start point of each part + partTypes: Sequence[int] | None + bbox: BBox | None + m: Sequence[float | None] | None + z: Sequence[float] | None + mbox: MBox | None + zbox: ZBox | None + + +class Shape: + def __init__( + self, + shapeType: int | _NoShapeTypeSentinel = _NO_SHAPE_TYPE_SENTINEL, + points: PointsT | None = None, + parts: Sequence[int] | None = None, # index of start point of each part + lines: list[PointsT] | None = None, + partTypes: Sequence[int] | None = None, + oid: int | None = None, + *, + m: Sequence[float | None] | None = None, + z: Sequence[float] | None = None, + bbox: BBox | None = None, + mbox: MBox | None = None, + zbox: ZBox | None = None, + ): + """Stores the geometry of the different shape types + specified in the Shapefile spec. Shape types are + usually point, polyline, or polygons. Every shape type + except the "Null" type contains points at some level for + example vertices in a polygon. If a shape type has + multiple shapes containing points within a single + geometry record then those shapes are called parts. Parts + are designated by their starting index in geometry record's + list of shapes. For MultiPatch geometry, partTypes designates + the patch type of each of the parts. + Lines allows the points-lists and parts to be denoted together + in one argument. It is intended for multiple point shapes + (polylines, polygons and multipatches) but if used as a length-1 + nested list for a multipoint (instead of points for some reason) + PyShp will not complain, as multipoints only have 1 part internally. + """ + + # Preserve previous behaviour for anyone who set self.shapeType = None + if shapeType is not _NO_SHAPE_TYPE_SENTINEL: + self.shapeType = cast(int, shapeType) + else: + class_name = self.__class__.__name__ + self.shapeType = SHAPETYPENUM_LOOKUP.get(class_name.upper(), NULL) + + if partTypes is not None: + self.partTypes = partTypes + + default_points: PointsT = [] + default_parts: list[int] = [] + + if lines is not None: + if self.shapeType in Polygon_shapeTypes: + lines = list(lines) + self._ensure_polygon_rings_closed(lines) + + default_points, default_parts = self._points_and_parts_indexes_from_lines( + lines + ) + elif points and self.shapeType in _CanHaveBBox_shapeTypes: + # TODO: Raise issue. + # This ensures Polylines, Polygons and Multipatches with no part information are a single + # Polyline, Polygon or Multipatch respectively. + # + # However this also allows MultiPoints shapes to have a single part index 0 as + # documented in README.md,also when set from points + # (even though this is just an artefact of initialising them as a length-1 nested + # list of points via _points_and_parts_indexes_from_lines). + # + # Alternatively single points could be given parts = [0] too, as they do if formed + # _from_geojson. + default_parts = [0] + + self.points: PointsT = points or default_points + + self.parts: Sequence[int] = parts or default_parts + + # and a dict to silently record any errors encountered in GeoJSON + self._errors: dict[str, int] = {} + + # add oid + self.__oid: int = -1 if oid is None else oid + + if bbox is not None: + self.bbox: BBox = bbox + elif len(self.points) >= 2: + self.bbox = self._bbox_from_points() + + ms_found = True + if m: + self.m: Sequence[float | None] = m + elif self.shapeType in _HasM_shapeTypes: + mpos = 3 if self.shapeType in _HasZ_shapeTypes | PointZ_shapeTypes else 2 + points_m_z = cast(Union[list[PointMT], list[PointZT]], self.points) + self.m = list(_ms_from_points(points_m_z, mpos)) + elif self.shapeType in PointM_shapeTypes: + mpos = 3 if self.shapeType == POINTZ else 2 + point_m_z = cast(Union[PointMT, PointZT], self.points[0]) + self.m = (_m_from_point(point_m_z, mpos),) + else: + ms_found = False + + zs_found = True + if z: + self.z: Sequence[float] = z + elif self.shapeType in _HasZ_shapeTypes: + points_z = cast(list[PointZT], self.points) + self.z = list(_zs_from_points(points_z)) + elif self.shapeType == POINTZ: + point_z = cast(PointZT, self.points[0]) + self.z = (_z_from_point(point_z),) + else: + zs_found = False + + if mbox is not None: + self.mbox: MBox = mbox + elif ms_found: + self.mbox = self._mbox_from_ms() + + if zbox is not None: + self.zbox: ZBox = zbox + elif zs_found: + self.zbox = self._zbox_from_zs() + + @staticmethod + def _ensure_polygon_rings_closed( + parts: list[PointsT], # Mutated + ) -> None: + for part in parts: + if part[0] != part[-1]: + part.append(part[0]) + + @staticmethod + def _points_and_parts_indexes_from_lines( + parts: list[PointsT], + ) -> tuple[PointsT, list[int]]: + # Intended for Union[Polyline, Polygon, MultiPoint, MultiPatch] + """From a list of parts (each part a list of points) return + a flattened list of points, and a list of indexes into that + flattened list corresponding to the start of each part. + + Internal method for both multipoints (formed entirely by a single part), + and shapes that have multiple collections of points (each one + a part): (poly)lines, polygons, and multipatchs. + """ + part_indexes: list[int] = [] + points: PointsT = [] + + for part in parts: + # set part index position + part_indexes.append(len(points)) + points.extend(part) + + return points, part_indexes + + def _bbox_from_points(self) -> BBox: + xs: list[float] = [] + ys: list[float] = [] + + for point in self.points: + xs.append(point[0]) + ys.append(point[1]) + + return min(xs), min(ys), max(xs), max(ys) + + def _mbox_from_ms(self) -> MBox: + ms: list[float] = [m for m in self.m if m is not None] + + if not ms: + # only if none of the shapes had m values, should mbox be set to missing m values + ms.append(NODATA) + + return min(ms), max(ms) + + def _zbox_from_zs(self) -> ZBox: + return min(self.z), max(self.z) + + @property + def __geo_interface__(self) -> GeoJSONHomogeneousGeometryObject: + if self.shapeType in {POINT, POINTM, POINTZ}: + # point + if len(self.points) == 0: + # the shape has no coordinate information, i.e. is 'empty' + # the geojson spec does not define a proper null-geometry type + # however, it does allow geometry types with 'empty' coordinates to be interpreted as null-geometries + return {"type": "Point", "coordinates": ()} + + return {"type": "Point", "coordinates": self.points[0]} + + if self.shapeType in {MULTIPOINT, MULTIPOINTM, MULTIPOINTZ}: + if len(self.points) == 0: + # the shape has no coordinate information, i.e. is 'empty' + # the geojson spec does not define a proper null-geometry type + # however, it does allow geometry types with 'empty' coordinates to be interpreted as null-geometries + return {"type": "MultiPoint", "coordinates": []} + + # multipoint + return { + "type": "MultiPoint", + "coordinates": self.points, + } + + if self.shapeType in {POLYLINE, POLYLINEM, POLYLINEZ}: + if len(self.parts) == 0: + # the shape has no coordinate information, i.e. is 'empty' + # the geojson spec does not define a proper null-geometry type + # however, it does allow geometry types with 'empty' coordinates to be interpreted as null-geometries + return {"type": "LineString", "coordinates": []} + + if len(self.parts) == 1: + # linestring + return { + "type": "LineString", + "coordinates": self.points, + } + + # multilinestring + ps = None + coordinates = [] + for part in self.parts: + if ps is None: + ps = part + continue + + coordinates.append(list(self.points[ps:part])) + ps = part + + # assert len(self.parts) > 1 + # from previous if len(self.parts) checks so part is defined + coordinates.append(list(self.points[part:])) + return {"type": "MultiLineString", "coordinates": coordinates} + + if self.shapeType in {POLYGON, POLYGONM, POLYGONZ}: + if len(self.parts) == 0: + # the shape has no coordinate information, i.e. is 'empty' + # the geojson spec does not define a proper null-geometry type + # however, it does allow geometry types with 'empty' coordinates to be interpreted as null-geometries + return {"type": "Polygon", "coordinates": []} + + # get all polygon rings + rings = [] + for i, start in enumerate(self.parts): + # get indexes of start and end points of the ring + try: + end = self.parts[i + 1] + except IndexError: + end = len(self.points) + + # extract the points that make up the ring + ring = list(self.points[start:end]) + rings.append(ring) + + # organize rings into list of polygons, where each polygon is defined as list of rings. + # the first ring is the exterior and any remaining rings are holes (same as GeoJSON). + polys = organize_polygon_rings(rings, self._errors) + + # if VERBOSE is True, issue detailed warning about any shape errors + # encountered during the Shapefile to GeoJSON conversion + if VERBOSE and self._errors: + header = f"Possible issue encountered when converting Shape #{self.oid} to GeoJSON: " + orphans = self._errors.get("polygon_orphaned_holes", None) + if orphans: + msg = ( + header + + "Shapefile format requires that all polygon interior holes be contained by an exterior ring, \ +but the Shape contained interior holes (defined by counter-clockwise orientation in the shapefile format) that were \ +orphaned, i.e. not contained by any exterior rings. The rings were still included but were \ +encoded as GeoJSON exterior rings instead of holes." + ) + logger.warning(msg) + only_holes = self._errors.get("polygon_only_holes", None) + if only_holes: + msg = ( + header + + "Shapefile format requires that polygons contain at least one exterior ring, \ +but the Shape was entirely made up of interior holes (defined by counter-clockwise orientation in the shapefile format). The rings were \ +still included but were encoded as GeoJSON exterior rings instead of holes." + ) + logger.warning(msg) + + # return as geojson + if len(polys) == 1: + return {"type": "Polygon", "coordinates": polys[0]} + + return {"type": "MultiPolygon", "coordinates": polys} + + raise GeoJSON_Error( + f'Shape type "{SHAPETYPE_LOOKUP[self.shapeType]}" cannot be represented as GeoJSON.' + ) + + @staticmethod + def _from_geojson(geoj: GeoJSONHomogeneousGeometryObject) -> Shape: + # create empty shape + # set shapeType + geojType = geoj["type"] if geoj else "Null" + if geojType in GEOJSON_TO_SHAPETYPE: + shapeType = GEOJSON_TO_SHAPETYPE[geojType] + else: + raise GeoJSON_Error(f"Cannot create Shape from GeoJSON type '{geojType}'") + + coordinates = geoj["coordinates"] + + if coordinates == (): + raise GeoJSON_Error(f"Cannot create non-Null Shape from: {coordinates=}") + + points: PointsT + parts: list[int] + + # set points and parts + if geojType == "Point": + points = [cast(PointT, coordinates)] + parts = [0] + elif geojType in ("MultiPoint", "LineString"): + points = cast(PointsT, coordinates) + parts = [0] + elif geojType == "Polygon": + points = [] + parts = [] + index = 0 + for i, ext_or_hole in enumerate(cast(list[PointsT], coordinates)): + # although the latest GeoJSON spec states that exterior rings should have + # counter-clockwise orientation, we explicitly check orientation since older + # GeoJSONs might not enforce this. + if i == 0 and not is_cw(ext_or_hole): + # flip exterior direction + ext_or_hole = rewind(ext_or_hole) + elif i > 0 and is_cw(ext_or_hole): + # flip hole direction + ext_or_hole = rewind(ext_or_hole) + points.extend(ext_or_hole) + parts.append(index) + index += len(ext_or_hole) + elif geojType == "MultiLineString": + points = [] + parts = [] + index = 0 + for linestring in cast(list[PointsT], coordinates): + points.extend(linestring) + parts.append(index) + index += len(linestring) + elif geojType == "MultiPolygon": + points = [] + parts = [] + index = 0 + for polygon in cast(list[list[PointsT]], coordinates): + for i, ext_or_hole in enumerate(polygon): + # although the latest GeoJSON spec states that exterior rings should have + # counter-clockwise orientation, we explicitly check orientation since older + # GeoJSONs might not enforce this. + if i == 0 and not is_cw(ext_or_hole): + # flip exterior direction + ext_or_hole = rewind(ext_or_hole) + elif i > 0 and is_cw(ext_or_hole): + # flip hole direction + ext_or_hole = rewind(ext_or_hole) + points.extend(ext_or_hole) + parts.append(index) + index += len(ext_or_hole) + return Shape(shapeType=shapeType, points=points, parts=parts) + + @property + def oid(self) -> int: + """The index position of the shape in the original shapefile""" + return self.__oid + + @property + def shapeTypeName(self) -> str: + return SHAPETYPE_LOOKUP[self.shapeType] + + def __repr__(self) -> str: + class_name = self.__class__.__name__ + if class_name == "Shape": + return f"Shape #{self.__oid}: {self.shapeTypeName}" + return f"{class_name} #{self.__oid}" + + +# Need unused arguments to keep the same call signature for +# different implementations of from_byte_stream and write_to_byte_stream +class NullShape(Shape): + # Shape.shapeType = NULL already, + # to preserve handling of default args in Shape.__init__ + # Repeated for the avoidance of doubt. + def __init__( + self, + oid: int | None = None, + ): + Shape.__init__(self, shapeType=NULL, oid=oid) + + @staticmethod + def from_byte_stream( + shapeType: int, + b_io: ReadSeekableBinStream, + next_shape: int, + oid: int | None = None, + bbox: BBox | None = None, + ) -> NullShape: + # Shape.__init__ sets self.points = points or [] + return NullShape(oid=oid) + + @staticmethod + def write_to_byte_stream( + b_io: WriteableBinStream, + s: Shape, + i: int, + ) -> int: + return 0 + + +_CanHaveBBox_shapeTypes = frozenset( + [ + POLYLINE, + POLYLINEM, + POLYLINEZ, + MULTIPOINT, + MULTIPOINTM, + MULTIPOINTZ, + POLYGON, + POLYGONM, + POLYGONZ, + MULTIPATCH, + ] +) + + +class _CanHaveBBox(Shape): + """As well as setting bounding boxes, we also utilize the + fact that this mixin only applies to all the shapes that are + not a single point (polylines, polygons, multipatches and multipoints). + """ + + @staticmethod + def _read_bbox_from_byte_stream(b_io: ReadableBinStream) -> BBox: + return unpack("<4d", b_io.read(32)) + + @staticmethod + def _write_bbox_to_byte_stream( + b_io: WriteableBinStream, i: int, bbox: BBox | None + ) -> int: + if not bbox or len(bbox) != 4: + raise ShapefileException(f"Four numbers required for bbox. Got: {bbox}") + try: + return b_io.write(pack("<4d", *bbox)) + except error: + raise ShapefileException( + f"Failed to write bounding box for record {i}. Expected floats." + ) + + @staticmethod + def _read_npoints_from_byte_stream(b_io: ReadableBinStream) -> int: + (nPoints,) = unpack(" int: + return b_io.write(pack(" list[Point2D]: + flat = unpack(f"<{2 * nPoints}d", b_io.read(16 * nPoints)) + return list(zip(*(iter(flat),) * 2)) + + @staticmethod + def _write_points_to_byte_stream( + b_io: WriteableBinStream, s: _CanHaveBBox, i: int + ) -> int: + x_ys: list[float] = [] + for point in s.points: + x_ys.extend(point[:2]) + try: + return b_io.write(pack(f"<{len(x_ys)}d", *x_ys)) + except error: + raise ShapefileException( + f"Failed to write points for record {i}. Expected floats." + ) + + @classmethod + def from_byte_stream( + cls, + shapeType: int, + b_io: ReadSeekableBinStream, + next_shape: int, + oid: int | None = None, + bbox: BBox | None = None, + ) -> Shape | None: + ShapeClass = cast(type[_CanHaveBBox], SHAPE_CLASS_FROM_SHAPETYPE[shapeType]) + + kwargs: CanHaveBboxNoLinesKwargs = {"oid": oid} # "shapeType": shapeType} + kwargs["bbox"] = shape_bbox = cls._read_bbox_from_byte_stream(b_io) + + # if bbox specified and no overlap, skip this shape + if bbox is not None and not bbox_overlap(bbox, shape_bbox): + # because we stop parsing this shape, caller must skip to beginning of + # next shape after we return (as done in f.seek(next_shape)) + return None + + nParts: int | None = ( + _CanHaveParts._read_nparts_from_byte_stream(b_io) + if shapeType in _CanHaveParts_shapeTypes + else None + ) + nPoints: int = cls._read_npoints_from_byte_stream(b_io) + # Previously, we also set __zmin = __zmax = __mmin = __mmax = None + + if nParts: + kwargs["parts"] = _CanHaveParts._read_parts_from_byte_stream(b_io, nParts) + if shapeType == MULTIPATCH: + kwargs["partTypes"] = MultiPatch._read_part_types_from_byte_stream( + b_io, nParts + ) + + if nPoints: + kwargs["points"] = cast( + PointsT, cls._read_points_from_byte_stream(b_io, nPoints) + ) + + if shapeType in _HasZ_shapeTypes: + kwargs["zbox"], kwargs["z"] = _HasZ._read_zs_from_byte_stream( + b_io, nPoints + ) + + if shapeType in _HasM_shapeTypes: + kwargs["mbox"], kwargs["m"] = _HasM._read_ms_from_byte_stream( + b_io, nPoints, next_shape + ) + + return ShapeClass(**kwargs) + + @staticmethod + def write_to_byte_stream( + b_io: WriteableBinStream, + s: Shape, + i: int, + ) -> int: + # We use static methods here and below, + # to support s only being an instance of the + # Shape base class (with shapeType set) + # i.e. not necessarily one of our newer shape specific + # sub classes. + + n = 0 + + if s.shapeType in _CanHaveBBox_shapeTypes: + n += _CanHaveBBox._write_bbox_to_byte_stream(b_io, i, s.bbox) + + if s.shapeType in _CanHaveParts_shapeTypes: + n += _CanHaveParts._write_nparts_to_byte_stream( + b_io, cast(_CanHaveParts, s) + ) + # Shape types with multiple points per record + if s.shapeType in _CanHaveBBox_shapeTypes: + n += _CanHaveBBox._write_npoints_to_byte_stream(b_io, cast(_CanHaveBBox, s)) + # Write part indexes. Includes MultiPatch + if s.shapeType in _CanHaveParts_shapeTypes: + n += _CanHaveParts._write_part_indices_to_byte_stream( + b_io, cast(_CanHaveParts, s) + ) + + if s.shapeType in MultiPatch_shapeTypes: + n += MultiPatch._write_part_types_to_byte_stream(b_io, cast(MultiPatch, s)) + # Write points for multiple-point records + if s.shapeType in _CanHaveBBox_shapeTypes: + n += _CanHaveBBox._write_points_to_byte_stream( + b_io, cast(_CanHaveBBox, s), i + ) + if s.shapeType in _HasZ_shapeTypes: + n += _HasZ._write_zs_to_byte_stream(b_io, cast(_HasZ, s), i, s.zbox) + + if s.shapeType in _HasM_shapeTypes: + n += _HasM._write_ms_to_byte_stream(b_io, cast(_HasM, s), i, s.mbox) + + return n + + +_CanHaveParts_shapeTypes = frozenset( + [ + POLYLINE, + POLYLINEM, + POLYLINEZ, + POLYGON, + POLYGONM, + POLYGONZ, + MULTIPATCH, + ] +) + + +class _CanHaveParts(_CanHaveBBox): + # The parts attribute is initialised by + # the base class Shape's __init__, to parts or []. + # "Can Have Parts" should be read as "Can Have non-empty parts". + + @staticmethod + def _read_nparts_from_byte_stream(b_io: ReadableBinStream) -> int: + (nParts,) = unpack(" int: + return b_io.write(pack(" _Array[int]: + return _Array[int]("i", unpack(f"<{nParts}i", b_io.read(nParts * 4))) + + @staticmethod + def _write_part_indices_to_byte_stream( + b_io: WriteableBinStream, s: _CanHaveParts + ) -> int: + return b_io.write(pack(f"<{len(s.parts)}i", *s.parts)) + + +Point_shapeTypes = frozenset([POINT, POINTM, POINTZ]) + + +class Point(Shape): + # We also use the fact that the single Point types are the only + # shapes that cannot have their own bounding box (a user supplied + # bbox is still used to filter out points). + def __init__( + self, + x: float, + y: float, + oid: int | None = None, + ): + Shape.__init__(self, points=[(x, y)], oid=oid) + + @staticmethod + def _x_y_from_byte_stream(b_io: ReadableBinStream) -> tuple[float, float]: + x, y = unpack("<2d", b_io.read(16)) + # Convert to tuple + return x, y + + @staticmethod + def _write_x_y_to_byte_stream( + b_io: WriteableBinStream, x: float, y: float, i: int + ) -> int: + try: + return b_io.write(pack("<2d", x, y)) + except error: + raise ShapefileException( + f"Failed to write point for record {i}. Expected floats." + ) + + @classmethod + def from_byte_stream( + cls, + shapeType: int, + b_io: ReadSeekableBinStream, + next_shape: int, + oid: int | None = None, + bbox: BBox | None = None, + ) -> Shape | None: + x, y = cls._x_y_from_byte_stream(b_io) + + if bbox is not None: + # create bounding box for Point by duplicating coordinates + # skip shape if no overlap with bounding box + if not bbox_overlap(bbox, (x, y, x, y)): + return None + elif shapeType == POINT: + return Point(x=x, y=y, oid=oid) + + if shapeType == POINTZ: + z = PointZ._read_single_point_zs_from_byte_stream(b_io)[0] + + m = PointM._read_single_point_ms_from_byte_stream(b_io, next_shape)[0] + + if shapeType == POINTZ: + return PointZ(x=x, y=y, z=z, m=m, oid=oid) + + return PointM(x=x, y=y, m=m, oid=oid) + # return Shape(shapeType=shapeType, points=[(x, y)], z=zs, m=ms, oid=oid) + + @staticmethod + def write_to_byte_stream(b_io: WriteableBinStream, s: Shape, i: int) -> int: + # Serialize a single point + x, y = s.points[0][0], s.points[0][1] + n = Point._write_x_y_to_byte_stream(b_io, x, y, i) + + # Write a single Z value + if s.shapeType in PointZ_shapeTypes: + n += PointZ._write_single_point_z_to_byte_stream(b_io, s, i) + + # Write a single M value + if s.shapeType in PointM_shapeTypes: + n += PointM._write_single_point_m_to_byte_stream(b_io, s, i) + + return n + + +Polyline_shapeTypes = frozenset([POLYLINE, POLYLINEM, POLYLINEZ]) + + +class Polyline(_CanHaveParts): + def __init__( + self, + *args: PointsT, + lines: list[PointsT] | None = None, + points: PointsT | None = None, + parts: list[int] | None = None, + bbox: BBox | None = None, + oid: int | None = None, + ): + if args: + if lines: + raise ShapefileException( + "Specify Either: a) positional args, or: b) the keyword arg lines. " + f"Not both. Got both: {args} and {lines=}. " + "If this was intentional, after the other positional args, " + "the arg passed to lines can be unpacked (arg1, arg2, *more_args, *lines, oid=oid,...)" + ) + lines = list(args) + Shape.__init__( + self, + lines=lines, + points=points, + parts=parts, + bbox=bbox, + oid=oid, + ) + + +Polygon_shapeTypes = frozenset([POLYGON, POLYGONM, POLYGONZ]) + + +class Polygon(_CanHaveParts): + def __init__( + self, + *args: PointsT, + lines: list[PointsT] | None = None, + parts: list[int] | None = None, + points: PointsT | None = None, + bbox: BBox | None = None, + oid: int | None = None, + ): + lines = list(args) if args else lines + Shape.__init__( + self, + lines=lines, + points=points, + parts=parts, + bbox=bbox, + oid=oid, + ) + + +MultiPoint_shapeTypes = frozenset([MULTIPOINT, MULTIPOINTM, MULTIPOINTZ]) + + +class MultiPoint(_CanHaveBBox): + def __init__( + self, + *args: PointT, + points: PointsT | None = None, + bbox: BBox | None = None, + oid: int | None = None, + ): + if args: + if points: + raise ShapefileException( + "Specify Either: a) positional args, or: b) the keyword arg points. " + f"Not both. Got both: {args} and {points=}. " + "If this was intentional, after the other positional args, " + "the arg passed to points can be unpacked, e.g. " + " (arg1, arg2, *more_args, *points, oid=oid,...)" + ) + points = list(args) + Shape.__init__( + self, + points=points, + bbox=bbox, + oid=oid, + ) + + +# Not a PointM or a PointZ +_HasM_shapeTypes = frozenset( + [ + POLYLINEM, + POLYLINEZ, + POLYGONM, + POLYGONZ, + MULTIPOINTM, + MULTIPOINTZ, + MULTIPATCH, + ] +) + + +class _HasM(_CanHaveBBox): + m: Sequence[float | None] + + @staticmethod + def _read_ms_from_byte_stream( + b_io: ReadSeekableBinStream, nPoints: int, next_shape: int + ) -> tuple[MBox | None, list[float | None]]: + mbox = None # Ensure mbox is always defined + if next_shape - b_io.tell() >= 16: + mbox = unpack("<2d", b_io.read(16)) + # Measure values less than -10e38 are nodata values according to the spec + if next_shape - b_io.tell() >= nPoints * 8: + ms = [] + for m in unpack(f"<{nPoints}d", b_io.read(nPoints * 8)): + if m > NODATA: + ms.append(m) + else: + ms.append(None) + else: + ms = [None for _ in range(nPoints)] + return mbox, ms + + @staticmethod + def _write_ms_to_byte_stream( + b_io: WriteableBinStream, s: Shape, i: int, mbox: MBox | None + ) -> int: + if not mbox or len(mbox) != 2: + raise ShapefileException(f"Two numbers required for mbox. Got: {mbox}") + # Write m extremes and values + # When reading a file, pyshp converts NODATA m values to None, so here we make sure to convert them back to NODATA + # Note: missing m values are autoset to NODATA. + try: + num_bytes_written = b_io.write(pack("<2d", *mbox)) + except error: + raise ShapefileException( + f"Failed to write measure extremes for record {i}. Expected floats" + ) + try: + ms = cast(_HasM, s).m + + ms_to_encode = [m if m is not None else NODATA for m in ms] + + num_bytes_written += b_io.write(pack(f"<{len(ms)}d", *ms_to_encode)) + except error: + raise ShapefileException( + f"Failed to write measure values for record {i}. Expected floats" + ) + + return num_bytes_written + + +# Not a PointZ +_HasZ_shapeTypes = frozenset( + [ + POLYLINEZ, + POLYGONZ, + MULTIPOINTZ, + MULTIPATCH, + ] +) + + +class _HasZ(_CanHaveBBox): + z: Sequence[float] + + @staticmethod + def _read_zs_from_byte_stream( + b_io: ReadableBinStream, nPoints: int + ) -> tuple[ZBox, Sequence[float]]: + zbox = unpack("<2d", b_io.read(16)) + return zbox, _Array[float]("d", unpack(f"<{nPoints}d", b_io.read(nPoints * 8))) + + @staticmethod + def _write_zs_to_byte_stream( + b_io: WriteableBinStream, s: Shape, i: int, zbox: ZBox | None + ) -> int: + if not zbox or len(zbox) != 2: + raise ShapefileException(f"Two numbers required for zbox. Got: {zbox}") + + # Write z extremes and values + # Note: missing z values are autoset to 0, but not sure if this is ideal. + try: + num_bytes_written = b_io.write(pack("<2d", *zbox)) + except error: + raise ShapefileException( + f"Failed to write elevation extremes for record {i}. Expected floats." + ) + try: + zs = cast(_HasZ, s).z + num_bytes_written += b_io.write(pack(f"<{len(zs)}d", *zs)) + except error: + raise ShapefileException( + f"Failed to write elevation values for record {i}. Expected floats." + ) + + return num_bytes_written + + +MultiPatch_shapeTypes = frozenset([MULTIPATCH]) + + +class MultiPatch(_HasM, _HasZ, _CanHaveParts): + def __init__( + self, + *args: PointsT, + lines: list[PointsT] | None = None, + partTypes: list[int] | None = None, + z: list[float] | None = None, + m: list[float | None] | None = None, + points: PointsT | None = None, + parts: list[int] | None = None, + bbox: BBox | None = None, + mbox: MBox | None = None, + zbox: ZBox | None = None, + oid: int | None = None, + ): + if args: + if lines: + raise ShapefileException( + "Specify Either: a) positional args, or: b) the keyword arg lines. " + f"Not both. Got both: {args} and {lines=}. " + "If this was intentional, after the other positional args, " + "the arg passed to lines can be unpacked (arg1, arg2, *more_args, *lines, oid=oid,...)" + ) + lines = list(args) + Shape.__init__( + self, + lines=lines, + points=points, + parts=parts, + partTypes=partTypes, + z=z, + m=m, + bbox=bbox, + zbox=zbox, + mbox=mbox, + oid=oid, + ) + + @staticmethod + def _read_part_types_from_byte_stream( + b_io: ReadableBinStream, nParts: int + ) -> Sequence[int]: + return _Array[int]("i", unpack(f"<{nParts}i", b_io.read(nParts * 4))) + + @staticmethod + def _write_part_types_to_byte_stream(b_io: WriteableBinStream, s: Shape) -> int: + return b_io.write(pack(f"<{len(s.partTypes)}i", *s.partTypes)) + + +PointM_shapeTypes = frozenset([POINTM, POINTZ]) + + +class PointM(Point): + def __init__( + self, + x: float, + y: float, + # same default as in Writer.__shpRecord (if s.shapeType in (11, 21):) + # PyShp encodes None m values as NODATA + m: float | None = None, + oid: int | None = None, + ): + Shape.__init__(self, points=[(x, y)], m=(m,), oid=oid) + + @staticmethod + def _read_single_point_ms_from_byte_stream( + b_io: ReadSeekableBinStream, next_shape: int + ) -> tuple[float | None]: + if next_shape - b_io.tell() >= 8: + (m,) = unpack(" NODATA: + return (m,) + else: + return (None,) + + @staticmethod + def _write_single_point_m_to_byte_stream( + b_io: WriteableBinStream, s: Shape, i: int + ) -> int: + try: + s = cast(_HasM, s) + m = s.m[0] if s.m else None + except error: + raise ShapefileException( + f"Failed to write measure value for record {i}. Expected floats." + ) + + # Note: missing m values are autoset to NODATA. + m_to_encode = m if m is not None else NODATA + + return b_io.write(pack("<1d", m_to_encode)) + + +PolylineM_shapeTypes = frozenset([POLYLINEM, POLYLINEZ]) + + +class PolylineM(Polyline, _HasM): + def __init__( + self, + *args: PointsT, + lines: list[PointsT] | None = None, + parts: list[int] | None = None, + m: Sequence[float | None] | None = None, + points: PointsT | None = None, + bbox: BBox | None = None, + mbox: MBox | None = None, + oid: int | None = None, + ): + if args: + if lines: + raise ShapefileException( + "Specify Either: a) positional args, or: b) the keyword arg lines. " + f"Not both. Got both: {args} and {lines=}. " + "If this was intentional, after the other positional args, " + "the arg passed to lines can be unpacked (arg1, arg2, *more_args, *lines, oid=oid,...)" + ) + lines = list(args) + Shape.__init__( + self, + lines=lines, + points=points, + parts=parts, + m=m, + bbox=bbox, + mbox=mbox, + oid=oid, + ) + + +PolygonM_shapeTypes = frozenset([POLYGONM, POLYGONZ]) + + +class PolygonM(Polygon, _HasM): + def __init__( + self, + *args: PointsT, + lines: list[PointsT] | None = None, + parts: list[int] | None = None, + m: list[float | None] | None = None, + points: PointsT | None = None, + bbox: BBox | None = None, + mbox: MBox | None = None, + oid: int | None = None, + ): + if args: + if lines: + raise ShapefileException( + "Specify Either: a) positional args, or: b) the keyword arg lines. " + f"Not both. Got both: {args} and {lines=}. " + "If this was intentional, after the other positional args, " + "the arg passed to lines can be unpacked (arg1, arg2, *more_args, *lines, oid=oid,...)" + ) + lines = list(args) + Shape.__init__( + self, + lines=lines, + points=points, + parts=parts, + m=m, + bbox=bbox, + mbox=mbox, + oid=oid, + ) + + +MultiPointM_shapeTypes = frozenset([MULTIPOINTM, MULTIPOINTZ]) + + +class MultiPointM(MultiPoint, _HasM): + def __init__( + self, + *args: PointT, + points: PointsT | None = None, + m: Sequence[float | None] | None = None, + bbox: BBox | None = None, + mbox: MBox | None = None, + oid: int | None = None, + ): + if args: + if points: + raise ShapefileException( + "Specify Either: a) positional args, or: b) the keyword arg points. " + f"Not both. Got both: {args} and {points=}. " + "If this was intentional, after the other positional args, " + "the arg passed to points can be unpacked, e.g. " + " (arg1, arg2, *more_args, *points, oid=oid,...)" + ) + points = list(args) + Shape.__init__( + self, + points=points, + m=m, + bbox=bbox, + mbox=mbox, + oid=oid, + ) + + +PointZ_shapeTypes = frozenset([POINTZ]) + + +class PointZ(PointM): + def __init__( + self, + x: float, + y: float, + z: float = 0.0, + m: float | None = None, + oid: int | None = None, + ): + Shape.__init__(self, points=[(x, y)], z=(z,), m=(m,), oid=oid) + + # same default as in Writer.__shpRecord (if s.shapeType == 11:) + z: Sequence[float] = (0.0,) + + @staticmethod + def _read_single_point_zs_from_byte_stream(b_io: ReadableBinStream) -> tuple[float]: + return unpack(" int: + # Note: missing z values are autoset to 0, but not sure if this is ideal. + z: float = 0.0 + # then write value + + try: + if s.z: + z = s.z[0] + except error: + raise ShapefileException( + f"Failed to write elevation value for record {i}. Expected floats." + ) + + return b_io.write(pack(">> # Create a Record with one field, normally the record is created by the Reader class + >>> r = _Record({'ID': 0}, [0]) + >>> print(r[0]) + >>> print(r['ID']) + >>> print(r.ID) + """ + + def __init__( + self, + field_positions: dict[str, int], + values: Iterable[RecordValue], + oid: int | None = None, + ): + """ + A Record should be created by the Reader class + + :param field_positions: A dict mapping field names to field positions + :param values: A sequence of values + :param oid: The object id, an int (optional) + """ + self.__field_positions = field_positions + if oid is not None: + self.__oid = oid + else: + self.__oid = -1 + list.__init__(self, values) + + def __getattr__(self, item: str) -> RecordValue: + """ + __getattr__ is called if an attribute is used that does + not exist in the normal sense. For example r=Record(...), r.ID + calls r.__getattr__('ID'), but r.index(5) calls list.index(r, 5) + :param item: The field name, used as attribute + :return: Value of the field + :raises: AttributeError, if item is not a field of the shapefile + and IndexError, if the field exists but the field's + corresponding value in the Record does not exist + """ + try: + if item == "__setstate__": # Prevent infinite loop from copy.deepcopy() + raise AttributeError("_Record does not implement __setstate__") + index = self.__field_positions[item] + return list.__getitem__(self, index) + except KeyError: + raise AttributeError(f"{item} is not a field name") + except IndexError: + raise IndexError( + f"{item} found as a field but not enough values available." + ) + + def __setattr__(self, key: str, value: RecordValue) -> None: + """ + Sets a value of a field attribute + :param key: The field name + :param value: the value of that field + :return: None + :raises: AttributeError, if key is not a field of the shapefile + """ + if key.startswith("_"): # Prevent infinite loop when setting mangled attribute + return list.__setattr__(self, key, value) + try: + index = self.__field_positions[key] + return list.__setitem__(self, index, value) + except KeyError: + raise AttributeError(f"{key} is not a field name") + + @overload + def __getitem__(self, i: SupportsIndex) -> RecordValue: ... + @overload + def __getitem__(self, s: slice) -> list[RecordValue]: ... + @overload + def __getitem__(self, s: str) -> RecordValue: ... + def __getitem__( + self, item: SupportsIndex | slice | str + ) -> RecordValue | list[RecordValue]: + """ + Extends the normal list item access with + access using a fieldname + + For example r['ID'], r[0] + :param item: Either the position of the value or the name of a field + :return: the value of the field + """ + try: + return list.__getitem__(self, item) # type: ignore[index] + except TypeError: + try: + index = self.__field_positions[item] # type: ignore[index] + except KeyError: + index = None + if index is not None: + return list.__getitem__(self, index) + + raise IndexError(f'"{item}" is not a field name and not an int') + + @overload + def __setitem__(self, key: SupportsIndex, value: RecordValue) -> None: ... + @overload + def __setitem__(self, key: slice, value: Iterable[RecordValue]) -> None: ... + @overload + def __setitem__(self, key: str, value: RecordValue) -> None: ... + def __setitem__( + self, + key: SupportsIndex | slice | str, + value: RecordValue | Iterable[RecordValue], + ) -> None: + """ + Extends the normal list item access with + access using a fieldname + + For example r['ID']=2, r[0]=2 + :param key: Either the position of the value or the name of a field + :param value: the new value of the field + """ + try: + return list.__setitem__(self, key, value) # type: ignore[misc,assignment] + except TypeError: + index = self.__field_positions.get(key) # type: ignore[arg-type] + if index is not None: + return list.__setitem__(self, index, value) # type: ignore[misc] + + raise IndexError(f"{key} is not a field name and not an int") + + @property + def oid(self) -> int: + """The index position of the record in the original shapefile""" + return self.__oid + + def as_dict(self, date_strings: bool = False) -> dict[str, RecordValue]: + """ + Returns this Record as a dictionary using the field names as keys + :return: dict + """ + dct = {f: self[i] for f, i in self.__field_positions.items()} + if date_strings: + for k, v in dct.items(): + if isinstance(v, date): + dct[k] = f"{v.year:04d}{v.month:02d}{v.day:02d}" + return dct + + def __repr__(self) -> str: + return f"Record #{self.__oid}: {list(self)}" + + def __dir__(self) -> list[str]: + """ + Helps to show the field names in an interactive environment like IPython. + See: http://ipython.readthedocs.io/en/stable/config/integrating.html + + :return: List of method names and fields + """ + default = list( + dir(type(self)) + ) # default list methods and attributes of this class + fnames = list( + self.__field_positions.keys() + ) # plus field names (random order if Python version < 3.6) + return default + fnames + + def __eq__(self, other: Any) -> bool: + if isinstance(other, _Record): + if self.__field_positions != other.__field_positions: + return False + return list.__eq__(self, other) + + +class ShapeRecord: + """A ShapeRecord object containing a shape along with its attributes. + Provides the GeoJSON __geo_interface__ to return a Feature dictionary.""" + + def __init__(self, shape: Shape | None = None, record: _Record | None = None): + self.shape = shape + self.record = record + + @property + def __geo_interface__(self) -> GeoJSONFeature: + return { + "type": "Feature", + "properties": None + if self.record is None + else self.record.as_dict(date_strings=True), + "geometry": None + if self.shape is None or self.shape.shapeType == NULL + else self.shape.__geo_interface__, + } + + +class Shapes(list[Optional[Shape]]): + """A class to hold a list of Shape objects. Subclasses list to ensure compatibility with + former work and to reuse all the optimizations of the builtin list. + In addition to the list interface, this also provides the GeoJSON __geo_interface__ + to return a GeometryCollection dictionary.""" + + def __repr__(self) -> str: + return f"Shapes: {list(self)}" + + @property + def __geo_interface__(self) -> GeoJSONGeometryCollection: + # Note: currently this will fail if any of the shapes are null-geometries + # could be fixed by storing the shapefile shapeType upon init, returning geojson type with empty coords + collection = GeoJSONGeometryCollection( + type="GeometryCollection", + geometries=[shape.__geo_interface__ for shape in self if shape is not None], + ) + return collection + + +class ShapeRecords(list[ShapeRecord]): + """A class to hold a list of ShapeRecord objects. Subclasses list to ensure compatibility with + former work and to reuse all the optimizations of the builtin list. + In addition to the list interface, this also provides the GeoJSON __geo_interface__ + to return a FeatureCollection dictionary.""" + + def __repr__(self) -> str: + return f"ShapeRecords: {list(self)}" + + @property + def __geo_interface__(self) -> GeoJSONFeatureCollection: + return GeoJSONFeatureCollection( + type="FeatureCollection", + features=[shaperec.__geo_interface__ for shaperec in self], + ) + + +class ShapefileException(Exception): + """An exception to handle shapefile specific problems.""" + + +class _NoShpSentinel: + """For use as a default value for shp to preserve the + behaviour (from when all keyword args were gathered + in the **kwargs dict) in case someone explictly + called Reader(shp=None) to load self.shx. + """ + + +_NO_SHP_SENTINEL = _NoShpSentinel() + + +class Reader: + """Reads the three files of a shapefile as a unit or + separately. If one of the three files (.shp, .shx, + .dbf) is missing no exception is thrown until you try + to call a method that depends on that particular file. + The .shx index file is used if available for efficiency + but is not required to read the geometry from the .shp + file. The "shapefile" argument in the constructor is the + name of the file you want to open, and can be the path + to a shapefile on a local filesystem, inside a zipfile, + or a url. + + You can instantiate a Reader without specifying a shapefile + and then specify one later with the load() method. + + Only the shapefile headers are read upon loading. Content + within each file is only accessed when required and as + efficiently as possible. Shapefiles are usually not large + but they can be. + """ + + CONSTITUENT_FILE_EXTS = ["shp", "shx", "dbf"] + assert all(ext.islower() for ext in CONSTITUENT_FILE_EXTS) + + def _assert_ext_is_supported(self, ext: str) -> None: + assert ext in self.CONSTITUENT_FILE_EXTS + + def __init__( + self, + shapefile_path: str | PathLike[Any] = "", + /, + *, + encoding: str = "utf-8", + encodingErrors: str = "strict", + shp: _NoShpSentinel | BinaryFileT | None = _NO_SHP_SENTINEL, + shx: BinaryFileT | None = None, + dbf: BinaryFileT | None = None, + # Keep kwargs even though unused, to preserve PyShp 2.4 API + **kwargs: Any, + ): + self.shp = None + self.shx = None + self.dbf = None + self._files_to_close: list[BinaryFileStreamT] = [] + self.shapeName = "Not specified" + self._offsets: list[int] = [] + self.shpLength: int | None = None + self.numRecords: int | None = None + self.numShapes: int | None = None + self.fields: list[Field] = [] + self.__dbfHdrLength = 0 + self.__fieldLookup: dict[str, int] = {} + self.encoding = encoding + self.encodingErrors = encodingErrors + # See if a shapefile name was passed as the first argument + if shapefile_path: + path = fsdecode_if_pathlike(shapefile_path) + if isinstance(path, str): + if ".zip" in path: + # Shapefile is inside a zipfile + if path.count(".zip") > 1: + # Multiple nested zipfiles + raise ShapefileException( + f"Reading from multiple nested zipfiles is not supported: {path}" + ) + # Split into zipfile and shapefile paths + if path.endswith(".zip"): + zpath = path + shapefile = None + else: + zpath = path[: path.find(".zip") + 4] + shapefile = path[path.find(".zip") + 4 + 1 :] + + zipfileobj: ( + tempfile._TemporaryFileWrapper[bytes] | io.BufferedReader + ) + # Create a zip file handle + if zpath.startswith("http"): + # Zipfile is from a url + # Download to a temporary url and treat as normal zipfile + req = Request( + zpath, + headers={ + "User-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/35.0.1916.47 Safari/537.36" + }, + ) + resp = urlopen(req) + # write zipfile data to a read+write tempfile and use as source, gets deleted when garbage collected + zipfileobj = tempfile.NamedTemporaryFile( + mode="w+b", suffix=".zip", delete=True + ) + zipfileobj.write(resp.read()) + zipfileobj.seek(0) + else: + # Zipfile is from a file + zipfileobj = open(zpath, mode="rb") + # Open the zipfile archive + with zipfile.ZipFile(zipfileobj, "r") as archive: + if not shapefile: + # Only the zipfile path is given + # Inspect zipfile contents to find the full shapefile path + shapefiles = [ + name + for name in archive.namelist() + if (name.endswith(".SHP") or name.endswith(".shp")) + ] + # The zipfile must contain exactly one shapefile + if len(shapefiles) == 0: + raise ShapefileException( + "Zipfile does not contain any shapefiles" + ) + if len(shapefiles) == 1: + shapefile = shapefiles[0] + else: + raise ShapefileException( + f"Zipfile contains more than one shapefile: {shapefiles}. " + "Please specify the full path to the shapefile you would like to open." + ) + # Try to extract file-like objects from zipfile + shapefile = os.path.splitext(shapefile)[ + 0 + ] # root shapefile name + for lower_ext in self.CONSTITUENT_FILE_EXTS: + for cased_ext in [lower_ext, lower_ext.upper()]: + try: + member = archive.open(f"{shapefile}.{cased_ext}") + # write zipfile member data to a read+write tempfile and use as source, gets deleted on close() + fileobj = tempfile.NamedTemporaryFile( + mode="w+b", delete=True + ) + fileobj.write(member.read()) + fileobj.seek(0) + setattr(self, lower_ext, fileobj) + self._files_to_close.append(fileobj) + except (OSError, AttributeError, KeyError): + pass + # Close and delete the temporary zipfile + try: + zipfileobj.close() + # TODO Does catching all possible exceptions really increase + # the chances of closing the zipfile successully, or does it + # just mean .close() failures will still fail, but fail + # silently? + except: # noqa: E722 + pass + # Try to load shapefile + if self.shp or self.dbf: + # Load and exit early + self.load() + return + + raise ShapefileException( + f"No shp or dbf file found in zipfile: {path}" + ) + + if path.startswith("http"): + # Shapefile is from a url + # Download each file to temporary path and treat as normal shapefile path + urlinfo = urlparse(path) + urlpath = urlinfo[2] + urlpath, _ = os.path.splitext(urlpath) + shapefile = os.path.basename(urlpath) + for ext in ["shp", "shx", "dbf"]: + try: + _urlinfo = list(urlinfo) + _urlinfo[2] = urlpath + "." + ext + _path = urlunparse(_urlinfo) + req = Request( + _path, + headers={ + "User-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/35.0.1916.47 Safari/537.36" + }, + ) + resp = urlopen(req) + # write url data to a read+write tempfile and use as source, gets deleted on close() + fileobj = tempfile.NamedTemporaryFile( + mode="w+b", delete=True + ) + fileobj.write(resp.read()) + fileobj.seek(0) + setattr(self, ext, fileobj) + self._files_to_close.append(fileobj) + except HTTPError: + pass + if self.shp or self.dbf: + # Load and exit early + self.load() + return + + raise ShapefileException(f"No shp or dbf file found at url: {path}") + + # Local file path to a shapefile + # Load and exit early + self.load(path) + return + + if shp is not _NO_SHP_SENTINEL: + shp = cast(Union[str, PathLike[Any], IO[bytes], None], shp) + self.shp = self.__seek_0_on_file_obj_wrap_or_open_from_name("shp", shp) + self.shx = self.__seek_0_on_file_obj_wrap_or_open_from_name("shx", shx) + + self.dbf = self.__seek_0_on_file_obj_wrap_or_open_from_name("dbf", dbf) + + # Load the files + if self.shp or self.dbf: + self._try_to_set_constituent_file_headers() + + def __seek_0_on_file_obj_wrap_or_open_from_name( + self, + ext: str, + file_: BinaryFileT | None, + ) -> None | IO[bytes]: + # assert ext in {'shp', 'dbf', 'shx'} + self._assert_ext_is_supported(ext) + + if file_ is None: + return None + + if isinstance(file_, (str, PathLike)): + baseName, __ = os.path.splitext(file_) + return self._load_constituent_file(baseName, ext) + + if hasattr(file_, "read"): + # Copy if required + try: + file_.seek(0) + return file_ + except (NameError, io.UnsupportedOperation): + return io.BytesIO(file_.read()) + + raise ShapefileException( + f"Could not load shapefile constituent file from: {file_}" + ) + + def __str__(self) -> str: + """ + Use some general info on the shapefile as __str__ + """ + info = ["shapefile Reader"] + if self.shp: + info.append( + f" {len(self)} shapes (type '{SHAPETYPE_LOOKUP[self.shapeType]}')" + ) + if self.dbf: + info.append(f" {len(self)} records ({len(self.fields)} fields)") + return "\n".join(info) + + def __enter__(self) -> Reader: + """ + Enter phase of context manager. + """ + return self + + # def __exit__(self, exc_type, exc_val, exc_tb) -> None: + def __exit__( + self, + exc_type: BaseException | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: + """ + Exit phase of context manager, close opened files. + """ + self.close() + return None + + def __len__(self) -> int: + """Returns the number of shapes/records in the shapefile.""" + if self.dbf: + # Preferably use dbf record count + if self.numRecords is None: + self.__dbfHeader() + + # .__dbfHeader sets self.numRecords or raises Exception + return cast(int, self.numRecords) + + if self.shp: + # Otherwise use shape count + if self.shx: + if self.numShapes is None: + self.__shxHeader() + + # .__shxHeader sets self.numShapes or raises Exception + return cast(int, self.numShapes) + + # Index file not available, iterate all shapes to get total count + if self.numShapes is None: + # Determine length of shp file + shp = self.shp + checkpoint = shp.tell() + shp.seek(0, 2) + shpLength = shp.tell() + shp.seek(100) + # Do a fast shape iteration until end of file. + offsets = [] + pos = shp.tell() + while pos < shpLength: + offsets.append(pos) + # Unpack the shape header only + (__recNum, recLength) = unpack_2_int32_be(shp.read(8)) + # Jump to next shape position + pos += 8 + (2 * recLength) + shp.seek(pos) + # Set numShapes and offset indices + self.numShapes = len(offsets) + self._offsets = offsets + # Return to previous file position + shp.seek(checkpoint) + + return self.numShapes + + # No file loaded yet, treat as 'empty' shapefile + return 0 + + def __iter__(self) -> Iterator[ShapeRecord]: + """Iterates through the shapes/records in the shapefile.""" + yield from self.iterShapeRecords() + + @property + def __geo_interface__(self) -> GeoJSONFeatureCollectionWithBBox: + shaperecords = self.shapeRecords() + fcollection = GeoJSONFeatureCollectionWithBBox( + bbox=list(self.bbox), + **shaperecords.__geo_interface__, + ) + return fcollection + + @property + def shapeTypeName(self) -> str: + return SHAPETYPE_LOOKUP[self.shapeType] + + def load(self, shapefile: str | None = None) -> None: + """Opens a shapefile from a filename or file-like + object. Normally this method would be called by the + constructor with the file name as an argument.""" + if shapefile: + (shapeName, __ext) = os.path.splitext(shapefile) + self.shapeName = shapeName + self.load_shp(shapeName) + self.load_shx(shapeName) + self.load_dbf(shapeName) + if not (self.shp or self.dbf): + raise ShapefileException( + f"Unable to open {shapeName}.dbf or {shapeName}.shp." + ) + self._try_to_set_constituent_file_headers() + + def _try_to_set_constituent_file_headers(self) -> None: + if self.shp: + self.__shpHeader() + if self.dbf: + self.__dbfHeader() + if self.shx: + self.__shxHeader() + + def _try_get_open_constituent_file( + self, + shapefile_name: str, + ext: str, + ) -> IO[bytes] | None: + """ + Attempts to open a .shp, .dbf or .shx file, + with both lower case and upper case file extensions, + and return it. If it was not possible to open the file, None is returned. + """ + # typing.LiteralString is only available from PYthon 3.11 onwards. + # https://docs.python.org/3/library/typing.html#typing.LiteralString + # assert ext in {'shp', 'dbf', 'shx'} + self._assert_ext_is_supported(ext) + + try: + return open(f"{shapefile_name}.{ext}", "rb") + except OSError: + try: + return open(f"{shapefile_name}.{ext.upper()}", "rb") + except OSError: + return None + + def _load_constituent_file( + self, + shapefile_name: str, + ext: str, + ) -> IO[bytes] | None: + """ + Attempts to open a .shp, .dbf or .shx file, with the extension + as both lower and upper case, and if successful append it to + self._files_to_close. + """ + shp_dbf_or_dhx_file = self._try_get_open_constituent_file(shapefile_name, ext) + if shp_dbf_or_dhx_file is not None: + self._files_to_close.append(shp_dbf_or_dhx_file) + return shp_dbf_or_dhx_file + + def load_shp(self, shapefile_name: str) -> None: + """ + Attempts to load file with .shp extension as both lower and upper case + """ + self.shp = self._load_constituent_file(shapefile_name, "shp") + + def load_shx(self, shapefile_name: str) -> None: + """ + Attempts to load file with .shx extension as both lower and upper case + """ + self.shx = self._load_constituent_file(shapefile_name, "shx") + + def load_dbf(self, shapefile_name: str) -> None: + """ + Attempts to load file with .dbf extension as both lower and upper case + """ + self.dbf = self._load_constituent_file(shapefile_name, "dbf") + + def __del__(self) -> None: + self.close() + + def close(self) -> None: + # Close any files that the reader opened (but not those given by user) + for attribute in self._files_to_close: + if hasattr(attribute, "close"): + try: + attribute.close() + except OSError: + pass + self._files_to_close = [] + + def __getFileObj(self, f: T | None) -> T: + """Checks to see if the requested shapefile file object is + available. If not a ShapefileException is raised.""" + if not f: + raise ShapefileException( + "Shapefile Reader requires a shapefile or file-like object." + ) + if self.shp and self.shpLength is None: + self.load() + if self.dbf and len(self.fields) == 0: + self.load() + return f + + def __restrictIndex(self, i: int) -> int: + """Provides list-like handling of a record index with a clearer + error message if the index is out of bounds.""" + if self.numRecords: + rmax = self.numRecords - 1 + if abs(i) > rmax: + raise IndexError( + f"Shape or Record index: {i} out of range. Max index: {rmax}" + ) + if i < 0: + i = range(self.numRecords)[i] + return i + + def __shpHeader(self) -> None: + """Reads the header information from a .shp file.""" + if not self.shp: + raise ShapefileException( + "Shapefile Reader requires a shapefile or file-like object. (no shp file found" + ) + + shp = self.shp + # File length (16-bit word * 2 = bytes) + shp.seek(24) + self.shpLength = unpack(">i", shp.read(4))[0] * 2 + # Shape type + shp.seek(32) + self.shapeType = unpack("= NODATA else None + for m_bound in unpack("<2d", shp.read(16)) + ] + # self.mbox = MBox(mmin=m_bounds[0], mmax=m_bounds[1]) + self.mbox: tuple[float | None, float | None] = (m_bounds[0], m_bounds[1]) + + def __shape(self, oid: int | None = None, bbox: BBox | None = None) -> Shape | None: + """Returns the header info and geometry for a single shape.""" + + f = self.__getFileObj(self.shp) + + # shape = Shape(oid=oid) + (__recNum, recLength) = unpack_2_int32_be(f.read(8)) + # Determine the start of the next record + + # Convert from num of 16 bit words, to 8 bit bytes + recLength_bytes = 2 * recLength + + # next_shape = f.tell() + recLength_bytes + + # Read entire record into memory to avoid having to call + # seek on the file afterwards + b_io: ReadSeekableBinStream = io.BytesIO(f.read(recLength_bytes)) + b_io.seek(0) + + shapeType = unpack(" None: + """Reads the header information from a .shx file.""" + shx = self.shx + if not shx: + raise ShapefileException( + "Shapefile Reader requires a shapefile or file-like object. (no shx file found" + ) + # File length (16-bit word * 2 = bytes) - header length + shx.seek(24) + shxRecordLength = (unpack(">i", shx.read(4))[0] * 2) - 100 + self.numShapes = shxRecordLength // 8 + + def __shxOffsets(self) -> None: + """Reads the shape offset positions from a .shx file""" + shx = self.shx + if not shx: + raise ShapefileException( + "Shapefile Reader requires a shapefile or file-like object. (no shx file found" + ) + if self.numShapes is None: + raise ShapefileException( + "numShapes must not be None. " + " Was there a problem with .__shxHeader() ?" + f"Got: {self.numShapes=}" + ) + # Jump to the first record. + shx.seek(100) + # Each index record consists of two nrs, we only want the first one + shxRecords = _Array[int]("i", shx.read(2 * self.numShapes * 4)) + if sys.byteorder != "big": + shxRecords.byteswap() + self._offsets = [2 * el for el in shxRecords[::2]] + + def __shapeIndex(self, i: int | None = None) -> int | None: + """Returns the offset in a .shp file for a shape based on information + in the .shx index file.""" + shx = self.shx + # Return None if no shx or no index requested + if not shx or i is None: + return None + # At this point, we know the shx file exists + if not self._offsets: + self.__shxOffsets() + return self._offsets[i] + + def shape(self, i: int = 0, bbox: BBox | None = None) -> Shape | None: + """Returns a shape object for a shape in the geometry + record file. + If the 'bbox' arg is given (list or tuple of xmin,ymin,xmax,ymax), + returns None if the shape is not within that region. + """ + shp = self.__getFileObj(self.shp) + i = self.__restrictIndex(i) + offset = self.__shapeIndex(i) + if not offset: + # Shx index not available. + # Determine length of shp file + shp.seek(0, 2) + shpLength = shp.tell() + shp.seek(100) + # Do a fast shape iteration until the requested index or end of file. + _i = 0 + offset = shp.tell() + while offset < shpLength: + if _i == i: + # Reached the requested index, exit loop with the offset value + break + # Unpack the shape header only + (__recNum, recLength) = unpack_2_int32_be(shp.read(8)) + # Jump to next shape position + offset += 8 + (2 * recLength) + shp.seek(offset) + _i += 1 + # If the index was not found, it likely means the .shp file is incomplete + if _i != i: + raise ShapefileException( + f"Shape index {i} is out of bounds; the .shp file only contains {_i} shapes" + ) + + # Seek to the offset and read the shape + shp.seek(offset) + return self.__shape(oid=i, bbox=bbox) + + def shapes(self, bbox: BBox | None = None) -> Shapes: + """Returns all shapes in a shapefile. + To only read shapes within a given spatial region, specify the 'bbox' + arg as a list or tuple of xmin,ymin,xmax,ymax. + """ + shapes = Shapes() + shapes.extend(self.iterShapes(bbox=bbox)) + return shapes + + def iterShapes(self, bbox: BBox | None = None) -> Iterator[Shape | None]: + """Returns a generator of shapes in a shapefile. Useful + for handling large shapefiles. + To only read shapes within a given spatial region, specify the 'bbox' + arg as a list or tuple of xmin,ymin,xmax,ymax. + """ + shp = self.__getFileObj(self.shp) + # Found shapefiles which report incorrect + # shp file length in the header. Can't trust + # that so we seek to the end of the file + # and figure it out. + shp.seek(0, 2) + shpLength = shp.tell() + shp.seek(100) + + if self.numShapes: + # Iterate exactly the number of shapes from shx header + for i in range(self.numShapes): + # MAYBE: check if more left of file or exit early? + shape = self.__shape(oid=i, bbox=bbox) + if shape: + yield shape + else: + # No shx file, unknown nr of shapes + # Instead iterate until reach end of file + # Collect the offset indices during iteration + i = 0 + offsets = [] + pos = shp.tell() + while pos < shpLength: + offsets.append(pos) + shape = self.__shape(oid=i, bbox=bbox) + pos = shp.tell() + if shape: + yield shape + i += 1 + # Entire shp file consumed + # Update the number of shapes and list of offsets + assert i == len(offsets) + self.numShapes = i + self._offsets = offsets + + def __dbfHeader(self) -> None: + """Reads a dbf header. Xbase-related code borrows heavily from ActiveState Python Cookbook Recipe 362715 by Raymond Hettinger""" + + if not self.dbf: + raise ShapefileException( + "Shapefile Reader requires a shapefile or file-like object. (no dbf file found)" + ) + dbf = self.dbf + # read relevant header parts + dbf.seek(0) + self.numRecords, self.__dbfHdrLength, self.__recordLength = unpack( + " tuple[str, int]: + """Calculates the format and size of a .dbf record. Optional 'fields' arg + specifies which fieldnames to unpack and which to ignore. Note that this + always includes the DeletionFlag at index 0, regardless of the 'fields' arg. + """ + if self.numRecords is None: + self.__dbfHeader() + structcodes = [f"{fieldinfo.size}s" for fieldinfo in self.fields] + if fields is not None: + # only unpack specified fields, ignore others using padbytes (x) + structcodes = [ + code + if fieldinfo.name in fields + or fieldinfo.name == "DeletionFlag" # always unpack delflag + else f"{fieldinfo.size}x" + for fieldinfo, code in zip(self.fields, structcodes) + ] + fmt = "".join(structcodes) + fmtSize = calcsize(fmt) + # total size of fields should add up to recordlength from the header + while fmtSize < self.__recordLength: + # if not, pad byte until reaches recordlength + fmt += "x" + fmtSize += 1 + return (fmt, fmtSize) + + def __recordFields( + self, fields: Iterable[str] | None = None + ) -> tuple[list[Field], dict[str, int], Struct]: + """Returns the necessary info required to unpack a record's fields, + restricted to a subset of fieldnames 'fields' if specified. + Returns a list of field info tuples, a name-index lookup dict, + and a Struct instance for unpacking these fields. Note that DeletionFlag + is not a valid field. + """ + if fields is not None: + # restrict info to the specified fields + # first ignore repeated field names (order doesn't matter) + unique_fields = list(set(fields)) + # get the struct + fmt, __fmtSize = self.__recordFmt(fields=unique_fields) + recStruct = Struct(fmt) + # make sure the given fieldnames exist + for name in unique_fields: + if name not in self.__fieldLookup or name == "DeletionFlag": + raise ValueError(f'"{name}" is not a valid field name') + # fetch relevant field info tuples + fieldTuples = [] + for fieldinfo in self.fields[1:]: + name = fieldinfo[0] + if name in unique_fields: + fieldTuples.append(fieldinfo) + # store the field positions + recLookup = {f[0]: i for i, f in enumerate(fieldTuples)} + else: + # use all the dbf fields + fieldTuples = self.fields[1:] # sans deletion flag + recStruct = self.__fullRecStruct + recLookup = self.__fullRecLookup + return fieldTuples, recLookup, recStruct + + def __record( + self, + fieldTuples: list[Field], + recLookup: dict[str, int], + recStruct: Struct, + oid: int | None = None, + ) -> _Record | None: + """Reads and returns a dbf record row as a list of values. Requires specifying + a list of field info Field namedtuples 'fieldTuples', a record name-index dict 'recLookup', + and a Struct instance 'recStruct' for unpacking these fields. + """ + f = self.__getFileObj(self.dbf) + + # The only format chars in from self.__recordFmt, in recStruct from __recordFields, + # are s and x (ascii encoded str and pad byte) so everything in recordContents is bytes + # https://docs.python.org/3/library/struct.html#format-characters + recordContents = recStruct.unpack(f.read(recStruct.size)) + + # deletion flag field is always unpacked as first value (see __recordFmt) + if recordContents[0] != b" ": + # deleted record + return None + + # drop deletion flag from values + recordContents = recordContents[1:] + + # check that values match fields + if len(fieldTuples) != len(recordContents): + raise ShapefileException( + f"Number of record values ({len(recordContents)}) is different from the requested " + f"number of fields ({len(fieldTuples)})" + ) + + # parse each value + record = [] + for (__name, typ, __size, decimal), value in zip(fieldTuples, recordContents): + if typ is FieldType.N or typ is FieldType.F: + # numeric or float: number stored as a string, right justified, and padded with blanks to the width of the field. + value = value.split(b"\0")[0] + value = value.replace(b"*", b"") # QGIS NULL is all '*' chars + if value == b"": + value = None + elif decimal: + try: + value = float(value) + except ValueError: + # not parseable as float, set to None + value = None + else: + # force to int + try: + # first try to force directly to int. + # forcing a large int to float and back to int + # will lose information and result in wrong nr. + value = int(value) + except ValueError: + # forcing directly to int failed, so was probably a float. + try: + value = int(float(value)) + except ValueError: + # not parseable as int, set to None + value = None + elif typ is FieldType.D: + # date: 8 bytes - date stored as a string in the format YYYYMMDD. + if ( + not value.replace(b"\x00", b"") + .replace(b" ", b"") + .replace(b"0", b"") + ): + # dbf date field has no official null value + # but can check for all hex null-chars, all spaces, or all 0s (QGIS null) + value = None + else: + try: + # return as python date object + y, m, d = int(value[:4]), int(value[4:6]), int(value[6:8]) + value = date(y, m, d) + except (TypeError, ValueError): + # if invalid date, just return as unicode string so user can decimalde + value = str(value.strip()) + elif typ is FieldType.L: + # logical: 1 byte - initialized to 0x20 (space) otherwise T or F. + if value == b" ": + value = None # space means missing or not yet set + else: + if value in b"YyTt1": + value = True + elif value in b"NnFf0": + value = False + else: + value = None # unknown value is set to missing + else: + value = value.decode(self.encoding, self.encodingErrors) + value = value.strip().rstrip( + "\x00" + ) # remove null-padding at end of strings + record.append(value) + + return _Record(recLookup, record, oid) + + def record(self, i: int = 0, fields: list[str] | None = None) -> _Record | None: + """Returns a specific dbf record based on the supplied index. + To only read some of the fields, specify the 'fields' arg as a + list of one or more fieldnames. + """ + f = self.__getFileObj(self.dbf) + if self.numRecords is None: + self.__dbfHeader() + i = self.__restrictIndex(i) + recSize = self.__recordLength + f.seek(0) + f.seek(self.__dbfHdrLength + (i * recSize)) + fieldTuples, recLookup, recStruct = self.__recordFields(fields) + return self.__record( + oid=i, fieldTuples=fieldTuples, recLookup=recLookup, recStruct=recStruct + ) + + def records(self, fields: list[str] | None = None) -> list[_Record]: + """Returns all records in a dbf file. + To only read some of the fields, specify the 'fields' arg as a + list of one or more fieldnames. + """ + if self.numRecords is None: + self.__dbfHeader() + records = [] + f = self.__getFileObj(self.dbf) + f.seek(self.__dbfHdrLength) + fieldTuples, recLookup, recStruct = self.__recordFields(fields) + # self.__dbfHeader() sets self.numRecords, so it's fine to cast it to int + # (to tell mypy it's not None). + for i in range(cast(int, self.numRecords)): + r = self.__record( + oid=i, fieldTuples=fieldTuples, recLookup=recLookup, recStruct=recStruct + ) + if r: + records.append(r) + return records + + def iterRecords( + self, + fields: list[str] | None = None, + start: int = 0, + stop: int | None = None, + ) -> Iterator[_Record | None]: + """Returns a generator of records in a dbf file. + Useful for large shapefiles or dbf files. + To only read some of the fields, specify the 'fields' arg as a + list of one or more fieldnames. + By default yields all records. Otherwise, specify start + (default: 0) or stop (default: number_of_records) + to only yield record numbers i, where + start <= i < stop, (or + start <= i < number_of_records + stop + if stop < 0). + """ + if self.numRecords is None: + self.__dbfHeader() + if not isinstance(self.numRecords, int): + raise ShapefileException( + "Error when reading number of Records in dbf file header" + ) + f = self.__getFileObj(self.dbf) + start = self.__restrictIndex(start) + if stop is None: + stop = self.numRecords + elif abs(stop) > self.numRecords: + raise IndexError( + f"abs(stop): {abs(stop)} exceeds number of records: {self.numRecords}." + ) + elif stop < 0: + stop = range(self.numRecords)[stop] + recSize = self.__recordLength + f.seek(self.__dbfHdrLength + (start * recSize)) + fieldTuples, recLookup, recStruct = self.__recordFields(fields) + for i in range(start, stop): + r = self.__record( + oid=i, fieldTuples=fieldTuples, recLookup=recLookup, recStruct=recStruct + ) + if r: + yield r + + def shapeRecord( + self, + i: int = 0, + fields: list[str] | None = None, + bbox: BBox | None = None, + ) -> ShapeRecord | None: + """Returns a combination geometry and attribute record for the + supplied record index. + To only read some of the fields, specify the 'fields' arg as a + list of one or more fieldnames. + If the 'bbox' arg is given (list or tuple of xmin,ymin,xmax,ymax), + returns None if the shape is not within that region. + """ + i = self.__restrictIndex(i) + shape = self.shape(i, bbox=bbox) + if shape: + record = self.record(i, fields=fields) + return ShapeRecord(shape=shape, record=record) + return None + + def shapeRecords( + self, + fields: list[str] | None = None, + bbox: BBox | None = None, + ) -> ShapeRecords: + """Returns a list of combination geometry/attribute records for + all records in a shapefile. + To only read some of the fields, specify the 'fields' arg as a + list of one or more fieldnames. + To only read entries within a given spatial region, specify the 'bbox' + arg as a list or tuple of xmin,ymin,xmax,ymax. + """ + return ShapeRecords(self.iterShapeRecords(fields=fields, bbox=bbox)) + + def iterShapeRecords( + self, + fields: list[str] | None = None, + bbox: BBox | None = None, + ) -> Iterator[ShapeRecord]: + """Returns a generator of combination geometry/attribute records for + all records in a shapefile. + To only read some of the fields, specify the 'fields' arg as a + list of one or more fieldnames. + To only read entries within a given spatial region, specify the 'bbox' + arg as a list or tuple of xmin,ymin,xmax,ymax. + """ + if bbox is None: + # iterate through all shapes and records + for shape, record in zip( + self.iterShapes(), self.iterRecords(fields=fields) + ): + yield ShapeRecord(shape=shape, record=record) + else: + # only iterate where shape.bbox overlaps with the given bbox + # TODO: internal __record method should be faster but would have to + # make sure to seek to correct file location... + + # fieldTuples,recLookup,recStruct = self.__recordFields(fields) + for shape in self.iterShapes(bbox=bbox): + if shape: + # record = self.__record(oid=i, fieldTuples=fieldTuples, recLookup=recLookup, recStruct=recStruct) + record = self.record(i=shape.oid, fields=fields) + yield ShapeRecord(shape=shape, record=record) + + +class Writer: + """Provides write support for ESRI Shapefiles.""" + + W = TypeVar("W", bound=WriteSeekableBinStream) + + def __init__( + self, + target: str | PathLike[Any] | None = None, + shapeType: int | None = None, + autoBalance: bool = False, + *, + encoding: str = "utf-8", + encodingErrors: str = "strict", + shp: WriteSeekableBinStream | None = None, + shx: WriteSeekableBinStream | None = None, + dbf: WriteSeekableBinStream | None = None, + # Keep kwargs even though unused, to preserve PyShp 2.4 API + **kwargs: Any, + ): + self.target = target + self.autoBalance = autoBalance + self.fields: list[Field] = [] + self.shapeType = shapeType + self.shp: WriteSeekableBinStream | None = None + self.shx: WriteSeekableBinStream | None = None + self.dbf: WriteSeekableBinStream | None = None + self._files_to_close: list[BinaryFileStreamT] = [] + if target: + target = fsdecode_if_pathlike(target) + if not isinstance(target, str): + raise TypeError( + f"The target filepath {target!r} must be of type str/unicode or path-like, not {type(target)}." + ) + self.shp = self.__getFileObj(os.path.splitext(target)[0] + ".shp") + self.shx = self.__getFileObj(os.path.splitext(target)[0] + ".shx") + self.dbf = self.__getFileObj(os.path.splitext(target)[0] + ".dbf") + elif shp or shx or dbf: + if shp: + self.shp = self.__getFileObj(shp) + if shx: + self.shx = self.__getFileObj(shx) + if dbf: + self.dbf = self.__getFileObj(dbf) + else: + raise TypeError( + "Either the target filepath, or any of shp, shx, or dbf must be set to create a shapefile." + ) + # Initiate with empty headers, to be finalized upon closing + if self.shp: + self.shp.write(b"9" * 100) + if self.shx: + self.shx.write(b"9" * 100) + # Geometry record offsets and lengths for writing shx file. + self.recNum = 0 + self.shpNum = 0 + self._bbox: BBox | None = None + self._zbox: ZBox | None = None + self._mbox: MBox | None = None + # Use deletion flags in dbf? Default is false (0). Note: Currently has no effect, records should NOT contain deletion flags. + self.deletionFlag = 0 + # Encoding + self.encoding = encoding + self.encodingErrors = encodingErrors + + def __len__(self) -> int: + """Returns the current number of features written to the shapefile. + If shapes and records are unbalanced, the length is considered the highest + of the two.""" + return max(self.recNum, self.shpNum) + + def __enter__(self) -> Writer: + """ + Enter phase of context manager. + """ + return self + + def __exit__( + self, + exc_type: BaseException | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: + """ + Exit phase of context manager, finish writing and close the files. + """ + self.close() + return None + + def __del__(self) -> None: + self.close() + + def close(self) -> None: + """ + Write final shp, shx, and dbf headers, close opened files. + """ + # Check if any of the files have already been closed + shp_open = self.shp and not (hasattr(self.shp, "closed") and self.shp.closed) + shx_open = self.shx and not (hasattr(self.shx, "closed") and self.shx.closed) + dbf_open = self.dbf and not (hasattr(self.dbf, "closed") and self.dbf.closed) + + # Balance if already not balanced + if self.shp and shp_open and self.dbf and dbf_open: + if self.autoBalance: + self.balance() + if self.recNum != self.shpNum: + raise ShapefileException( + "When saving both the dbf and shp file, " + f"the number of records ({self.recNum}) must correspond " + f"with the number of shapes ({self.shpNum})" + ) + # Fill in the blank headers + if self.shp and shp_open: + self.__shapefileHeader(self.shp, headerType="shp") + if self.shx and shx_open: + self.__shapefileHeader(self.shx, headerType="shx") + + # Update the dbf header with final length etc + if self.dbf and dbf_open: + self.__dbfHeader() + + # Flush files + for attribute in (self.shp, self.shx, self.dbf): + if attribute is None: + continue + if hasattr(attribute, "flush") and not getattr(attribute, "closed", False): + try: + attribute.flush() + except OSError: + pass + + # Close any files that the writer opened (but not those given by user) + for attribute in self._files_to_close: + if hasattr(attribute, "close"): + try: + attribute.close() + except OSError: + pass + self._files_to_close = [] + + @overload + def __getFileObj(self, f: str) -> WriteSeekableBinStream: ... + @overload + def __getFileObj(self, f: None) -> NoReturn: ... + @overload + def __getFileObj(self, f: WriteSeekableBinStream) -> WriteSeekableBinStream: ... + def __getFileObj( + self, f: str | None | WriteSeekableBinStream + ) -> WriteSeekableBinStream: + """Safety handler to verify file-like objects""" + if not f: + raise ShapefileException("No file-like object available.") + if isinstance(f, str): + pth = os.path.split(f)[0] + if pth and not os.path.exists(pth): + os.makedirs(pth) + fp = open(f, "wb+") + self._files_to_close.append(fp) + return fp + + if hasattr(f, "write"): + return f + raise ShapefileException(f"Unsupported file-like object: {f}") + + def __shpFileLength(self) -> int: + """Calculates the file length of the shp file.""" + shp = self.__getFileObj(self.shp) + + # Remember starting position + + start = shp.tell() + # Calculate size of all shapes + shp.seek(0, 2) + size = shp.tell() + # Calculate size as 16-bit words + size //= 2 + # Return to start + shp.seek(start) + return size + + def _update_file_bbox(self, s: Shape) -> None: + if s.shapeType == NULL: + shape_bbox = None + elif s.shapeType in _CanHaveBBox_shapeTypes: + shape_bbox = s.bbox + else: + x, y = s.points[0][:2] + shape_bbox = (x, y, x, y) + + if shape_bbox is None: + return None + + if self._bbox: + # compare with existing + self._bbox = ( + min(shape_bbox[0], self._bbox[0]), + min(shape_bbox[1], self._bbox[1]), + max(shape_bbox[2], self._bbox[2]), + max(shape_bbox[3], self._bbox[3]), + ) + else: + # first time bbox is being set + self._bbox = shape_bbox + return None + + def _update_file_zbox(self, s: _HasZ | PointZ) -> None: + if self._zbox: + # compare with existing + self._zbox = (min(s.zbox[0], self._zbox[0]), max(s.zbox[1], self._zbox[1])) + else: + # first time zbox is being set + self._zbox = s.zbox + + def _update_file_mbox(self, s: _HasM | PointM) -> None: + mbox = s.mbox + if self._mbox: + # compare with existing + self._mbox = (min(mbox[0], self._mbox[0]), max(mbox[1], self._mbox[1])) + else: + # first time mbox is being set + self._mbox = mbox + + @property + def shapeTypeName(self) -> str: + return SHAPETYPE_LOOKUP[self.shapeType or 0] + + def bbox(self) -> BBox | None: + """Returns the current bounding box for the shapefile which is + the lower-left and upper-right corners. It does not contain the + elevation or measure extremes.""" + return self._bbox + + def zbox(self) -> ZBox | None: + """Returns the current z extremes for the shapefile.""" + return self._zbox + + def mbox(self) -> MBox | None: + """Returns the current m extremes for the shapefile.""" + return self._mbox + + def __shapefileHeader( + self, + fileObj: WriteSeekableBinStream | None, + headerType: Literal["shp", "dbf", "shx"] = "shp", + ) -> None: + """Writes the specified header type to the specified file-like object. + Several of the shapefile formats are so similar that a single generic + method to read or write them is warranted.""" + + f = self.__getFileObj(fileObj) + f.seek(0) + # File code, Unused bytes + f.write(pack(">6i", 9994, 0, 0, 0, 0, 0)) + # File length (Bytes / 2 = 16-bit words) + if headerType == "shp": + f.write(pack(">i", self.__shpFileLength())) + elif headerType == "shx": + f.write(pack(">i", ((100 + (self.shpNum * 8)) // 2))) + # Version, Shape type + if self.shapeType is None: + self.shapeType = NULL + f.write(pack("<2i", 1000, self.shapeType)) + # The shapefile's bounding box (lower left, upper right) + if self.shapeType != 0: + try: + bbox = self.bbox() + if bbox is None: + # The bbox is initialized with None, so this would mean the shapefile contains no valid geometries. + # In such cases of empty shapefiles, ESRI spec says the bbox values are 'unspecified'. + # Not sure what that means, so for now just setting to 0s, which is the same behavior as in previous versions. + # This would also make sense since the Z and M bounds are similarly set to 0 for non-Z/M type shapefiles. + # bbox = BBox(0, 0, 0, 0) + bbox = (0, 0, 0, 0) + f.write(pack("<4d", *bbox)) + except error: + raise ShapefileException( + "Failed to write shapefile bounding box. Floats required." + ) + else: + f.write(pack("<4d", 0, 0, 0, 0)) + # Elevation + if self.shapeType in PointZ_shapeTypes | _HasZ_shapeTypes: + # Z values are present in Z type + zbox = self.zbox() + if zbox is None: + # means we have empty shapefile/only null geoms (see commentary on bbox above) + # zbox = ZBox(0, 0) + zbox = (0, 0) + else: + # As per the ESRI shapefile spec, the zbox for non-Z type shapefiles are set to 0s + # zbox = ZBox(0, 0) + zbox = (0, 0) + # Measure + if self.shapeType in PointM_shapeTypes | _HasM_shapeTypes: + # M values are present in M or Z type + mbox = self.mbox() + if mbox is None: + # means we have empty shapefile/only null geoms (see commentary on bbox above) + # mbox = MBox(0, 0) + mbox = (0, 0) + else: + # As per the ESRI shapefile spec, the mbox for non-M type shapefiles are set to 0s + # mbox = MBox(0, 0) + mbox = (0, 0) + # Try writing + try: + f.write(pack("<4d", zbox[0], zbox[1], mbox[0], mbox[1])) + except error: + raise ShapefileException( + "Failed to write shapefile elevation and measure values. Floats required." + ) + + def __dbfHeader(self) -> None: + """Writes the dbf header and field descriptors.""" + f = self.__getFileObj(self.dbf) + f.seek(0) + version = 3 + year, month, day = time.localtime()[:3] + year -= 1900 + # Get all fields, ignoring DeletionFlag if specified + fields = [field for field in self.fields if field[0] != "DeletionFlag"] + # Ensure has at least one field + if not fields: + raise ShapefileException( + "Shapefile dbf file must contain at least one field." + ) + numRecs = self.recNum + numFields = len(fields) + headerLength = numFields * 32 + 33 + if headerLength >= 65535: + raise ShapefileException( + "Shapefile dbf header length exceeds maximum length." + ) + recordLength = sum(field.size for field in fields) + 1 + header = pack( + " None: + # Balance if already not balanced + if self.autoBalance and self.recNum < self.shpNum: + self.balance() + # Check is shape or import from geojson + if not isinstance(s, Shape): + if hasattr(s, "__geo_interface__"): + s = cast(HasGeoInterface, s) + shape_dict = s.__geo_interface__ + elif isinstance(s, dict): # TypedDict is a dict at runtime + shape_dict = s + else: + raise TypeError( + "Can only write Shape objects, GeoJSON dictionaries, " + "or objects with the __geo_interface__, " + f"not: {s}" + ) + s = Shape._from_geojson(shape_dict) + # Write to file + offset, length = self.__shpRecord(s) + if self.shx: + self.__shxRecord(offset, length) + + def __shpRecord(self, s: Shape) -> tuple[int, int]: + f: WriteSeekableBinStream = self.__getFileObj(self.shp) + offset = f.tell() + self.shpNum += 1 + + # Shape Type + if self.shapeType is None and s.shapeType != NULL: + self.shapeType = s.shapeType + if s.shapeType not in (NULL, self.shapeType): + raise ShapefileException( + f"The shape's type ({s.shapeType}) must match " + f"the type of the shapefile ({self.shapeType})." + ) + + # For both single point and multiple-points non-null shapes, + # update bbox, mbox and zbox of the whole shapefile + if s.shapeType != NULL: + self._update_file_bbox(s) + + if s.shapeType in PointM_shapeTypes | _HasM_shapeTypes: + self._update_file_mbox(cast(Union[_HasM, PointM], s)) + + if s.shapeType in PointZ_shapeTypes | _HasZ_shapeTypes: + self._update_file_zbox(cast(Union[_HasZ, PointZ], s)) + + # Create an in-memory binary buffer to avoid + # unnecessary seeks to files on disk + # (other ops are already buffered until .seek + # or .flush is called if not using RawIOBase). + # https://docs.python.org/3/library/io.html#id2 + # https://docs.python.org/3/library/io.html#io.BufferedWriter + b_io: ReadWriteSeekableBinStream = io.BytesIO() + + # Record number, Content length place holder + b_io.write(pack(">2i", self.shpNum, -1)) + + # Track number of content bytes written, excluding + # self.shpNum and length (t.b.c.) + n = 0 + + n += b_io.write(pack("i", length)) + + # Flush to file. + b_io.seek(0) + f.write(b_io.read()) + return offset, length + + def __shxRecord(self, offset: int, length: int) -> None: + """Writes the shx records.""" + + f = self.__getFileObj(self.shx) + try: + f.write(pack(">i", offset // 2)) + except error: + raise ShapefileException( + "The .shp file has reached its file size limit > 4294967294 bytes (4.29 GB). To fix this, break up your file into multiple smaller ones." + ) + f.write(pack(">i", length)) + + def record( + self, + *recordList: RecordValue, + **recordDict: RecordValue, + ) -> None: + """Creates a dbf attribute record. You can submit either a sequence of + field values or keyword arguments of field names and values. Before + adding records you must add fields for the record values using the + field() method. If the record values exceed the number of fields the + extra ones won't be added. In the case of using keyword arguments to specify + field/value pairs only fields matching the already registered fields + will be added.""" + # Balance if already not balanced + if self.autoBalance and self.recNum > self.shpNum: + self.balance() + record: list[RecordValue] + fieldCount = sum(1 for field in self.fields if field[0] != "DeletionFlag") + if recordList: + record = list(recordList) + while len(record) < fieldCount: + record.append("") + elif recordDict: + record = [] + for field in self.fields: + if field[0] == "DeletionFlag": + continue # ignore deletionflag field in case it was specified + if field[0] in recordDict: + val = recordDict[field[0]] + if val is None: + record.append("") + else: + record.append(val) + else: + record.append("") # need empty value for missing dict entries + else: + # Blank fields for empty record + record = ["" for _ in range(fieldCount)] + self.__dbfRecord(record) + + def __dbfRecord(self, record: list[RecordValue]) -> None: + """Writes the dbf records.""" + f = self.__getFileObj(self.dbf) + if self.recNum == 0: + # first records, so all fields should be set + # allowing us to write the dbf header + # cannot change the fields after this point + self.__dbfHeader() + # first byte of the record is deletion flag, always disabled + f.write(b" ") + # begin + self.recNum += 1 + fields = ( + field for field in self.fields if field[0] != "DeletionFlag" + ) # ignore deletionflag field in case it was specified + for (fieldName, fieldType, size, deci), value in zip(fields, record): + # write + # fieldName, fieldType, size and deci were already checked + # when their Field instance was created and added to self.fields + str_val: str | None = None + + if fieldType in ("N", "F"): + # numeric or float: number stored as a string, right justified, and padded with blanks to the width of the field. + if value in MISSING: + str_val = "*" * size # QGIS NULL + elif not deci: + # force to int + try: + # first try to force directly to int. + # forcing a large int to float and back to int + # will lose information and result in wrong nr. + num_val = int(cast(int, value)) + except ValueError: + # forcing directly to int failed, so was probably a float. + num_val = int(float(cast(float, value))) + str_val = format(num_val, "d")[:size].rjust( + size + ) # caps the size if exceeds the field size + else: + f_val = float(cast(float, value)) + str_val = format(f_val, f".{deci}f")[:size].rjust( + size + ) # caps the size if exceeds the field size + elif fieldType == "D": + # date: 8 bytes - date stored as a string in the format YYYYMMDD. + if isinstance(value, date): + str_val = f"{value.year:04d}{value.month:02d}{value.day:02d}" + elif isinstance(value, list) and len(value) == 3: + str_val = f"{value[0]:04d}{value[1]:02d}{value[2]:02d}" + elif value in MISSING: + str_val = "0" * 8 # QGIS NULL for date type + elif isinstance(value, str) and len(value) == 8: + pass # value is already a date string + else: + raise ShapefileException( + "Date values must be either a datetime.date object, a list, a YYYYMMDD string, or a missing value." + ) + elif fieldType == "L": + # logical: 1 byte - initialized to 0x20 (space) otherwise T or F. + if value in MISSING: + str_val = " " # missing is set to space + elif value in [True, 1]: + str_val = "T" + elif value in [False, 0]: + str_val = "F" + else: + str_val = " " # unknown is set to space + + if str_val is None: + # Types C and M, and anything else, value is forced to string, + # encoded by the codec specified to the Writer (utf-8 by default), + # then the resulting bytes are padded and truncated to the length + # of the field + encoded = ( + str(value) + .encode(self.encoding, self.encodingErrors)[:size] + .ljust(size) + ) + else: + # str_val was given a not-None string value + # under the checks for fieldTypes "N", "F", "D", or "L" above + # Numeric, logical, and date numeric types are ascii already, but + # for Shapefile or dbf spec reasons + # "should be default ascii encoding" + encoded = str_val.encode("ascii", self.encodingErrors) + + if len(encoded) != size: + raise ShapefileException( + f"Shapefile Writer unable to pack incorrect sized {value=}" + f" (encoded as {len(encoded)}B) into field '{fieldName}' ({size}B)." + ) + f.write(encoded) + + def balance(self) -> None: + """Adds corresponding empty attributes or null geometry records depending + on which type of record was created to make sure all three files + are in synch.""" + while self.recNum > self.shpNum: + self.null() + while self.recNum < self.shpNum: + self.record() + + def null(self) -> None: + """Creates a null shape.""" + self.shape(NullShape()) + + def point(self, x: float, y: float) -> None: + """Creates a POINT shape.""" + pointShape = Point(x, y) + self.shape(pointShape) + + def pointm(self, x: float, y: float, m: float | None = None) -> None: + """Creates a POINTM shape. + If the m (measure) value is not set, it defaults to NoData.""" + pointShape = PointM(x, y, m) + self.shape(pointShape) + + def pointz( + self, x: float, y: float, z: float = 0.0, m: float | None = None + ) -> None: + """Creates a POINTZ shape. + If the z (elevation) value is not set, it defaults to 0. + If the m (measure) value is not set, it defaults to NoData.""" + pointShape = PointZ(x, y, z, m) + self.shape(pointShape) + + def multipoint(self, points: PointsT) -> None: + """Creates a MULTIPOINT shape. + Points is a list of xy values.""" + # nest the points inside a list to be compatible with the generic shapeparts method + shape = MultiPoint(points=points) + self.shape(shape) + + def multipointm(self, points: PointsT) -> None: + """Creates a MULTIPOINTM shape. + Points is a list of xym values. + If the m (measure) value is not included, it defaults to None (NoData).""" + # nest the points inside a list to be compatible with the generic shapeparts method + shape = MultiPointM(points=points) + self.shape(shape) + + def multipointz(self, points: PointsT) -> None: + """Creates a MULTIPOINTZ shape. + Points is a list of xyzm values. + If the z (elevation) value is not included, it defaults to 0. + If the m (measure) value is not included, it defaults to None (NoData).""" + # nest the points inside a list to be compatible with the generic shapeparts method + shape = MultiPointZ(points=points) + self.shape(shape) + + def line(self, lines: list[PointsT]) -> None: + """Creates a POLYLINE shape. + Lines is a collection of lines, each made up of a list of xy values.""" + shape = Polyline(lines=lines) + self.shape(shape) + + def linem(self, lines: list[PointsT]) -> None: + """Creates a POLYLINEM shape. + Lines is a collection of lines, each made up of a list of xym values. + If the m (measure) value is not included, it defaults to None (NoData).""" + shape = PolylineM(lines=lines) + self.shape(shape) + + def linez(self, lines: list[PointsT]) -> None: + """Creates a POLYLINEZ shape. + Lines is a collection of lines, each made up of a list of xyzm values. + If the z (elevation) value is not included, it defaults to 0. + If the m (measure) value is not included, it defaults to None (NoData).""" + shape = PolylineZ(lines=lines) + self.shape(shape) + + def poly(self, polys: list[PointsT]) -> None: + """Creates a POLYGON shape. + Polys is a collection of polygons, each made up of a list of xy values. + Note that for ordinary polygons the coordinates must run in a clockwise direction. + If some of the polygons are holes, these must run in a counterclockwise direction.""" + shape = Polygon(lines=polys) + self.shape(shape) + + def polym(self, polys: list[PointsT]) -> None: + """Creates a POLYGONM shape. + Polys is a collection of polygons, each made up of a list of xym values. + Note that for ordinary polygons the coordinates must run in a clockwise direction. + If some of the polygons are holes, these must run in a counterclockwise direction. + If the m (measure) value is not included, it defaults to None (NoData).""" + shape = PolygonM(lines=polys) + self.shape(shape) + + def polyz(self, polys: list[PointsT]) -> None: + """Creates a POLYGONZ shape. + Polys is a collection of polygons, each made up of a list of xyzm values. + Note that for ordinary polygons the coordinates must run in a clockwise direction. + If some of the polygons are holes, these must run in a counterclockwise direction. + If the z (elevation) value is not included, it defaults to 0. + If the m (measure) value is not included, it defaults to None (NoData).""" + shape = PolygonZ(lines=polys) + self.shape(shape) + + def multipatch(self, parts: list[PointsT], partTypes: list[int]) -> None: + """Creates a MULTIPATCH shape. + Parts is a collection of 3D surface patches, each made up of a list of xyzm values. + PartTypes is a list of types that define each of the surface patches. + The types can be any of the following module constants: TRIANGLE_STRIP, + TRIANGLE_FAN, OUTER_RING, INNER_RING, FIRST_RING, or RING. + If the z (elevation) value is not included, it defaults to 0. + If the m (measure) value is not included, it defaults to None (NoData).""" + shape = MultiPatch(lines=parts, partTypes=partTypes) + self.shape(shape) + + def field( + # Types of args should match *Field + self, + name: str, + field_type: FieldTypeT = "C", + size: int = 50, + decimal: int = 0, + ) -> None: + """Adds a dbf field descriptor to the shapefile.""" + if len(self.fields) >= 2046: + raise ShapefileException( + "Shapefile Writer reached maximum number of fields: 2046." + ) + field_ = Field.from_unchecked(name, field_type, size, decimal) + self.fields.append(field_) + + +# Begin Testing +def _get_doctests() -> doctest.DocTest: + # run tests + with open("README.md", "rb") as fobj: + tests = doctest.DocTestParser().get_doctest( + string=fobj.read().decode("utf8").replace("\r\n", "\n"), + globs={}, + name="README", + filename="README.md", + lineno=0, + ) + + return tests + + +def _filter_network_doctests( + examples: Iterable[doctest.Example], + include_network: bool = False, + include_non_network: bool = True, +) -> Iterator[doctest.Example]: + globals_from_network_doctests = set() + + if not (include_network or include_non_network): + return + + examples_it = iter(examples) + + yield next(examples_it) + + for example in examples_it: + # Track variables in doctest shell sessions defined from commands + # that poll remote URLs, to skip subsequent commands until all + # such dependent variables are reassigned. + + if 'sf = shapefile.Reader("https://' in example.source: + globals_from_network_doctests.add("sf") + if include_network: + yield example + continue + + lhs = example.source.partition("=")[0] + + for target in lhs.split(","): + target = target.strip() + if target in globals_from_network_doctests: + globals_from_network_doctests.remove(target) + + # Non-network tests dependent on the network tests. + if globals_from_network_doctests: + if include_network: + yield example + continue + + if not include_non_network: + continue + + yield example + + +def _replace_remote_url( + old_url: str, + # Default port of Python http.server + port: int = 8000, + scheme: str = "http", + netloc: str = "localhost", + path: str | None = None, + params: str = "", + query: str = "", + fragment: str = "", +) -> str: + old_parsed = urlparse(old_url) + + # Strip subpaths, so an artefacts + # repo or file tree can be simpler and flat + if path is None: + path = old_parsed.path.rpartition("/")[2] + + if port not in (None, ""): # type: ignore[comparison-overlap] + netloc = f"{netloc}:{port}" + + new_parsed = old_parsed._replace( + scheme=scheme, + netloc=netloc, + path=path, + params=params, + query=query, + fragment=fragment, + ) + + new_url = urlunparse(new_parsed) + return new_url + + +def _test(args: list[str] = sys.argv[1:], verbosity: bool = False) -> int: + if verbosity == 0: + print("Getting doctests...") + + import re + + tests = _get_doctests() + + if len(args) >= 2 and args[0] == "-m": + if verbosity == 0: + print("Filtering doctests...") + tests.examples = list( + _filter_network_doctests( + tests.examples, + include_network=args[1] == "network", + include_non_network=args[1] == "not network", + ) + ) + + if REPLACE_REMOTE_URLS_WITH_LOCALHOST: + if verbosity == 0: + print("Replacing remote urls with http://localhost in doctests...") + + for example in tests.examples: + match_url_str_literal = re.search(r'"(https://.*)"', example.source) + if not match_url_str_literal: + continue + old_url = match_url_str_literal.group(1) + new_url = _replace_remote_url(old_url) + example.source = example.source.replace(old_url, new_url) + + runner = doctest.DocTestRunner(verbose=verbosity, optionflags=doctest.FAIL_FAST) + + if verbosity == 0: + print(f"Running {len(tests.examples)} doctests...") + failure_count, __test_count = runner.run(tests) + + # print results + if verbosity: + runner.summarize(True) + else: + if failure_count == 0: + print("All test passed successfully") + elif failure_count > 0: + runner.summarize(verbosity) + + return failure_count + + +def main() -> None: + """ + Doctests are contained in the file 'README.md', and are tested using the built-in + testing libraries. + """ + failure_count = _test() + sys.exit(failure_count) + + +if __name__ == "__main__": + main() diff --git a/test_wabasso.000 b/test_wabasso.000 new file mode 100644 index 0000000..17e14d4 Binary files /dev/null and b/test_wabasso.000 differ