diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-26 09:53:39 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-26 09:53:52 +0100 |
| commit | 84db1ca075dc63ccb02da825948d95ad09f94e4d (patch) | |
| tree | b9678bec669304bb3201a1e40a86bb3828150fac | |
| parent | 291450f2add8ddd6ed8757b2bdbfceb476be3033 (diff) | |
Convert submodules to regular repo files, add custom feeds
39 files changed, 4317 insertions, 29 deletions
@@ -6,9 +6,6 @@ *.obj *.jar -# Feeds -feeds/*.zip - # Python cache files *.pyc diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e0d993f..0000000 --- a/.gitmodules +++ /dev/null @@ -1,13 +0,0 @@ -[submodule "build_renfe"] - path = build_renfe - url = https://github.com/tpgalicia/gtfs-renfe-galicia.git - -[submodule "build_tranvias"] - path = build_tranvias - url = https://github.com/tpgalicia/gtfs-coruna.git -[submodule "build_xunta"] - path = build_xunta - url = https://github.com/tpgalicia/gtfs-xunta.git -[submodule "build_vitrasa"] - path = build_vitrasa - url = https://github.com/tpgalicia/gtfs-vigo.git diff --git a/Taskfile.yml b/Taskfile.yml index 329afee..0f1332f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -3,13 +3,13 @@ $schema: https://taskfile.dev/schema.json version: '3' vars: - OTP_JAR: otp-shaded-2.8.1.jar + OTP_JAR: otp-shaded-2.9.0.jar tasks: setup: desc: "Set up the project dependencies" cmds: - - curl -L -o {{.OTP_JAR}} https://github.com/opentripplanner/OpenTripPlanner/releases/download/v2.8.1/otp-shaded-2.8.1.jar + - curl -L -o {{.OTP_JAR}} https://github.com/opentripplanner/OpenTripPlanner/releases/download/v2.9.0/otp-shaded-2.9.0.jar - curl -sLo galicia-latest.osm.pbf https://download.geofabrik.de/europe/spain/galicia-latest.osm.pbf - uv --directory build_xunta run ./gen_parroquias.py --pbf ../galicia-latest.osm.pbf diff --git a/build-config.json b/build-config.json index ba0b553..f5742bf 100644 --- a/build-config.json +++ b/build-config.json @@ -1,9 +1,9 @@ { "transitFeeds": [ { - "feedId": "xunta", + "feedId": "feve", "type": "gtfs", - "source": "./feeds/xunta.zip" + "source": "./feeds/feve.zip" }, { "feedId": "renfe", @@ -11,24 +11,25 @@ "source": "./feeds/renfe.zip" }, { - "feedId": "feve", + "feedId": "tranvias", "type": "gtfs", - "source": "./feeds/feve.zip" + "source": "./feeds/tranvias.zip" }, { - "feedId": "tussa", + "feedId": "vitrasa", "type": "gtfs", - "source": "./feeds/tussa.zip" + "source": "./feeds/vitrasa.zip" }, { - "feedId": "tranvias", + "feedId": "xunta", "type": "gtfs", - "source": "./feeds/tranvias.zip" + "source": "./feeds/xunta.zip" }, + { - "feedId": "vitrasa", + "feedId": "tussa", "type": "gtfs", - "source": "./feeds/vitrasa.zip" + "source": "./feeds/tussa.zip" }, { "feedId": "shuttle", @@ -52,6 +53,6 @@ "timeZone": "Europe/Madrid" } ], - "transitServiceStart": "-P3D", + "transitServiceStart": "-P2D", "transitServiceEnd": "P1M" } diff --git a/build_renfe b/build_renfe deleted file mode 160000 -Subproject 43130f953f86942b349eb1e5cdf59110c02b81c diff --git a/build_renfe/.gitignore b/build_renfe/.gitignore new file mode 100644 index 0000000..e70de83 --- /dev/null +++ b/build_renfe/.gitignore @@ -0,0 +1,2 @@ +.venv/ +*.zip
\ No newline at end of file diff --git a/build_renfe/Dockerfile b/build_renfe/Dockerfile new file mode 100644 index 0000000..f565320 --- /dev/null +++ b/build_renfe/Dockerfile @@ -0,0 +1,25 @@ +# Use a multi-stage build to download necessary files +FROM alpine/curl AS downloader + +RUN curl -L https://download.geofabrik.de/europe/spain/galicia-latest.osm.pbf -o /galicia-latest.osm.pbf +RUN curl -L https://raw.githubusercontent.com/railnova/osrm-train-profile/refs/heads/master/basic.lua -o /opt/train.lua + +FROM osrm/osrm-backend + +# Copy the downloaded OSM file from the downloader stage +COPY --from=downloader /galicia-latest.osm.pbf /data/galicia-latest.osm.pbf +COPY --from=downloader /opt/train.lua /opt/train.lua + +# Extract the map data using osrm-train-profile (by Railnova) +RUN osrm-extract -p /opt/train.lua /data/galicia-latest.osm.pbf + +# Prepare the map data for routing +RUN osrm-partition /data/galicia-latest.osrm +RUN osrm-customize /data/galicia-latest.osrm + +# Expose the OSRM server port +EXPOSE 5000 + +# Start the OSRM server +CMD ["osrm-routed", "--algorithm", "mld", "/data/galicia-latest.osrm"] + diff --git a/build_renfe/LICENCE b/build_renfe/LICENCE new file mode 100644 index 0000000..4153cd3 --- /dev/null +++ b/build_renfe/LICENCE @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. diff --git a/build_renfe/README.md b/build_renfe/README.md new file mode 100644 index 0000000..d2dfce7 --- /dev/null +++ b/build_renfe/README.md @@ -0,0 +1,52 @@ +# Generador de GTFS Renfe Galicia + +Este repositorio contiene un script para extraer feeds GTFS estático para los servicios de Renfe en Galicia, España; usando los tres feeds disponibles (general, cercanías y FEVE). El script descarga los datos oficiales del Punto de Acceso Nacional (NAP) de España, extrae los viajes con paradas en Galicia y genera nuevos feeds GTFS con esta información. Adicionalmente, genera las formas de los viajes utilizando un servidor OSRM local con datos de OpenStreetMap (Geofabrik). + +## Cambios que se realizan + +1. Recortar los viajes para incluir solo aquellos con al menos una parada en Galicia. +2. Añadir headsigns a los viajes usando la última parada (como aparece en los letreros de los trenes). +3. Generar las formas de los viajes utilizando un servidor OSRM local con datos de OpenStreetMap para Galicia y un perfil específico para trenes. +4. Corregir algunos nombres y posiciones de estaciones para que sean fieles a la realidad. +5. Añadir colores a las rutas basándose en colores oficiales actuales y pasados de Renfe: naranja para Media Distancia, rojo Cercanías, verde en Trencelta, morado para regionales y AVE, azulado Avlo. + +## Requisitos + +- Python 3.12 o superior y `requests`. Con [uv](https://docs.astral.sh/uv) no es necesario instalar dependencias manualmente. +- Clave API gratuita del Punto de Acceso Nacional (NAP) de España. Se puede obtener en su portal: <https://nap.transportes.gob.es> registrándose como consumidor de datos. +- Docker y Docker Compose. Alternativamente, Rancher, Podman u otros gestores compatibles con Dockerfile y archivos docker-compose.yml. + +## Uso + +1. Clona este repositorio: + + ```bash + git clone https://github.com/tpgalicia/gtfs-renfe-galicia.git + cd gtfs-renfe-galicia + ``` + +2. Inicia el servidor OSRM local con datos de OpenStreetMap para Galicia y perfil específico para trenes. La primera vez puede tardar varios minutos en arrancar ya que tiene que preprocesar los datos: + + ```bash + docker-compose up -d + ``` + +3. Ejecutar el script para generar el feed GTFS estático: + + ```bash + uv run build_static_feed.py <NAP API KEY> + ``` + +Los feeds GTFS generados se guardarán en `gtfs_renfe_galicia_{feed}.zip` donde `feed` puede ser `general`, `cercanias` o `feve`. + +## Notas + +- Asegúrate de que el servidor OSRM esté en funcionamiento antes de ejecutar el script, en el puerto 5050. +- El script filtra los viajes para incluir solo aquellos con paradas en Galicia, basándose en las coordenadas geográficas de las estaciones. +- Las formas de los viajes se generan utilizando el servidor OSRM local para obtener rutas entre las paradas. + +## Licencia + +Este proyecto está cedido como software libre bajo licencia EUPL v1.2 o superior. Más información en el archivo [`LICENCE`](LICENCE) o en [Interoperable Europe](https://interoperable-europe.ec.europa.eu/collection/eupl). + +Los datos GTFS originales son propiedad de Renfe Operadora, cedidos bajo la [licencia de uso libre del NAP](https://nap.transportes.gob.es/licencia-datos). diff --git a/build_renfe/build_static_feed.py b/build_renfe/build_static_feed.py new file mode 100644 index 0000000..a60360f --- /dev/null +++ b/build_renfe/build_static_feed.py @@ -0,0 +1,564 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "requests", +# "tqdm", +# ] +# /// + +from argparse import ArgumentParser +import csv +import json +import logging +import os +import shutil +import tempfile +import zipfile + +import requests +from tqdm import tqdm + + +# Approximate bounding box for Galicia +BOUNDS = {"SOUTH": 41.820455, "NORTH": 43.937462, "WEST": -9.437256, "EAST": -6.767578} + +FEEDS = { + "general": "1098", + "cercanias": "1130", + "feve": "1131" +} + + +def is_in_bounds(lat: float, lon: float) -> bool: + return ( + BOUNDS["SOUTH"] <= lat <= BOUNDS["NORTH"] + and BOUNDS["WEST"] <= lon <= BOUNDS["EAST"] + ) + + +def get_stops_in_bounds(stops_file: str): + with open(stops_file, "r", encoding="utf-8") as f: + stops = csv.DictReader(f) + + for stop in stops: + lat = float(stop["stop_lat"]) + lon = float(stop["stop_lon"]) + if is_in_bounds(lat, lon): + yield stop + + +def get_trip_ids_for_stops(stoptimes_file: str, stop_ids: list[str]) -> list[str]: + trip_ids: set[str] = set() + + with open(stoptimes_file, "r", encoding="utf-8") as f: + stop_times = csv.DictReader(f) + + for stop_time in stop_times: + if stop_time["stop_id"] in stop_ids: + trip_ids.add(stop_time["trip_id"]) + + return list(trip_ids) + + +def get_routes_for_trips(trips_file: str, trip_ids: list[str]) -> list[str]: + route_ids: set[str] = set() + + with open(trips_file, "r", encoding="utf-8") as f: + trips = csv.DictReader(f) + + for trip in trips: + if trip["trip_id"] in trip_ids: + route_ids.add(trip["route_id"]) + + return list(route_ids) + + +def get_distinct_stops_from_stop_times( + stoptimes_file: str, trip_ids: list[str] +) -> list[str]: + stop_ids: set[str] = set() + + with open(stoptimes_file, "r", encoding="utf-8") as f: + stop_times = csv.DictReader(f) + + for stop_time in stop_times: + if stop_time["trip_id"] in trip_ids: + stop_ids.add(stop_time["stop_id"]) + + return list(stop_ids) + + +def get_last_stop_for_trips( + stoptimes_file: str, trip_ids: list[str] +) -> dict[str, str]: + trip_last: dict[str, str] = {} + trip_last_seq: dict[str, int] = {} + + with open(stoptimes_file, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + if reader.fieldnames is None: + raise Exception("Fuck you, screw you, fieldnames is None and you just get rekt") + reader.fieldnames = [name.strip() for name in reader.fieldnames] + + for stop_time in reader: + if stop_time["trip_id"] in trip_ids: + trip_id = stop_time["trip_id"] + if trip_last.get(trip_id, None) is None: + trip_last[trip_id] = "" + trip_last_seq[trip_id] = -1 + + this_stop_seq = int(stop_time["stop_sequence"]) + if this_stop_seq > trip_last_seq[trip_id]: + trip_last_seq[trip_id] = this_stop_seq + trip_last[trip_id] = stop_time["stop_id"] + + return trip_last + +def get_rows_by_ids(input_file: str, id_field: str, ids: list[str]) -> list[dict]: + rows: list[dict] = [] + + with open(input_file, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + if reader.fieldnames is None: + raise Exception("Fuck you, screw you, fieldnames is None and you just get rekt") + reader.fieldnames = [name.strip() for name in reader.fieldnames] + + for row in reader: + if row[id_field].strip() in ids: + rows.append(row) + + return rows + +# First colour is background, second is text +SERVICE_COLOURS = { + "REGIONAL": ("9A0060", "FFFFFF"), + "REG.EXP.": ("9A0060", "FFFFFF"), + + "MD": ("F85B0B", "000000"), + "AVANT": ("F85B0B", "000000"), + + "AVLO": ("05CEC6", "000000"), + "AVE": ("FFFFFF", "9A0060"), + "ALVIA": ("FFFFFF", "9A0060"), + + "INTERCITY": ("606060", "FFFFFF"), + + "TRENCELTA": ("00824A", "FFFFFF"), + + # Cercanías Ferrol-Ortigueira + "C1": ("F5333F", "FFFFFF") +} + + +def colour_route(route_short_name: str) -> tuple[str, str]: + """ + Returns the colours to be used for a route from its short name. + + :param route_short_name: The routes.txt's route_short_name + :return: A tuple containing the "route_color" (background) first and "route_text_color" (text) second + :rtype: tuple[str, str] + """ + + route_name_searched = route_short_name.strip().upper() + + if route_name_searched in SERVICE_COLOURS: + return SERVICE_COLOURS[route_name_searched] + + print("Unknown route short name:", route_short_name) + return ("000000", "FFFFFF") + + +if __name__ == "__main__": + parser = ArgumentParser( + description="Extract GTFS data for Galicia from Renfe GTFS feed." + ) + parser.add_argument( + "nap_apikey", + type=str, + help="NAP API Key (https://nap.transportes.gob.es/)" + ) + parser.add_argument( + "--osrm-url", + type=str, + help="OSRM server URL", + default="http://localhost:5050", + required=False, + ) + parser.add_argument( + "--debug", + help="Enable debug logging", + action="store_true" + ) + + args = parser.parse_args() + + try: + osrm_check = requests.head(args.osrm_url, timeout=5) + GENERATE_SHAPES = osrm_check.status_code < 500 + except requests.RequestException: + GENERATE_SHAPES = False + logging.warning("OSRM server is not reachable. Shape generation will be skipped.") + + + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + ) + + for feed in FEEDS.keys(): + INPUT_GTFS_FD, INPUT_GTFS_ZIP = tempfile.mkstemp(suffix=".zip", prefix=f"renfe_galicia_in_{feed}_") + INPUT_GTFS_PATH = tempfile.mkdtemp(prefix=f"renfe_galicia_in_{feed}_") + OUTPUT_GTFS_PATH = tempfile.mkdtemp(prefix=f"renfe_galicia_out_{feed}_") + OUTPUT_GTFS_ZIP = os.path.join(os.path.dirname(__file__), f"gtfs_renfe_galicia_{feed}.zip") + + FEED_URL = f"https://nap.transportes.gob.es/api/Fichero/download/{FEEDS[feed]}" + + logging.info(f"Downloading GTFS feed '{feed}'...") + response = requests.get(FEED_URL, headers={"ApiKey": args.nap_apikey}) + with open(INPUT_GTFS_ZIP, "wb") as f: + f.write(response.content) + + # Unzip the GTFS feed + with zipfile.ZipFile(INPUT_GTFS_ZIP, "r") as zip_ref: + zip_ref.extractall(INPUT_GTFS_PATH) + + STOPS_FILE = os.path.join(INPUT_GTFS_PATH, "stops.txt") + STOP_TIMES_FILE = os.path.join(INPUT_GTFS_PATH, "stop_times.txt") + TRIPS_FILE = os.path.join(INPUT_GTFS_PATH, "trips.txt") + + all_stops_applicable = [stop for stop in get_stops_in_bounds(STOPS_FILE)] + logging.info(f"Total stops in Galicia: {len(all_stops_applicable)}") + + stop_ids = [stop["stop_id"] for stop in all_stops_applicable] + trip_ids = get_trip_ids_for_stops(STOP_TIMES_FILE, stop_ids) + + route_ids = get_routes_for_trips(TRIPS_FILE, trip_ids) + + logging.info(f"Feed parsed successfully. Stops: {len(stop_ids)}, trips: {len(trip_ids)}, routes: {len(route_ids)}") + if len(trip_ids) == 0 or len(route_ids) == 0: + logging.warning(f"No trips or routes found for feed '{feed}'. Skipping...") + shutil.rmtree(INPUT_GTFS_PATH) + shutil.rmtree(OUTPUT_GTFS_PATH) + continue + + # Copy agency.txt, calendar.txt, calendar_dates.txt as is + for filename in ["agency.txt", "calendar.txt", "calendar_dates.txt"]: + src_path = os.path.join(INPUT_GTFS_PATH, filename) + dest_path = os.path.join(OUTPUT_GTFS_PATH, filename) + if os.path.exists(src_path): + shutil.copy(src_path, dest_path) + else: + logging.debug(f"File {filename} does not exist in the input GTFS feed.") + + # Write new stops.txt with the stops in any trip that passes through Galicia + with open( + os.path.join(os.path.dirname(__file__), "stop_overrides.json"), + "r", + encoding="utf-8", + ) as f: + stop_overrides_raw: list = json.load(f) + stop_overrides = { + item["stop_id"]: item + for item in stop_overrides_raw + } + logging.debug(f"Loaded stop overrides for {len(stop_overrides)} stops.") + + deleted_stop_ids: set[str] = set() + for stop_id, override_item in stop_overrides.items(): + if override_item.get("_delete", False): + if override_item.get("feed_id", None) is None or override_item["feed_id"] == feed: + deleted_stop_ids.add(stop_id) + logging.debug(f"Stops marked for deletion in feed '{feed}': {len(deleted_stop_ids)}") + + distinct_stop_ids = get_distinct_stops_from_stop_times( + STOP_TIMES_FILE, trip_ids + ) + stops_in_trips = get_rows_by_ids(STOPS_FILE, "stop_id", distinct_stop_ids) + for stop in stops_in_trips: + stop["stop_code"] = stop["stop_id"] + if stop_overrides.get(stop["stop_id"], None) is not None: + override_item = stop_overrides[stop["stop_id"]] + + if override_item.get("feed_id", None) is not None and override_item["feed_id"] != feed: + continue + + for key, value in override_item.items(): + if key in ("stop_id", "feed_id", "_delete"): + continue + stop[key] = value + + if stop["stop_name"].startswith("Estación de tren "): + stop["stop_name"] = stop["stop_name"][17:].strip() + stop["stop_name"] = " ".join([ + word.capitalize() for word in stop["stop_name"].split(" ") if word != "de" + ]) + + stops_in_trips = [stop for stop in stops_in_trips if stop["stop_id"] not in deleted_stop_ids] + + with open( + os.path.join(OUTPUT_GTFS_PATH, "stops.txt"), + "w", + encoding="utf-8", + newline="", + ) as f: + writer = csv.DictWriter(f, fieldnames=stops_in_trips[0].keys()) + writer.writeheader() + writer.writerows(stops_in_trips) + + # Write new routes.txt with the routes that have trips in Galicia + routes_in_trips = get_rows_by_ids( + os.path.join(INPUT_GTFS_PATH, "routes.txt"), "route_id", route_ids + ) + + if feed == "feve": + feve_c1_route_ids = ["46T0001C1", "46T0002C1"] + new_route_id = "FEVE_C1" + + # Find agency_id and a template route + template_route = routes_in_trips[0] if routes_in_trips else {} + agency_id = "1" + for r in routes_in_trips: + if r["route_id"].strip() in feve_c1_route_ids: + agency_id = r.get("agency_id", "1") + template_route = r + break + + # Filter out old routes + routes_in_trips = [r for r in routes_in_trips if r["route_id"].strip() not in feve_c1_route_ids] + + # Add new route + new_route = template_route.copy() + new_route.update({ + "route_id": new_route_id, + "route_short_name": "C1", + "route_long_name": "Ferrol - Xuvia - San Sadurniño - Ortigueira", + "route_type": "2", + }) + if "agency_id" in template_route: + new_route["agency_id"] = agency_id + + routes_in_trips.append(new_route) + + for route in routes_in_trips: + route["route_color"], route["route_text_color"] = colour_route( + route["route_short_name"] + ) + with open( + os.path.join(OUTPUT_GTFS_PATH, "routes.txt"), + "w", + encoding="utf-8", + newline="", + ) as f: + writer = csv.DictWriter(f, fieldnames=routes_in_trips[0].keys()) + writer.writeheader() + writer.writerows(routes_in_trips) + + # Write new trips.txt with the trips that pass through Galicia + # Load stop_times early so we can filter deleted stops and renumber sequences + stop_times_in_galicia = get_rows_by_ids(STOP_TIMES_FILE, "trip_id", trip_ids) + stop_times_in_galicia = [st for st in stop_times_in_galicia if st["stop_id"].strip() not in deleted_stop_ids] + stop_times_in_galicia.sort(key=lambda x: (x["trip_id"], int(x["stop_sequence"].strip()))) + trip_seq_counter: dict[str, int] = {} + for st in stop_times_in_galicia: + tid = st["trip_id"] + if tid not in trip_seq_counter: + trip_seq_counter[tid] = 0 + st["stop_sequence"] = str(trip_seq_counter[tid]) + trip_seq_counter[tid] += 1 + + last_stop_in_trips: dict[str, str] = {} + trip_last_seq: dict[str, int] = {} + for st in stop_times_in_galicia: + tid = st["trip_id"] + seq = int(st["stop_sequence"]) + if seq > trip_last_seq.get(tid, -1): + trip_last_seq[tid] = seq + last_stop_in_trips[tid] = st["stop_id"].strip() + + trips_in_galicia = get_rows_by_ids(TRIPS_FILE, "trip_id", trip_ids) + + if feed == "feve": + feve_c1_route_ids = ["46T0001C1", "46T0002C1"] + new_route_id = "FEVE_C1" + for tig in trips_in_galicia: + if tig["route_id"].strip() in feve_c1_route_ids: + tig["route_id"] = new_route_id + tig["direction_id"] = "1" if tig["route_id"].strip()[6] == "2" else "0" + + stops_by_id = {stop["stop_id"]: stop for stop in stops_in_trips} + + for tig in trips_in_galicia: + if GENERATE_SHAPES: + tig["shape_id"] = f"Shape_{tig['trip_id'][0:5]}" + tig["trip_headsign"] = stops_by_id[last_stop_in_trips[tig["trip_id"]]]["stop_name"] + with open( + os.path.join(OUTPUT_GTFS_PATH, "trips.txt"), + "w", + encoding="utf-8", + newline="", + ) as f: + writer = csv.DictWriter(f, fieldnames=trips_in_galicia[0].keys()) + writer.writeheader() + writer.writerows(trips_in_galicia) + + # Write new stop_times.txt with the stop times for any trip that passes through Galicia + with open( + os.path.join(OUTPUT_GTFS_PATH, "stop_times.txt"), + "w", + encoding="utf-8", + newline="", + ) as f: + writer = csv.DictWriter(f, fieldnames=stop_times_in_galicia[0].keys()) + writer.writeheader() + writer.writerows(stop_times_in_galicia) + + logging.info("GTFS data for Galicia has been extracted successfully. Generate shapes for the trips...") + + if GENERATE_SHAPES: + shape_ids_total = len(set(f"Shape_{trip_id[0:5]}" for trip_id in trip_ids)) + shape_ids_generated: set[str] = set() + + # Pre-load stops for quick lookup + stops_dict = {stop["stop_id"]: stop for stop in stops_in_trips} + + # Group stop times by trip_id to avoid repeated file reads + stop_times_by_trip: dict[str, list[dict]] = {} + for st in stop_times_in_galicia: + tid = st["trip_id"] + if tid not in stop_times_by_trip: + stop_times_by_trip[tid] = [] + stop_times_by_trip[tid].append(st) + + OSRM_BASE_URL = f"{args.osrm_url}/route/v1/driving/" + for trip_id in tqdm(trip_ids, total=shape_ids_total, desc="Generating shapes"): + shape_id = f"Shape_{trip_id[0:5]}" + if shape_id in shape_ids_generated: + continue + + stop_seq = stop_times_by_trip.get(trip_id, []) + stop_seq.sort(key=lambda x: int(x["stop_sequence"].strip())) + + if not stop_seq: + continue + + final_shape_points = [] + i = 0 + while i < len(stop_seq) - 1: + stop_a = stops_dict[stop_seq[i]["stop_id"]] + lat_a, lon_a = float(stop_a["stop_lat"]), float(stop_a["stop_lon"]) + + if not is_in_bounds(lat_a, lon_a): + # S_i is out of bounds. Segment S_i -> S_{i+1} is straight line. + stop_b = stops_dict[stop_seq[i+1]["stop_id"]] + lat_b, lon_b = float(stop_b["stop_lat"]), float(stop_b["stop_lon"]) + + segment_points = [[lon_a, lat_a], [lon_b, lat_b]] + if not final_shape_points: + final_shape_points.extend(segment_points) + else: + final_shape_points.extend(segment_points[1:]) + i += 1 + else: + # S_i is in bounds. Find how many subsequent stops are also in bounds. + j = i + 1 + while j < len(stop_seq): + stop_j = stops_dict[stop_seq[j]["stop_id"]] + if is_in_bounds(float(stop_j["stop_lat"]), float(stop_j["stop_lon"])): + j += 1 + else: + break + + # Stops from i to j-1 are in bounds. + if j > i + 1: + # We have at least two consecutive stops in bounds. + in_bounds_stops = stop_seq[i:j] + coordinates = [] + for st in in_bounds_stops: + s = stops_dict[st["stop_id"]] + coordinates.append(f"{s['stop_lon']},{s['stop_lat']}") + + coords_str = ";".join(coordinates) + osrm_url = f"{OSRM_BASE_URL}{coords_str}?overview=full&geometries=geojson" + + segment_points = [] + try: + response = requests.get(osrm_url, timeout=10) + if response.status_code == 200: + data = response.json() + if data.get("code") == "Ok": + segment_points = data["routes"][0]["geometry"]["coordinates"] + except Exception: + pass + + if not segment_points: + # Fallback to straight lines for this whole sub-sequence + segment_points = [] + for k in range(i, j): + s = stops_dict[stop_seq[k]["stop_id"]] + segment_points.append([float(s["stop_lon"]), float(s["stop_lat"])]) + + if not final_shape_points: + final_shape_points.extend(segment_points) + else: + final_shape_points.extend(segment_points[1:]) + + i = j - 1 # Next iteration starts from S_{j-1} + else: + # Only S_i is in bounds, S_{i+1} is out. + # Segment S_i -> S_{i+1} is straight line. + stop_b = stops_dict[stop_seq[i+1]["stop_id"]] + lat_b, lon_b = float(stop_b["stop_lat"]), float(stop_b["stop_lon"]) + + segment_points = [[lon_a, lat_a], [lon_b, lat_b]] + if not final_shape_points: + final_shape_points.extend(segment_points) + else: + final_shape_points.extend(segment_points[1:]) + i += 1 + + shape_ids_generated.add(shape_id) + + with open( + os.path.join(OUTPUT_GTFS_PATH, "shapes.txt"), + "a", + encoding="utf-8", + newline="", + ) as f: + fieldnames = [ + "shape_id", + "shape_pt_lat", + "shape_pt_lon", + "shape_pt_sequence", + ] + writer = csv.DictWriter(f, fieldnames=fieldnames) + + if f.tell() == 0: + writer.writeheader() + + for seq, point in enumerate(final_shape_points): + writer.writerow( + { + "shape_id": shape_id, + "shape_pt_lat": point[1], + "shape_pt_lon": point[0], + "shape_pt_sequence": seq, + } + ) + else: + logging.info("Shape generation skipped as per user request.") + + # Create a ZIP archive of the output GTFS + with zipfile.ZipFile(OUTPUT_GTFS_ZIP, "w", zipfile.ZIP_DEFLATED) as zipf: + for root, _, files in os.walk(OUTPUT_GTFS_PATH): + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, OUTPUT_GTFS_PATH) + zipf.write(file_path, arcname) + + logging.info( + f"GTFS data from feed {feed} has been zipped successfully at {OUTPUT_GTFS_ZIP}." + ) + os.close(INPUT_GTFS_FD) + os.remove(INPUT_GTFS_ZIP) + shutil.rmtree(INPUT_GTFS_PATH) + shutil.rmtree(OUTPUT_GTFS_PATH) diff --git a/build_renfe/compose.yml b/build_renfe/compose.yml new file mode 100644 index 0000000..ebe29cf --- /dev/null +++ b/build_renfe/compose.yml @@ -0,0 +1,7 @@ +services: + osrm: + build: + context: . + restart: unless-stopped + ports: + - "5050:5000" diff --git a/build_renfe/stop_overrides.json b/build_renfe/stop_overrides.json new file mode 100644 index 0000000..c2298fa --- /dev/null +++ b/build_renfe/stop_overrides.json @@ -0,0 +1,101 @@ +[ + { + "stop_id": "31412", + "stop_lat": 43.3504, + "stop_lon": -8.412142, + "stop_name": "A Coruña" + }, + { + "stop_id": "23021", + "stop_lat": 42.78122, + "stop_lon": -8.656493, + "stop_name": "Padrón-Barbanza" + }, + { + "stop_id": "08224", + "stop_lat": 42.28455, + "stop_lon": -8.603739, + "stop_name": "Redondela AV" + }, + { + "stop_id": "22201", + "stop_name": "O Porriño" + }, + { + "stop_id": "22006", + "stop_name": "Barra de Miño" + }, + { + "stop_id": "20208", + "stop_name": "Quereño" + }, + { + "stop_id": "22109", + "stop_name": "Salvaterra de Miño" + }, + { + "stop_id": "20410", + "stop_name": "Elviña-Universidade" + }, + { + "stop_id": "20318", + "stop_name": "Piñoi" + }, + { + "stop_id": "21002", + "stop_name": "Miño" + }, + { + "stop_id": "31304", + "stop_name": "O Carballiño" + }, + { + "stop_id": "96122", + "stop_name": "Barcelos" + }, + { + "stop_id": "94033", + "stop_name": "Viana do Castelo" + }, + { + "stop_id": "22308", + "stop_lat": 42.23930, + "stop_lon": -8.71226 + }, + { + "stop_id": "22402", + "stop_name": "Valença do Minho" + }, + { + "stop_id": "21010", + "feed_id": "general", + "stop_lat": 43.4880356421007, + "stop_lon": -8.230795701069612 + }, + { + "stop_id": "21010", + "feed_id": "feve", + "stop_lat": 43.48826050175589, + "stop_lon": -8.231122670037813 + }, + { + "stop_id": "99117", + "stop_name": "Ourense Turístico", + "_delete": true + }, + { + "stop_id": "99143", + "stop_name": "A Coruña - Turístico", + "_delete": true + }, + { + "stop_id": "99159", + "stop_name": "Santiago Turístico", + "_delete": true + }, + { + "stop_id": "99161", + "stop_name": "Pontevedra Turístico", + "_delete": true + } +] diff --git a/build_tranvias b/build_tranvias deleted file mode 160000 -Subproject ecb94447915e593944315002b1c5c6a01a5c4ee diff --git a/build_tranvias/.gitignore b/build_tranvias/.gitignore new file mode 100644 index 0000000..e70de83 --- /dev/null +++ b/build_tranvias/.gitignore @@ -0,0 +1,2 @@ +.venv/ +*.zip
\ No newline at end of file diff --git a/build_tranvias/LICENCE b/build_tranvias/LICENCE new file mode 100644 index 0000000..4153cd3 --- /dev/null +++ b/build_tranvias/LICENCE @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. diff --git a/build_tranvias/README.md b/build_tranvias/README.md new file mode 100644 index 0000000..20a541c --- /dev/null +++ b/build_tranvias/README.md @@ -0,0 +1,31 @@ +# Generador de GTFS Transporte Urbano de A Coruña + +Este repositorio contiene un script para mejorar los datos del feed GTFS del transporte urbano de A Coruña, España. El script descarga los datos oficiales del Punto de Acceso Nacional (NAP) de España y aplica pequeñas correcciones-mejoras. + +## Requisitos + +- Python 3.12 o superior y `requests`. Con [uv](https://docs.astral.sh/uv) no es necesario instalar dependencias manualmente. +- Clave API del Punto de Acceso Nacional (NAP) de España. Se puede obtener en su portal: <https://nap.transportes.gob.es> registrándose como consumidor de manera gratuita. + +## Uso + +1. Clona este repositorio: + + ```bash + git clone https://github.com/tpgalicia/gtfs-coruna.git + cd gtfs-coruna + ``` + +2. Ejecutar el script para generar el feed GTFS estático: + + ```bash + uv run build_static_feed.py <NAP API KEY> + ``` + +El feed GTFS generado se guardará en `gtfs_coruna.zip`. + +## Licencia + +Este proyecto está cedido como software libre bajo licencia EUPL v1.2 o superior. Más información en el archivo [`LICENCE`](LICENCE) o en [Interoperable Europe](https://interoperable-europe.ec.europa.eu/collection/eupl). + +Los datos GTFS originales son propiedad de Compañía de Tranvías de La Coruña (sic.), cedidos bajo la [licencia de uso libre del NAP](https://nap.transportes.gob.es/licencia-datos). diff --git a/build_tranvias/build_static_feed.py b/build_tranvias/build_static_feed.py new file mode 100644 index 0000000..dd7f383 --- /dev/null +++ b/build_tranvias/build_static_feed.py @@ -0,0 +1,195 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "requests" +# ] +# /// + +from argparse import ArgumentParser +import csv +import json +import logging +import os +import shutil +import tempfile +import zipfile + +import requests + + +FEED_ID = 1574 + + +def get_rows(input_file: str) -> list[dict]: + rows: list[dict] = [] + + with open(input_file, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + if reader.fieldnames is None: + return [] + reader.fieldnames = [name.strip() for name in reader.fieldnames] + + for row in reader: + rows.append(row) + + return rows + + +if __name__ == "__main__": + parser = ArgumentParser() + parser.add_argument( + "nap_apikey", + type=str, + help="NAP API Key (https://nap.transportes.gob.es/)" + ) + parser.add_argument( + "--debug", + help="Enable debug logging", + action="store_true" + ) + + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + ) + + INPUT_GTFS_FD, INPUT_GTFS_ZIP = tempfile.mkstemp(suffix=".zip", prefix="coruna_in_") + INPUT_GTFS_PATH = tempfile.mkdtemp(prefix="coruna_in_") + OUTPUT_GTFS_PATH = tempfile.mkdtemp(prefix="coruna_out_") + OUTPUT_GTFS_ZIP = os.path.join(os.path.dirname(__file__), "gtfs_coruna.zip") + + FEED_URL = f"https://nap.transportes.gob.es/api/Fichero/download/{FEED_ID}" + + logging.info(f"Downloading GTFS feed '{FEED_ID}'...") + response = requests.get(FEED_URL, headers={"ApiKey": args.nap_apikey}) + with open(INPUT_GTFS_ZIP, "wb") as f: + f.write(response.content) + + # Unzip the GTFS feed + with zipfile.ZipFile(INPUT_GTFS_ZIP, "r") as zip_ref: + zip_ref.extractall(INPUT_GTFS_PATH) + + TRIPS_FILE = os.path.join(INPUT_GTFS_PATH, "trips.txt") + STOPS_FILE = os.path.join(INPUT_GTFS_PATH, "stops.txt") + ROUTES_FILE = os.path.join(INPUT_GTFS_PATH, "routes.txt") + + # Copy every file in feed except stops.txt and routes.txt + for filename in os.listdir(INPUT_GTFS_PATH): + if filename in ["stops.txt", "routes.txt"]: + continue + if not filename.endswith(".txt"): + continue + + src_path = os.path.join(INPUT_GTFS_PATH, filename) + dest_path = os.path.join(OUTPUT_GTFS_PATH, filename) + shutil.copy(src_path, dest_path) + + # Process trips.txt + logging.info("Processing trips.txt...") + with open( + os.path.join(os.path.dirname(__file__), "trip_byshape_overrides.json"), + "r", + encoding="utf-8", + ) as f: + trip_byshape_overrides_list = json.load(f) + trip_byshape_overrides = {item["shape_id"]: item for item in trip_byshape_overrides_list} + + trips = get_rows(TRIPS_FILE) + for trip in trips: + tsid = trip["shape_id"] + + # Then we apply the overrides (which could update the name too, that's why it's done later) + if tsid in trip_byshape_overrides: + for key, value in trip_byshape_overrides[tsid].items(): + trip[key] = value + + if trips: + with open( + os.path.join(OUTPUT_GTFS_PATH, "trips.txt"), + "w", + encoding="utf-8", + newline="", + ) as f: + writer = csv.DictWriter(f, fieldnames=trips[0].keys()) + writer.writeheader() + writer.writerows(trips) + + # Process stops.txt + logging.info("Processing stops.txt...") + with open( + os.path.join(os.path.dirname(__file__), "stop_overrides.json"), + "r", + encoding="utf-8", + ) as f: + stop_overrides_list = json.load(f) + stop_overrides = {item["stop_id"]: item for item in stop_overrides_list} + + stops = get_rows(STOPS_FILE) + for stop in stops: + sid = stop["stop_id"] + + # First we default the stop_name to stop_desc if it's not empty + if stop["stop_desc"] != "": + stop["stop_name"] = stop["stop_desc"] + + # Then we apply the overrides (which could update the name too, that's why it's done later) + if sid in stop_overrides: + for key, value in stop_overrides[sid].items(): + stop[key] = value + + if stops: + with open( + os.path.join(OUTPUT_GTFS_PATH, "stops.txt"), + "w", + encoding="utf-8", + newline="", + ) as f: + writer = csv.DictWriter(f, fieldnames=stops[0].keys()) + writer.writeheader() + writer.writerows(stops) + + # Process routes.txt + logging.info("Processing routes.txt...") + with open( + os.path.join(os.path.dirname(__file__), "route_overrides.json"), + "r", + encoding="utf-8", + ) as f: + route_overrides_list = json.load(f) + route_overrides = {item["route_id"]: item for item in route_overrides_list} + + routes = get_rows(ROUTES_FILE) + for route in routes: + rid = route["route_id"] + if rid in route_overrides: + for key, value in route_overrides[rid].items(): + route[key] = value + + if routes: + with open( + os.path.join(OUTPUT_GTFS_PATH, "routes.txt"), + "w", + encoding="utf-8", + newline="", + ) as f: + writer = csv.DictWriter(f, fieldnames=routes[0].keys()) + writer.writeheader() + writer.writerows(routes) + + # Create a ZIP archive of the output GTFS + with zipfile.ZipFile(OUTPUT_GTFS_ZIP, "w", zipfile.ZIP_DEFLATED) as zipf: + for root, _, files in os.walk(OUTPUT_GTFS_PATH): + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, OUTPUT_GTFS_PATH) + zipf.write(file_path, arcname) + + logging.info( + f"GTFS data from feed {FEED_ID} has been zipped successfully at {OUTPUT_GTFS_ZIP}." + ) + os.close(INPUT_GTFS_FD) + os.remove(INPUT_GTFS_ZIP) + shutil.rmtree(INPUT_GTFS_PATH) + shutil.rmtree(OUTPUT_GTFS_PATH) diff --git a/build_tranvias/route_overrides.json b/build_tranvias/route_overrides.json new file mode 100644 index 0000000..6a719b5 --- /dev/null +++ b/build_tranvias/route_overrides.json @@ -0,0 +1,27 @@ +[ + { "route_id": "100", "route_color": "982135" }, + { "route_id": "200", "route_color": "FDB515" }, + { "route_id": "300", "route_color": "C0910F" }, + { "route_id": "301", "route_color": "D3B255" }, + { "route_id": "400", "route_color": "00B17A" }, + { "route_id": "500", "route_color": "6792BC" }, + { "route_id": "600", "route_color": "F96B09" }, + { "route_id": "601", "route_color": "FC9751" }, + { "route_id": "700", "route_color": "019FA2" }, + { "route_id": "800", "route_color": "FDCB5A" }, + { "route_id": "1100", "route_color": "F94F8E" }, + { "route_id": "1200", "route_color": "019F02" }, + { "route_id": "1400", "route_color": "025BBF" }, + { "route_id": "1500", "route_color": "4DB94C" }, + { "route_id": "1700", "route_color": "A15011" }, + { "route_id": "1800", "route_color": "35264F" }, + { "route_id": "1801", "route_color": "35264F" }, + { "route_id": "1900", "route_color": "E46078" }, + { "route_id": "2000", "route_color": "982135" }, + { "route_id": "2100", "route_color": "355787" }, + { "route_id": "2200", "route_color": "934165" }, + { "route_id": "2300", "route_color": "8E47AD" }, + { "route_id": "2301", "route_color": "AE7FC5" }, + { "route_id": "2400", "route_color": "056E74" }, + { "route_id": "2450", "route_color": "D61D3F" } +] diff --git a/build_tranvias/stop_overrides.json b/build_tranvias/stop_overrides.json new file mode 100644 index 0000000..c1bf7f5 --- /dev/null +++ b/build_tranvias/stop_overrides.json @@ -0,0 +1,177 @@ +[ + { + "stop_id": "64", + "stop_lat": 43.352065, + "stop_lon": -8.391949 + }, + { + "stop_id": "28", + "stop_lat": 43.369616, + "stop_lon": -8.399461 + }, + { + "stop_id": "139", + "stop_lat": 43.369955, + "stop_lon": -8.395769 + }, + { + "stop_id": "373", + "stop_lat": 43.332242, + "stop_lon": -8.391254 + }, + { + "stop_id": "378", + "stop_lat": 43.343533, + "stop_lon": -8.397769 + }, + { + "stop_id": "112", + "stop_lat": 43.343073, + "stop_lon": -8.400457 + }, + { + "stop_id": "379", + "stop_lat": 43.340733, + "stop_lon": -8.404184 + }, + { + "stop_id": "37", + "stop_lat": 43.353238, + "stop_lon": -8.391853 + }, + { + "stop_id": "38", + "stop_lat": 43.353941, + "stop_lon": -8.394702 + }, + { + "stop_id": "132", + "stop_lat": 43.369736, + "stop_lon": -8.434347 + }, + { + "stop_id": "137", + "stop_lat": 43.367460, + "stop_lon": -8.407564 + }, + { + "stop_id": "145", + "stop_lat": 43.374383, + "stop_lon": -8.394661 + }, + { + "stop_id": "165", + "stop_lat": 43.369644, + "stop_lon": -8.434374 + }, + { + "stop_id": "389", + "stop_lat": 43.379966, + "stop_lon": -8.408394 + }, + { + "stop_id": "580", + "stop_lat": 43.348332, + "stop_lon": -8.413733 + }, + { + "stop_id": "582", + "stop_lat": 43.343739, + "stop_lon": -8.415791 + }, + { + "stop_id": "566", + "stop_lat": 43.348227, + "stop_lon": -8.413728 + }, + { + "stop_id": "567", + "stop_lat": 43.350400, + "stop_lon": -8.411097 + }, + { + "stop_id": "215", + "stop_lat": 43.365544, + "stop_lon": -8.411132 + }, + { + "stop_id": "222", + "stop_lat": 43.354834, + "stop_lon": -8.429719 + }, + { + "stop_id": "224", + "stop_lat": 43.350309, + "stop_lon": -8.439742 + }, + { + "stop_id": "229", + "stop_lat": 43.354434, + "stop_lon": -8.430025 + }, + { + "stop_id": "236", + "stop_lat": 43.365450, + "stop_lon": -8.411054 + }, + { + "stop_id": "242", + "stop_lat": 43.360933, + "stop_lon": -8.425733 + }, + { + "stop_id": "212", + "stop_lat": 43.346950, + "stop_lon": -8.425975 + }, + { + "stop_id": "579", + "stop_lat": 43.348578, + "stop_lon": -8.409703 + }, + { + "stop_id": "268", + "stop_lat": 43.357253, + "stop_lon": -8.400609 + }, + { + "stop_id": "327", + "stop_lat": 43.333791, + "stop_lon": -8.426546 + }, + { + "stop_id": "300", + "stop_lat": 43.328896, + "stop_lon": -8.427418 + }, + { + "stop_id": "486", + "stop_lat": 43.326831, + "stop_lon": -8.429056 + }, + { + "stop_id": "317", + "stop_lat": 43.340516, + "stop_lon": -8.416727 + }, + { + "stop_id": "308", + "stop_lat": 43.320482, + "stop_lon": -8.435579 + }, + { + "stop_id": "429", + "stop_lat": 43.323887, + "stop_lon": -8.407597 + }, + { + "stop_id": "571", + "stop_lat": 43.322342, + "stop_lon": -8.411534 + }, + { + "stop_id": "572", + "stop_lat": 43.322149, + "stop_lon": -8.410724 + } +] diff --git a/build_tranvias/trip_byshape_overrides.json b/build_tranvias/trip_byshape_overrides.json new file mode 100644 index 0000000..9d7eb99 --- /dev/null +++ b/build_tranvias/trip_byshape_overrides.json @@ -0,0 +1,56 @@ +[ + { "shape_id": "10001", "trip_headsign": "Abente y Lago" }, + { "shape_id": "10000", "trip_headsign": "Castrillón" }, + { "shape_id": "20000", "trip_headsign": "Os Castros" }, + { "shape_id": "20001", "trip_headsign": "Abente y Lago" }, + { "shape_id": "30000", "trip_headsign": "P.Real - Durmideiras" }, + { "shape_id": "30003", "trip_headsign": "B.Maza - San Pedro de Visma" }, + { "shape_id": "30001", "trip_headsign": "B.Maza - San Pedro de Visma" }, + { "shape_id": "30100", "trip_headsign": "B.Maza - Durmideiras" }, + { "shape_id": "30101", "trip_headsign": "P.Real - San Pedro de Visma" }, + { "shape_id": "40000", "trip_headsign": "Barrio das Flores" }, + { "shape_id": "40001", "trip_headsign": "Av. de Hércules" }, + { "shape_id": "50000", "trip_headsign": "Espacio Coruña" }, + { "shape_id": "50002", "trip_headsign": "Espacio Coruña" }, + { "shape_id": "60002", "trip_headsign": "Meicende - Nostián" }, + { "shape_id": "60003", "trip_headsign": "Av. de Hércules" }, + { "shape_id": "60001", "trip_headsign": "Av. de Hércules" }, + { "shape_id": "60101", "trip_headsign": "Av. de Hércules" }, + { "shape_id": "70001", "trip_headsign": "Av. de Hércules" }, + { "shape_id": "80000", "trip_headsign": "Hosp.Oza - As Xubias" }, + { "shape_id": "80002", "trip_headsign": "Hosp.Oza - As Xubias" }, + { "shape_id": "110000", "trip_headsign": "Os Mallos - Área C.Marineda" }, + { "shape_id": "110001", "trip_headsign": "As Lagoas" }, + { "shape_id": "120000", "trip_headsign": "CHUAC - Materno Inf." }, + { "shape_id": "120001", "trip_headsign": "Os Rosales" }, + { "shape_id": "140000", "trip_headsign": "Castrillón" }, + { "shape_id": "140001", "trip_headsign": "Os Rosales" }, + { "shape_id": "150000", "trip_headsign": "CHUAC - Materno Inf."}, + { "shape_id": "150000", "trip_headsign": "Cidade Escolar" }, + { "shape_id": "170000", "trip_headsign": "CHUAC - Materno Inf." }, + { "shape_id": "170001", "trip_headsign": "Av. de Hércules" }, + { "shape_id": "180000", "trip_headsign": "Servizo Nocturno" }, + { "shape_id": "180001", "trip_headsign": "Servizo Nocturno" }, + { "shape_id": "180100", "trip_headsign": "Servizo Nocturno Inverso" }, + { "shape_id": "180101", "trip_headsign": "Servizo Nocturno Inverso" }, + { "shape_id": "190000", "trip_headsign": "A Pasaxe" }, + { "shape_id": "200000", "trip_headsign": "CHUAC - A Pasaxe" }, + { "shape_id": "200001", "trip_headsign": "A.Molina - Pza.Pontevedra" }, + { "shape_id": "200003", "trip_headsign": "Pza.Pontevedra - Cocheiras" }, + { "shape_id": "210000", "trip_headsign": "Novo Mesoiro" }, + { "shape_id": "210001", "trip_headsign": "Juana de Vega" }, + { "shape_id": "220000", "trip_headsign": "A.Molina - A Pasaxe" }, + { "shape_id": "220001", "trip_headsign": "CHUAC - Pza.Pontevedra" }, + { "shape_id": "220003", "trip_headsign": "Pza.Pontevedra - Cocheiras" }, + { "shape_id": "230001", "trip_headsign": "Abente y Lago" }, + { "shape_id": "230000", "trip_headsign": "Urb.Breogán" }, + { "shape_id": "230101", "trip_headsign": "Abente y Lago" }, + { "shape_id": "230100", "trip_headsign": "Urb.Breogán" }, + { "shape_id": "240002", "trip_headsign": "Zapateira - Vallesur" }, + { "shape_id": "240003", "trip_headsign": "Pza. Pontevedra" }, + { "shape_id": "240000", "trip_headsign": "Zapateira - O Carón" }, + { "shape_id": "240001", "trip_headsign": "Pza. Pontevedra" }, + { "shape_id": "245000", "trip_headsign": "Universidade" }, + { "shape_id": "245001", "trip_headsign": "Pza. Pontevedra" }, + { "shape_id": "245003", "trip_headsign": "Pza.Pontevedra - Cocheiras" } +] diff --git a/build_vitrasa b/build_vitrasa deleted file mode 160000 -Subproject e4d94dc353d50bdc4b179fff84df4fb864d3afd diff --git a/build_vitrasa/.gitignore b/build_vitrasa/.gitignore new file mode 100644 index 0000000..e70de83 --- /dev/null +++ b/build_vitrasa/.gitignore @@ -0,0 +1,2 @@ +.venv/ +*.zip
\ No newline at end of file diff --git a/build_vitrasa/LICENCE b/build_vitrasa/LICENCE new file mode 100644 index 0000000..4153cd3 --- /dev/null +++ b/build_vitrasa/LICENCE @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. diff --git a/build_vitrasa/README.md b/build_vitrasa/README.md new file mode 100644 index 0000000..bc7b4db --- /dev/null +++ b/build_vitrasa/README.md @@ -0,0 +1,31 @@ +# Generador de GTFS Vigo + +Este repositorio contiene un script para mejorar los datos del feed GTFS del transporte urbano de Vigo, España. El script descarga los datos oficiales del Open Data municipal y aplica pequeñas correcciones-mejoras. + +## Requisitos + +- Python 3.12 o superior y `requests`. Con [uv](https://docs.astral.sh/uv) no es necesario instalar dependencias manualmente. +- Clave API del Punto de Acceso Nacional (NAP) de España. Se puede obtener en su portal: <https://nap.transportes.gob.es> registrándose como consumidor de manera gratuita. + +## Uso + +1. Clona este repositorio: + + ```bash + git clone https://github.com/tpgalicia/gtfs-vigo.git + cd gtfs-vigo + ``` + +2. Ejecutar el script para generar el feed GTFS estático: + + ```bash + uv run build_static_feed.py + ``` + +El feed GTFS generado se guardará en `gtfs_vigo.zip`. + +## Licencia + +Este proyecto está cedido como software libre bajo licencia EUPL v1.2 o superior. Más información en el archivo [`LICENCE`](LICENCE) o en [Interoperable Europe](https://interoperable-europe.ec.europa.eu/collection/eupl). + +Los datos GTFS originales son propiedad del Concello de Vigo o su proveedor, cedidos bajo los [términos de uso de datos.vigo.org](https://datos.vigo.org/es/condiciones-de-uso-de-los-datos/). diff --git a/build_vitrasa/build_static_feed.py b/build_vitrasa/build_static_feed.py new file mode 100644 index 0000000..3b7fb7e --- /dev/null +++ b/build_vitrasa/build_static_feed.py @@ -0,0 +1,191 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "requests" +# ] +# /// + +from argparse import ArgumentParser +import csv +from datetime import date, datetime, timedelta +import json +import logging +import os +import shutil +import tempfile +import zipfile + +import requests + + +def get_rows(input_file: str) -> list[dict]: + rows: list[dict] = [] + + with open(input_file, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + if reader.fieldnames is None: + return [] + reader.fieldnames = [name.strip() for name in reader.fieldnames] + + for row in reader: + rows.append(row) + + return rows + + +if __name__ == "__main__": + parser = ArgumentParser() + parser.add_argument( + "--debug", + help="Enable debug logging", + action="store_true" + ) + + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + ) + + INPUT_GTFS_FD, INPUT_GTFS_ZIP = tempfile.mkstemp(suffix=".zip", prefix="vigo_in_") + INPUT_GTFS_PATH = tempfile.mkdtemp(prefix="vigo_in_") + OUTPUT_GTFS_PATH = tempfile.mkdtemp(prefix="vigo_out_") + OUTPUT_GTFS_ZIP = os.path.join(os.path.dirname(__file__), "gtfs_vigo.zip") + + FEED_URL = f"https://datos.vigo.org/data/transporte/gtfs_vigo.zip" + + logging.info(f"Downloading GTFS feed from '{FEED_URL}'...") + response = requests.get(FEED_URL) + with open(INPUT_GTFS_ZIP, "wb") as f: + f.write(response.content) + + # Unzip the GTFS feed + with zipfile.ZipFile(INPUT_GTFS_ZIP, "r") as zip_ref: + zip_ref.extractall(INPUT_GTFS_PATH) + + TRIPS_FILE = os.path.join(INPUT_GTFS_PATH, "trips.txt") + STOPS_FILE = os.path.join(INPUT_GTFS_PATH, "stops.txt") + ROUTES_FILE = os.path.join(INPUT_GTFS_PATH, "routes.txt") + + # Build calendar.txt from calendar_dates.txt + # infer each service's weekly pattern from which actual dates it ran on. + # The "reference weekday" is the weekday date with the most active services + # (i.e. the most likely normal working day, avoiding holidays). + # Saturday and Sunday services are inferred from the Saturday/Sunday dates present. + CALENDAR_DATES_FILE = os.path.join(INPUT_GTFS_PATH, "calendar_dates.txt") + + # service_id -> set of YYYYMMDD date strings (exception_type=1 only) + service_dates: dict[str, set[str]] = {} + for row in get_rows(CALENDAR_DATES_FILE): + if row.get("exception_type", "").strip() != "1": + continue + sid = row["service_id"].strip() + d = row["date"].strip() + service_dates.setdefault(sid, set()).add(d) + + logging.debug(f"Found {len(service_dates)} service IDs in calendar_dates.txt") + + def _parse_date(d: str) -> date: + return datetime.strptime(d, "%Y%m%d").date() + + all_dates: set[str] = {d for dates in service_dates.values() for d in dates} + + # Group dates by day-of-week (0=Mon … 6=Sun) + dates_by_dow: dict[int, list[str]] = {} + for d in all_dates: + dow = _parse_date(d).weekday() + dates_by_dow.setdefault(dow, []).append(d) + + saturday_dates: set[str] = set(dates_by_dow.get(5, [])) + sunday_dates: set[str] = set(dates_by_dow.get(6, [])) + weekday_dates: set[str] = set() + for _dow in range(5): + weekday_dates.update(dates_by_dow.get(_dow, [])) + + # Pick the weekday date where the most services run (most "normal" working day). + # Days with fewer services than others are likely public holidays. + weekday_svc_counts: dict[str, int] = { + d: sum(1 for dates in service_dates.values() if d in dates) + for d in weekday_dates + } + if weekday_svc_counts: + ref_weekday = max(weekday_svc_counts, key=weekday_svc_counts.__getitem__) + logging.info( + f"Reference weekday: {ref_weekday} " + f"({_parse_date(ref_weekday).strftime('%A')}) " + f"with {weekday_svc_counts[ref_weekday]} active services" + ) + else: + ref_weekday = None + logging.warning("No weekday dates found in calendar_dates.txt") + + feed_start = min(_parse_date(d) for d in all_dates) + feed_end = feed_start + timedelta(days=365) + + calendar_output_rows: list[dict] = [] + for sid, dates in service_dates.items(): + is_weekday = ref_weekday is not None and ref_weekday in dates + is_saturday = bool(dates & saturday_dates) + is_sunday = bool(dates & sunday_dates) + + if not is_weekday and not is_saturday and not is_sunday: + logging.warning(f"Service {sid!r} has no day-type classification, skipping") + continue + + wd = "1" if is_weekday else "0" + sat = "1" if is_saturday else "0" + sun = "1" if is_sunday else "0" + calendar_output_rows.append({ + "service_id": sid, + "monday": wd, + "tuesday": wd, + "wednesday": wd, + "thursday": wd, + "friday": wd, + "saturday": sat, + "sunday": sun, + # 2 days before feed start, so feeds published early don't mess it up + "start_date": (feed_start - timedelta(days=2)).strftime("%Y%m%d"), + "end_date": feed_end.strftime("%Y%m%d"), + }) + + logging.info(f"Generated {len(calendar_output_rows)} calendar.txt entries") + + # Copy every file in the feed except calendar_dates.txt / calendar.txt + # (we replace them with a freshly generated calendar.txt above) + for filename in os.listdir(INPUT_GTFS_PATH): + if not filename.endswith(".txt"): + continue + if filename in ("calendar_dates.txt", "calendar.txt"): + continue + + src_path = os.path.join(INPUT_GTFS_PATH, filename) + dest_path = os.path.join(OUTPUT_GTFS_PATH, filename) + shutil.copy(src_path, dest_path) + + CALENDAR_OUTPUT_FILE = os.path.join(OUTPUT_GTFS_PATH, "calendar.txt") + with open(CALENDAR_OUTPUT_FILE, "w", encoding="utf-8", newline="") as f: + fieldnames = [ + "service_id", "monday", "tuesday", "wednesday", "thursday", + "friday", "saturday", "sunday", "start_date", "end_date", + ] + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(calendar_output_rows) + + # Create a ZIP archive of the output GTFS + with zipfile.ZipFile(OUTPUT_GTFS_ZIP, "w", zipfile.ZIP_DEFLATED) as zipf: + for root, _, files in os.walk(OUTPUT_GTFS_PATH): + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, OUTPUT_GTFS_PATH) + zipf.write(file_path, arcname) + + logging.info( + f"GTFS data from feed has been zipped successfully at {OUTPUT_GTFS_ZIP}." + ) + os.close(INPUT_GTFS_FD) + os.remove(INPUT_GTFS_ZIP) + shutil.rmtree(INPUT_GTFS_PATH) + shutil.rmtree(OUTPUT_GTFS_PATH) diff --git a/build_xunta b/build_xunta deleted file mode 160000 -Subproject caf5806f7ff0bd78983631d32724c12999d0f3a diff --git a/build_xunta/.gitignore b/build_xunta/.gitignore new file mode 100644 index 0000000..ce296bc --- /dev/null +++ b/build_xunta/.gitignore @@ -0,0 +1,8 @@ +feed/ +__pycache__/ +*.pyc + +galicia-latest.osm.pbf +gtfs_xunta.zip +parroquias.geojson +.venv/
\ No newline at end of file diff --git a/build_xunta/LICENCE b/build_xunta/LICENCE new file mode 100644 index 0000000..5afc293 --- /dev/null +++ b/build_xunta/LICENCE @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version.
\ No newline at end of file diff --git a/build_xunta/LICENCE-MITRAMS.md b/build_xunta/LICENCE-MITRAMS.md new file mode 100644 index 0000000..577d471 --- /dev/null +++ b/build_xunta/LICENCE-MITRAMS.md @@ -0,0 +1,94 @@ +# Licencia de datos abiertos del Ministerio de Transportes y Movilidad Sostenible + +La presente Licencia de datos abiertos del Ministerio de Transportes y Movilidad Sostenible (en adelante, LDA) regula +la reutilización de los datos abiertos del Ministerio de Transportes y Movilidad Sostenible (en adelante, MITRAMS) en las +condiciones fijadas en este documento. Por la misma queda vinculada la persona, empresa, organización o entidad (en adelante, +agente reutilizador) que va a hacer uso de los documentos contenidos o descritos en la presente licencia o de cualquier dato derivado +del mismo y que sean definidos como datos de carácter abierto del MITRAMS. + +Las presentes condiciones generales definidas en esta licencia permiten la reutilización de los documentos sometidos a ellas +para fines comerciales y no comerciales + +El concepto de documento es el recogido en el Anexo de la Ley 37/2007, añadido por el art. único.13 de la Ley 18/2015, de 9 de julio de 16 de noviembre, +sobre reutilización de la información del sector público, por lo que comprende toda información o parte de ella, cualquiera que sea su soporte o forma +de expresión, sea esta textual, gráfica, sonora, visual o audiovisual, incluyendo los metadatos asociados y los datos contenidos con los niveles más +elevados de precisión y desagregación. No se considerarán documentos los programas informáticos que estén protegidos por la legislación específica aplicable a los mismos. + +Se entiende por reutilización el uso de documentos que obran en poder de las Administraciones y organismos del sector público (referido en el +artículo 2 de la modificación por la disposición final 13.1 de la Ley 9/2017, de 8 de noviembre a la precitada Ley 37/2007) por personas físicas +o jurídicas, con fines comerciales o no comerciales, siempre que dicho uso no constituya una actividad administrativa pública. Queda excluido de +este concepto el intercambio de documentos entre Administraciones y organismos del sector público en el ejercicio de las funciones públicas que +tengan atribuidas. La reutilización autorizada incluye, a modo ilustrativo, actividades como la copia, difusión, modificación, adaptación, +extracción, reordenación y combinación de la información. + +Esta autorización conlleva, asimismo, la cesión gratuita y no exclusiva de los derechos de propiedad intelectual, en su caso, correspondientes a +tales documentos, autorizándose la realización de actividades de reproducción, distribución, comunicación pública o transformación, necesarias +para desarrollar la actividad de reutilización autorizada, en cualquier modalidad y bajo cualquier formato, para todo el mundo y por el plazo +máximo permitido por la Ley. + +La presente licencia debe ser entendida pues como una licencia-tipo de las previstas en la letra b) del apartado 2 del artículo 4 de la precitada +Ley 37/2007, modificada por el art. único.2 de la Ley 18/2015, de 9 de julio. + +## Ámbito + +La LDA autoriza a: + +1. Descargar datos del MITRAMS. +2. Compartir (copiar, distribuir) los datos anteriores y obtenidos del MITRAMS ofreciendo los datos bajo el mismo tipo de licencia. +3. Los datos generados por el sector público pueden utilizarse como materia prima para servicios + de valor añadido y productos innovadores que impulsan la economía. En consecuencia, las obras derivadas añadiendo valor pueden ofrecerse + bajo licencias diferentes. + +## Restricciones + +Son de aplicación las siguientes restricciones a las condiciones generales para la reutilización de los documentos sometidos a ellas: + +1. El agente reutilizador tiene expresamente prohibido desnaturalizar el sentido de la información, estando obligado a: + 1. No manipular con mala fe ni falsear la información. + 2. Garantizar que la información mostrada en su sistema esté siempre + actualizada. + 3. No utilizar la información para menoscabar o dañar la imagen pública del MITRAMS. + 4. No utilizar la información en sitios en los que la información del MITRAMS pueda + relacionarse con actos ilegales o intenciones de sabotaje hacia el MITRAMS o hacia otras entidades, organizaciones o personas. +2. Debe citarse al MITRAMS como fuente de datos, especificando si son datos en bruto o explotados. En plataformas digitales; + webs, foros, blogs, apps, etcdebe quedar claramente la indicación Powered by MITRAMS" incluyendo enlace a la página + oficial del MITRAMS (<https://www.transportes.gob.es/>) +3. Deben conservarse, no alterarse ni suprimirse, los metadatos sobre la fecha de actualización y las condiciones de + reutilización aplicables incluidos, en su caso, como atributos complementarios a la información puesta a disposición para su reutilización. +4. No se podrá indicar, insinuar o sugerir que el MITRAMS, como titular de la información reutilizada, participa, patrocina o apoya + expresamente el producto final del agente reutilizador, salvo que el MITRAMS así lo autorice. +5. MITRAMS monitorizará el acceso realizado por parte de los sistemas del agente reutilizador. Si se detectara un acceso indebido o abusivo + de modo que pudiera penalizar los recursos de los sistemas de MITRAMS y comprometer la disponibilidad de los mismos por colapso o retardo, MITRAMS + podrá bloquear el acceso al identificador del agente reutilizador. + +## Garantías y responsabilidades + +1. Cada parte manifiesta y garantiza que tiene pleno poder para suscribir el presente Acuerdo. +2. MITRAMS se reserva el derecho de poder incluir cualquier modificación en sus sistemas de Datos Abiertos, + tanto en su interfaz de acceso y uso, como en su contenido y diseño. El agente reutilizador deberá realizar la + actualización y adaptación de su sistema para una correcta integración con la información de MITRAMS. +3. MITRAMS garantiza que posee plenos derechos sobre la titularidad y veracidad de los datos susceptibles de cesión a los que permite el acceso. +4. La utilización de los conjuntos de datos se realizará por parte del agente de reutilización bajo su propia cuenta + y riesgo, correspondiéndoles en exclusiva a ellos responder frente a terceros por los daños que pudieran derivarse de ella. +5. MITRAMS no será responsable del uso que de su información hagan los agentes reutilizadores ni tampoco de los daños sufridos o + pérdidas económicas que, de forma directa o indirecta, produzcan o puedan producir perjuicios económicos, materiales o sobre datos, + provocados por el uso de la información reutilizada. +6. MITRAMS no garantiza la continuidad en la puesta a disposición de la información reutilizable, ni en contenido ni en forma, + ni asume responsabilidades por cualquier error u omisión contenido en ellos. +7. Los datos se proporcionan tal y cómo están. No se puede garantizar que todos los datos sean estrictamente correctos. Cualquier perjuicio + que pudiera producir a un tercero por el uso de datos que no sean estrictamente correctos no podrá ser imputado al MITRAMS. La aceptación de + las condiciones de esta LDA exime de esta responsabilidad al MITRAMS. +8. El agente reutilizador se halla sometido a la normativa aplicable en materia de reutilización de la información del sector + público, incluyendo el régimen sancionador previsto en el artículo 11 de la Ley 37/2007, de 16 de noviembre, sobre reutilización + de la información del sector público. El incumplimiento de esta licencia podría dar lugar a acciones restrictivas por parte del MITRAMS. +9. La ley aplicable en caso de disputa o conflicto de interpretación de los términos que configuran este aviso legal, será la ley + española. Para la resolución de cualquier conflicto que pudiera surgir, el MITRAMS y el usuario de los servicios de puesta a disposición + de los documentos reutilizables acuerdan someterse a los Jueces y Tribunales de Madrid. + +La presente licencia se entenderá sin perjuicio de las restricciones, garantías y responsabilidades que, en su caso, pudieran haber sido establecidas +por la licencia de uso de datos de la fuente original de la que proviene cada conjunto de datos recogido en el Punto de Acceso Nacional de Transporte +Multimodal, que también serán de aplicación, prevaleciendo en caso de discrepancia la más restrictiva de ambas licencias. + +--- + +Adaptado del texto HTML disponible en <https://nap.transportes.gob.es/licencia-datos> diff --git a/build_xunta/README.md b/build_xunta/README.md new file mode 100644 index 0000000..67f8125 --- /dev/null +++ b/build_xunta/README.md @@ -0,0 +1,34 @@ +# Feed GTFS mejorado de la Xunta de Galicia + +Este repositorio contendrá un feed GTFS (General Transit Feed Specification) mejorado a partir del feed oficial de la Xunta de Galicia, publicado en el [Punto de Acceso Nacional](https://nap.transportes.gob.es/Files/Detail/1386) del Ministerio de Transportes y Movilidad Sostenible de España. + +## Mejoras que se realizan + +- **Restricciones de tráfico**: Se marcan las paradas del concello de salida como "solo subida", y las del concello de llegada como "solo bajada", cuando uno u otro son A Coruña, Lugo, Ourense, Santiago o Vigo. De este modo se reduce la probabilidad de calcular rutas que no se pueden realizar por prohibiciones de tráfico (que corresponde al transporte urbano). +- **Añadir nombre de parroquia y concello**: Se añade al campo `stop_desc` el nombre de la parroquia y concello donde se ubica la parada, con datos de OpenStreetMap, separados por ` -- ` para su más fácil transformación y uso en otras aplicaciones. En algunos casos la parroquia puede ser igual al Concello donde se encuentra. Ejemplos: `Salcedo -- Pontevedra`, `Elviña -- A Coruña`. +- **Separación de rutas en agencias**: Se crean agencias separadas para cada operador, asignando las rutas correspondientes a estas. Este proceso incluye añadir los datos manualmente en [agency_mappings.json](./agency_mappings.json) a partir de los adjudicatarios, con sus colores de marca e información de contacto (para aquel cuya web tenga datos más detallados sobre el servicio). Las adjudicaciones están disponibles en estos 4 expedientes de Contratos de Galicia: + - [XG600-XG743](https://www.contratosdegalicia.gal/licitacion?OP=50&N=501362&lang=gl) + - [XG603, XG630, XG641, XG686](https://www.contratosdegalicia.gal/licitacion?OP=50&N=573083&lang=gl) + - [XG800-XG891](https://www.contratosdegalicia.gal/licitacion?OP=50&N=640920&lang=gl) + - [XG635](https://www.contratosdegalicia.gal/licitacion?OP=50&N=823020&lang=gl) + +## Mejoras planificadas + +Las mejoras previstas incluyen: + +- **Uso de nomenclaturas de líneas de los operadores**: Además, o en lugar de, utilizar la nomenclatura oficial de la Xunta para las líneas `XG<contrato><línea>`, se emplearán las nomenclaturas utilizadas por los operadores en caso de haberlos. Por ejemplo, las líneas operadas por ALSA en los entornos de A Coruña y Ferrol, Lugove en el Val Miñor/Baixo Miño; y Autocares Rías Baixas en Pontevedra. +- **Datos sobre tarifas y precios**: Se añadirán datos relacionados con las tarifas y precios de los billetes, a partir de la información que proporciona la Xunta en Excel y el portal <https://bus.gal>. + +## Mejoras no planificadas + +No se planea modificar la información de líneas, recorridos, horarios o paradas, dado que estos datos están sujetos a variación por la administración y es una cantidad inmensa de mejoras que habría que realizar, y que no puede ser hecha mediante scripts automáticos. + +## Contribuciones + +Las contribuciones son bienvenidas. Si deseas colaborar en la mejora del feed GTFS, por favor abre un issue o envía un pull request con tus propuestas o cambios. + +## Licencia + +El código propio de este proyecto está bajo la [European Union Public License v1.2 o posterior](./LICENCE). El feed GTFS original y el proporcionado por este proyecto están sujetos a la licencia del feed original, disponible en el archivo [`LICENCE-MITRAMS.md`](LICENCE-MITRAMS.md). + +Este repositorio utiliza datos de OpenStreetMap, que están bajo licencia [Open Data Commons Open Database License (ODbL)](https://opendatacommons.org/licenses/odbl/) y pueden requerir dar crédito por su uso. diff --git a/build_xunta/agency_mappings.json b/build_xunta/agency_mappings.json new file mode 100644 index 0000000..ced6581 --- /dev/null +++ b/build_xunta/agency_mappings.json @@ -0,0 +1,1019 @@ +{ + "XG600": { + "agency_name": "Operador XG600", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG601": { + "agency_name": "Operador XG601", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG602": { + "agency_name": "Operador XG602", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG604": { + "agency_name": "Autos González/Monbus", + "agency_email": "autocares@autosgonzalez.com", + "agency_phone": "+34 900 441 222", + "agency_url": "https://www.autosgonzalez.com/", + "route_color": "8B7CB4", + "route_text_color": "FFFFFF" + }, + "XG605": { + "agency_name": "Operador XG605", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG610": { + "agency_name": "Operador XG610", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG611": { + "agency_name": "Operador XG611", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG612": { + "agency_name": "Operador XG612", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG613": { + "agency_name": "Operador XG613", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG614": { + "agency_name": "Operador XG614", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG615": { + "agency_name": "Operador XG615", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG618": { + "agency_name": "Operador XG618", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG620": { + "agency_name": "Operador XG620", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG621": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG622": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG623": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG624": { + "agency_name": "Autos González/Monbus", + "agency_email": "autocares@autosgonzalez.com", + "agency_phone": "+34 900 441 222", + "agency_url": "https://www.autosgonzalez.com/", + "route_color": "8B7CB4", + "route_text_color": "FFFFFF" + }, + "XG625": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG626": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG627": { + "agency_name": "Abalo/Monbus", + "agency_email": "autocares@autocaresabalo.com", + "agency_phone": "+34 986 540 101", + "agency_url": "https://autocaresabalo.com/", + "route_color": "203584", + "route_text_color": "FFFFFF" + }, + "XG628": { + "agency_name": "Rías Baixas/Monbus", + "agency_email": "arbp@autocaresriasbaixas.com", + "agency_phone": "+34 986 856 622", + "agency_url": "https://www.autocaresriasbaixas.com/", + "route_color": "CF142B", + "route_text_color": "FFFFFF" + }, + "XG632": { + "agency_name": "Operador XG632", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG633": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG634": { + "agency_name": "Operador XG634", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG635": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG636": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG637": { + "agency_name": "Operador XG637", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG638": { + "agency_name": "Operador XG638", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG639": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG640": { + "agency_name": "Operador XG640", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG642": { + "agency_name": "ALSA Ferrol", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://www.alsaferrol.es/", + "route_color": "3FC8EB", + "route_text_color": "FFFFFF" + }, + "XG643": { + "agency_name": "Operador XG643", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG644": { + "agency_name": "Operador XG644", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG645": { + "agency_name": "Operador XG645", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG646": { + "agency_name": "Operador XG646", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG647": { + "agency_name": "Operador XG647", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG648": { + "agency_name": "Operador XG648", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG649": { + "agency_name": "Operador XG649", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG651": { + "agency_name": "Operador XG651", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG654": { + "agency_name": "Operador XG654", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG656": { + "agency_name": "Operador XG656", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG658": { + "agency_name": "Rías Baixas", + "agency_email": "arbp@autocaresriasbaixas.com", + "agency_phone": "+34 986 856 622", + "agency_url": "https://www.autocaresriasbaixas.com/", + "route_color": "CF142B", + "route_text_color": "FFFFFF" + }, + "XG659": { + "agency_name": "Operador XG659", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG660": { + "agency_name": "Operador XG660", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG661": { + "agency_name": "Operador XG661", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG662": { + "agency_name": "Rías Baixas", + "agency_email": "arbp@autocaresriasbaixas.com", + "agency_phone": "+34 986 856 622", + "agency_url": "https://www.autocaresriasbaixas.com/", + "route_color": "CF142B", + "route_text_color": "FFFFFF" + }, + "XG663": { + "agency_name": "Operador XG663", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG664": { + "agency_name": "Operador XG664", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG665": { + "agency_name": "Operador XG665", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG666": { + "agency_name": "Operador XG666", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG667": { + "agency_name": "Operador XG667", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG668": { + "agency_name": "Operador XG668", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG669": { + "agency_name": "Operador XG669", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG670": { + "agency_name": "Operador XG670", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG671": { + "agency_name": "Operador XG671", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG672": { + "agency_name": "Operador XG672", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG673": { + "agency_name": "Operador XG673", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG676": { + "agency_name": "Operador XG676", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG677": { + "agency_name": "Operador XG677", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG680": { + "agency_name": "Operador XG680", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG681": { + "agency_name": "Operador XG681", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG682": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG684": { + "agency_name": "Operador XG684", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG685": { + "agency_name": "Operador XG685", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG687": { + "agency_name": "Operador XG687", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG688": { + "agency_name": "Operador XG688", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG689": { + "agency_name": "Operador XG689", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG690": { + "agency_name": "Operador XG690", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG691": { + "agency_name": "Operador XG691", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG692": { + "agency_name": "Operador XG692", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG695": { + "agency_name": "Rías Baixas", + "agency_email": "arbp@autocaresriasbaixas.com", + "agency_phone": "+34 986 856 622", + "agency_url": "https://www.autocaresriasbaixas.com/", + "route_color": "CF142B", + "route_text_color": "FFFFFF" + }, + "XG696": { + "agency_name": "Operador XG696", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG698": { + "agency_name": "Operador XG698", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG699": { + "agency_name": "Rías Baixas", + "agency_email": "arbp@autocaresriasbaixas.com", + "agency_phone": "+34 986 856 622", + "agency_url": "https://www.autocaresriasbaixas.com/", + "route_color": "CF142B", + "route_text_color": "FFFFFF" + }, + "XG701": { + "agency_name": "Operador XG701", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG703": { + "agency_name": "Operador XG703", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG705": { + "agency_name": "Operador XG705", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG706": { + "agency_name": "Operador XG706", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG707": { + "agency_name": "Operador XG707", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG708": { + "agency_name": "Operador XG708", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG709": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG714": { + "agency_name": "Operador XG714", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG717": { + "agency_name": "Operador XG717", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG719": { + "agency_name": "ALSA-Autos Rodríguez Eocar", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://eocar.es/", + "route_color": "FF0000 ", + "route_text_color": "FFFFFF" + }, + "XG720": { + "agency_name": "Operador XG720", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG721": { + "agency_name": "Operador XG721", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG723": { + "agency_name": "Operador XG723", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG724": { + "agency_name": "Operador XG724", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG727": { + "agency_name": "Operador XG727", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG728": { + "agency_name": "Operador XG728", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG729": { + "agency_name": "Operador XG729", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG730": { + "agency_name": "Operador XG730", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG732": { + "agency_name": "Operador XG732", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG743": { + "agency_name": "Operador XG743", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG800": { + "agency_name": "Grabanxa", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "FF7A01", + "route_text_color": "FFFFFF" + }, + "XG802": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "212449", + "route_text_color": "000000" + }, + "XG804": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG807": { + "agency_name": "Monbus/Seoane/Abalo", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "FDC609", + "route_text_color": "000000" + }, + "XG811": { + "agency_name": "Operador XG811", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG813": { + "agency_name": "Operador XG813", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG814": { + "agency_name": "Abalo/Rías Baixas", + "agency_email": "autocares@autocaresabalo.com", + "agency_phone": "+34 986 540 101", + "agency_url": "https://autocaresabalo.com/", + "route_color": "203584", + "route_text_color": "FFFFFF" + }, + "XG817": { + "agency_name": "Monbus", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "212449", + "route_text_color": "000000" + }, + "XG830": { + "agency_name": "Operador XG830", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG833": { + "agency_name": "Operador XG833", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG835": { + "agency_name": "García Castro", + "agency_email": "info@autocaresgarciacastro.com", + "agency_phone": "+34 629 039 544", + "agency_url": "https://autocaresgarciacastro.com/", + "route_color": "00213B", + "route_text_color": "FFFFFF" + }, + "XG843": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG845": { + "agency_name": "Operador XG845", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG846": { + "agency_name": "Operador XG846", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG847": { + "agency_name": "Operador XG847", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG848": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG852": { + "agency_name": "Cerqueiro", + "agency_email": "cangas@autobusescerqueiro.com", + "agency_phone": "+34 986 320 254 ", + "agency_url": "https://autobusescerqueiro.com/", + "route_color": "C10707", + "route_text_color": "FFFFFF" + }, + "XG859": { + "agency_name": "Lugove", + "agency_email": "cliente@lugove.gal", + "agency_phone": "+34 986 608 045", + "agency_url": "https://lugove.gal", + "route_color": "18A1DF", + "route_text_color": "FFFFFF" + }, + "XG860": { + "agency_name": "Lázara/Rías Baixas", + "agency_email": "info@autocareslazara.es", + "agency_phone": "+34 986 580 485", + "agency_url": "https://autocareslazara.eu/", + "route_color": "3d107b", + "route_text_color": "FFFFFF" + }, + "XG863": { + "agency_name": "Lázara/Rías Baixas", + "agency_email": "info@autocareslazara.es", + "agency_phone": "+34 986 580 485", + "agency_url": "https://autocareslazara.eu/", + "route_color": "3d107b", + "route_text_color": "FFFFFF" + }, + "XG871": { + "agency_name": "Monbus-Hércules Norte", + "agency_email": "info@monbus.es", + "agency_phone": "+34 900 929 192", + "agency_url": "https://monbus.es", + "route_color": "212449", + "route_text_color": "000000" + }, + "XG872": { + "agency_name": "Operador XG872", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG881": { + "agency_name": "ALSA-Cal Pita", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://www.alsacalpita.es/", + "route_color": "3FC8EB", + "route_text_color": "FFFFFF" + }, + "XG883": { + "agency_name": "Lugove", + "agency_email": "cliente@lugove.gal", + "agency_phone": "+34 986 608 045", + "agency_url": "https://lugove.gal", + "route_color": "18A1DF", + "route_text_color": "FFFFFF" + }, + "XG884": { + "agency_name": "Operador XG884", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG888": { + "agency_name": "Ojea/Monbus", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 986 640 846", + "agency_url": "https://empresaojea.com", + "route_color": "006600", + "route_text_color": "FFFFFF" + }, + "XG889": { + "agency_name": "Operador XG889", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG890": { + "agency_name": "Operador XG890", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG891": { + "agency_name": "Operador XG891", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG603": { + "agency_name": "Operador XG603", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG630": { + "agency_name": "Operador XG630", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + }, + "XG641": { + "agency_name": "Arriva", + "agency_email": "cliente@arriva.es", + "agency_phone": "+34 981 311 213", + "agency_url": "https://arriva.es/es/galicia", + "route_color": "00BECD", + "route_text_color": "000000" + }, + "XG686": { + "agency_name": "Operador XG686", + "agency_email": "baixodemanda@xunta.gal", + "agency_phone": "+34 981 546 100", + "agency_url": "https://bus.gal", + "route_color": "007BC4", + "route_text_color": "FFFFFF" + } + +} diff --git a/build_xunta/build_static_feed.py b/build_xunta/build_static_feed.py new file mode 100644 index 0000000..9c2a3b1 --- /dev/null +++ b/build_xunta/build_static_feed.py @@ -0,0 +1,364 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "requests", +# "shapely", +# "tqdm", +# ] +# /// + +from argparse import ArgumentParser +from collections import defaultdict +import csv +import json +import logging +import os +import shutil +import tempfile +import zipfile +from pathlib import Path + +import requests +from shapely.geometry import Point, shape +from shapely.strtree import STRtree +from tqdm import tqdm + + +def _load_boundaries(path: Path) -> tuple[ + dict[str, dict], # muni_by_ine: {ine_5 -> {shape, props}} + dict[str, list[dict]], # parishes_by_muni: {ine_5 -> [{shape, props}, ...]} +]: + logging.info("Loading boundaries from %s …", path) + with open(path, encoding="utf-8") as fh: + geojson = json.load(fh) + + muni_by_ine: dict[str, dict] = {} + parishes_by_muni: dict[str, list] = defaultdict(list) + + for feature in geojson["features"]: + props = feature["properties"] + geom = shape(feature["geometry"]) + level = props["admin_level"] + ine_muni = props.get("ine_muni", "") + + if level == 8: + if ine_muni: + muni_by_ine[ine_muni] = {"shape": geom, "props": props} + elif level == 9: + ref_ine = props.get("ref_ine", "") + parent_ine = ref_ine[:5] if ref_ine else ine_muni + if parent_ine: + parishes_by_muni[parent_ine].append({"shape": geom, "props": props}) + + logging.info( + "Loaded %d municipalities, %d parishes grouped into %d municipalities.", + len(muni_by_ine), + sum(len(v) for v in parishes_by_muni.values()), + len(parishes_by_muni), + ) + return muni_by_ine, dict(parishes_by_muni) + + +def _build_parish_trees( + parishes_by_muni: dict[str, list[dict]], +) -> dict[str, tuple[STRtree, list[dict]]]: + trees: dict[str, tuple[STRtree, list[dict]]] = {} + for ine, parish_list in parishes_by_muni.items(): + geoms = [p["shape"] for p in parish_list] + trees[ine] = (STRtree(geoms), parish_list) + return trees + + +def _find_parish( + point: Point, + ine_muni: str, + parish_trees: dict[str, tuple[STRtree, list[dict]]], +) -> dict | None: + entry = parish_trees.get(ine_muni) + if entry is None: + return None + tree, parish_list = entry + hits = tree.query(point, predicate="intersects") + if len(hits) == 0: + return None + if len(hits) == 1: + return parish_list[hits[0]]["props"] + best = min(hits, key=lambda i: parish_list[i]["shape"].centroid.distance(point)) + return parish_list[best]["props"] + + +def build_stop_desc( + stop: dict, + muni_by_ine: dict[str, dict], + parish_trees: dict[str, tuple[STRtree, list[dict]]], +) -> str: + """Return a stop_desc string of the form 'Parish (Municipality)', or an + empty string if neither can be resolved.""" + zone_id = stop.get("zone_id", "") + ine_muni = zone_id[:5] if len(zone_id) >= 5 else "" + + muni_entry = muni_by_ine.get(ine_muni) if ine_muni else None + muni_name = muni_entry["props"]["name"] if muni_entry else "" + + try: + lat = float(stop["stop_lat"]) + lon = float(stop["stop_lon"]) + except ValueError: + return muni_name + + parish_props = _find_parish(Point(lon, lat), ine_muni, parish_trees) + parish_name = parish_props["name"] if parish_props else "" + + if parish_name and muni_name: + return f"{parish_name} -- {muni_name}" + return parish_name or muni_name + + +if __name__ == "__main__": + parser = ArgumentParser( + description="Build static GTFS feed for Galicia (Xunta) with parish/municipality stop descriptions." + ) + parser.add_argument( + "nap_apikey", + type=str, + help="NAP API Key (https://nap.transportes.gob.es/)" + ) + parser.add_argument( + "--boundaries", + type=Path, + default=Path(os.path.join(os.path.dirname(__file__), "parroquias.geojson")), + help="Path to the boundaries GeoJSON produced by gen_parroquias.py " + "(default: parroquias.geojson next to this script).", + ) + parser.add_argument( + "--debug", + help="Enable debug logging", + action="store_true" + ) + + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + ) + + # Boundaries + muni_by_ine, parishes_by_muni = _load_boundaries(args.boundaries) + logging.info("Building per-municipality parish trees …") + parish_trees = _build_parish_trees(parishes_by_muni) + + # Download & unpack feed + INPUT_GTFS_FD, INPUT_GTFS_ZIP = tempfile.mkstemp(suffix=".zip", prefix="xunta_in_") + INPUT_GTFS_PATH = tempfile.mkdtemp(prefix="xunta_in_") + OUTPUT_GTFS_PATH = tempfile.mkdtemp(prefix="xunta_out_") + OUTPUT_GTFS_ZIP = os.path.join(os.path.dirname(__file__), "gtfs_xunta.zip") + + FEED_URL = "https://nap.transportes.gob.es/api/Fichero/download/1584" + + logging.info("Downloading GTFS feed...") + response = requests.get(FEED_URL, headers={"ApiKey": args.nap_apikey}) + response.raise_for_status() + with open(INPUT_GTFS_ZIP, "wb") as f: + f.write(response.content) + + with zipfile.ZipFile(INPUT_GTFS_ZIP, "r") as zip_ref: + zip_ref.extractall(INPUT_GTFS_PATH) + + STOPS_FILE = os.path.join(INPUT_GTFS_PATH, "stops.txt") + STOP_TIMES_FILE = os.path.join(INPUT_GTFS_PATH, "stop_times.txt") + TRIPS_FILE = os.path.join(INPUT_GTFS_PATH, "trips.txt") + + # Copy unchanged files + for filename in ["trips.txt", + "calendar.txt", "calendar_dates.txt", + "shapes.txt"]: + src = os.path.join(INPUT_GTFS_PATH, filename) + if os.path.exists(src): + shutil.copy(src, os.path.join(OUTPUT_GTFS_PATH, filename)) + else: + logging.debug("File %s not present in the input feed, skipping.", filename) + + # Load agency list + AGENCY_MAPPINGS_JSON_FILE = Path(os.path.join(os.path.dirname(__file__), "agency_mappings.json")) + with open(AGENCY_MAPPINGS_JSON_FILE, encoding="utf-8") as f: + agency_mappings: dict[str, dict[str, str]] = json.load(f) + + with open(os.path.join(OUTPUT_GTFS_PATH, "agency.txt"), "w", encoding="utf-8", newline="") as agency_out: + fieldnames = ["agency_id", "agency_name", "agency_url", "agency_email", + "agency_phone", "agency_timezone", "agency_lang"] + writer = csv.DictWriter(agency_out, fieldnames=fieldnames) + writer.writeheader() + for agency_id, mapping in agency_mappings.items(): + writer.writerow({ + "agency_id": agency_id, + "agency_name": mapping["agency_name"], + "agency_url": mapping["agency_url"], + "agency_email": mapping["agency_email"], + "agency_phone": mapping["agency_phone"], + "agency_timezone": "Europe/Madrid", + "agency_lang": "es", + }) + + # Load routes, mapping to agency_id by first 5 chars of route_short_name, and apply route_color/route_text_color from the mapping if available + with open(os.path.join(INPUT_GTFS_PATH, "routes.txt"), encoding="utf-8-sig", newline="") as routes_fh: + reader = csv.DictReader(routes_fh) + routes = list(reader) + route_fieldnames = set(reader.fieldnames or routes[0].keys()) + + for route in routes: + short_name = route.get("route_short_name", "") + agency_key = short_name[:5] if len(short_name) >= 5 else "" + + mapping = agency_mappings.get(agency_key, None) + route["agency_id"] = agency_key if mapping else "unknown" + if route["agency_id"] == "unknown": + logging.error("Route %s: could not determine agency_id from route_short_name '%s'.", route["route_id"], short_name) + continue + if mapping is None: + logging.error("Route %s: no agency mapping found for key '%s'.", route["route_id"], agency_key) + continue + + if "route_color" in mapping: + route["route_color"] = mapping["route_color"] + route_fieldnames.add("route_color") + if "route_text_color" in mapping: + route["route_text_color"] = mapping["route_text_color"] + route_fieldnames.add("route_text_color") + + with open(os.path.join(OUTPUT_GTFS_PATH, "routes.txt"), "w", encoding="utf-8", newline="") as routes_out: + writer = csv.DictWriter(routes_out, fieldnames=route_fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(routes) + + # Build stops.txt with stop_desc + logging.info("Enriching stops with parish/municipality descriptions …") + with open(STOPS_FILE, encoding="utf-8-sig", newline="") as in_fh: + reader = csv.DictReader(in_fh) + stops = list(reader) + base_fieldnames = list(reader.fieldnames or stops[0].keys()) + + unmatched = 0 + for stop in tqdm(stops, desc="Enriching stops", unit="stop"): + desc = build_stop_desc(stop, muni_by_ine, parish_trees) + stop["stop_desc"] = desc + if not desc: + unmatched += 1 + logging.debug("Stop %s: could not resolve parish/municipality.", stop["stop_id"]) + + if unmatched: + logging.warning("%d stops (%.1f%%) could not be matched to a parish/municipality.", + unmatched, 100 * unmatched / len(stops)) + + out_fieldnames = base_fieldnames if "stop_desc" in base_fieldnames else base_fieldnames + ["stop_desc"] + with open(os.path.join(OUTPUT_GTFS_PATH, "stops.txt"), "w", + encoding="utf-8", newline="") as out_fh: + writer = csv.DictWriter(out_fh, fieldnames=out_fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(stops) + + logging.info("stops.txt written with stop_desc for %d stops.", len(stops)) + + # Interurban lines may not pick up or drop off passengers within cities that + # have their own urban network. The rule is applied per trip: + # - If the FIRST stop is in a restricted municipality, all consecutive + # stops in that municipality (from the start) are marked pickup-only + # (dropoff_type=1) until the first stop outside it. + # - If the LAST stop is in a restricted municipality, all consecutive + # stops in that municipality (from the end) are marked dropoff-only + # (pickup_type=1) until the last stop outside it. + # - Stops in restricted municipalities that appear only in the middle of + # a trip are left with regular pickup/dropoff. + RESTRICTED_MUNIS = {"15030", "27028", "32054", "15078", "36057"} + + # Build stop_id -> INE code dict from the already-loaded stops (O(1) lookups) + stop_ine: dict[str, str] = {} + for stop in stops: + zone_id = stop.get("zone_id", "") + stop_ine[stop["stop_id"]] = zone_id[:5] if len(zone_id) >= 5 else "" + + logging.info("Applying traffic restrictions for municipalities: %s …", + ", ".join(sorted(RESTRICTED_MUNIS))) + + with open(STOP_TIMES_FILE, encoding="utf-8-sig", newline="") as st_fh: + st_reader = csv.DictReader(st_fh) + all_stop_times = list(st_reader) + st_fieldnames = list(st_reader.fieldnames or all_stop_times[0].keys()) + + # Ensure pickup_type / dropoff_type columns exist (GTFS optional, default 0) + for col in ("pickup_type", "dropoff_type"): + if col not in st_fieldnames: + st_fieldnames.append(col) + for st in all_stop_times: + st.setdefault("pickup_type", "0") + st.setdefault("dropoff_type", "0") + + # Group by trip_id and sort each group by stop_sequence + trips_stop_times: dict[str, list[dict]] = defaultdict(list) + for st in all_stop_times: + trips_stop_times[st["trip_id"]].append(st) + for seq in trips_stop_times.values(): + seq.sort(key=lambda x: int(x["stop_sequence"])) + + restricted_trips = 0 + for seq in trips_stop_times.values(): + n = len(seq) + + # Prefix: how many consecutive stops from the START are in a restricted muni + prefix_end = 0 # exclusive end index + while prefix_end < n and stop_ine.get(seq[prefix_end]["stop_id"], "") in RESTRICTED_MUNIS: + prefix_end += 1 + + # Suffix: how many consecutive stops from the END are in a restricted muni + suffix_start = n - 1 # will become inclusive start index after adjustment + while suffix_start >= 0 and stop_ine.get(seq[suffix_start]["stop_id"], "") in RESTRICTED_MUNIS: + suffix_start -= 1 + suffix_start += 1 # inclusive start of the suffix run + + first_is_restricted = prefix_end > 0 + last_is_restricted = suffix_start < n + + if not first_is_restricted and not last_is_restricted: + continue + + # If prefix and suffix meet or overlap, the whole trip is within restricted + # munis (likely a purely urban service not subject to these rules) — skip. + if first_is_restricted and last_is_restricted and prefix_end >= suffix_start: + continue + + if first_is_restricted: + for st in seq[:prefix_end]: + st["pickup_type"] = "0" # regular pickup + st["drop_off_type"] = "1" # no dropoff + + if last_is_restricted: + for st in seq[suffix_start:]: + st["pickup_type"] = "1" # no pickup + st["drop_off_type"] = "0" # regular dropoff + + restricted_trips += 1 + + logging.info("Traffic restrictions applied to %d trips.", restricted_trips) + + with open(os.path.join(OUTPUT_GTFS_PATH, "stop_times.txt"), "w", + encoding="utf-8", newline="") as st_out_fh: + writer = csv.DictWriter(st_out_fh, fieldnames=st_fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(all_stop_times) + + # Package output ZIP + with zipfile.ZipFile(OUTPUT_GTFS_ZIP, "w", zipfile.ZIP_DEFLATED) as zipf: + for root, _, files in os.walk(OUTPUT_GTFS_PATH): + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, OUTPUT_GTFS_PATH) + zipf.write(file_path, arcname) + + logging.info("GTFS feed zipped to %s", OUTPUT_GTFS_ZIP) + + # Cleanup + os.close(INPUT_GTFS_FD) + os.remove(INPUT_GTFS_ZIP) + shutil.rmtree(INPUT_GTFS_PATH) + shutil.rmtree(OUTPUT_GTFS_PATH) + diff --git a/build_xunta/gen_parroquias.py b/build_xunta/gen_parroquias.py new file mode 100644 index 0000000..fa3984d --- /dev/null +++ b/build_xunta/gen_parroquias.py @@ -0,0 +1,172 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "osmium", +# "shapely", +# "requests", +# "tqdm", +# ] +# /// + +import json +import logging +import sys +from argparse import ArgumentParser +from pathlib import Path + +import osmium +import osmium.geom +import requests +from shapely.wkb import loads as wkb_loads +from tqdm import tqdm + +GEOFABRIK_URL = "https://download.geofabrik.de/europe/spain/galicia-latest.osm.pbf" +DEFAULT_PBF = "galicia-latest.osm.pbf" +DEFAULT_OUTPUT = "parroquias.geojson" + +_wkb_factory = osmium.geom.WKBFactory() + + +class _AdminBoundaryHandler(osmium.SimpleHandler): + """Collects administrative boundary areas at the requested admin levels.""" + + def __init__(self, admin_levels: set[str]) -> None: + super().__init__() + self.admin_levels = admin_levels + self.features: list[dict] = [] + self._geom_errors = 0 + + def area(self, a: osmium.osm.Area) -> None: # type: ignore[name-defined] + tags = a.tags + if tags.get("boundary") != "administrative": + return + level = tags.get("admin_level") + if level not in self.admin_levels: + return + + try: + wkb = _wkb_factory.create_multipolygon(a) + geom = wkb_loads(wkb, hex=True) + except Exception: + self._geom_errors += 1 + return + + ref_ine = tags.get("ref:ine", "") + self.features.append( + { + "type": "Feature", + "geometry": geom.__geo_interface__, + "properties": { + "osm_type": "way" if a.from_way() else "relation", + "osm_id": a.orig_id(), + "admin_level": int(level), + "name": tags.get("name", ""), + "name_gl": tags.get("name:gl", ""), + # ref:ine full code (e.g. "15017000000" for a municipality, + # "15017030000" for a parish). First 5 chars are always the + # 5-digit INE municipality code (PP+MMM). + "ref_ine": ref_ine, + "ine_muni": tags.get("ine:municipio", ref_ine[:5] if ref_ine else ""), + "wikidata": tags.get("wikidata", ""), + }, + } + ) + + +def _download_pbf(url: str, dest: Path) -> None: + """Stream-download *url* to *dest*, showing a progress bar. + + Skips the download silently if *dest* already exists. + """ + if dest.exists(): + logging.info("PBF already present at %s — skipping download.", dest) + return + + logging.info("Downloading %s …", url) + with requests.get(url, stream=True, timeout=60) as resp: + resp.raise_for_status() + total = int(resp.headers.get("content-length", 0)) + with open(dest, "wb") as fh, tqdm( + total=total, unit="B", unit_scale=True, desc=dest.name + ) as bar: + for chunk in resp.iter_content(chunk_size=1 << 20): + fh.write(chunk) + bar.update(len(chunk)) + + logging.info("Download complete: %s (%.1f MB)", dest, dest.stat().st_size / 1e6) + + +def main() -> None: + parser = ArgumentParser( + description=( + "Extract Galician parish (admin_level=9) and municipality " + "(admin_level=8) boundaries from an OSM PBF file." + ) + ) + parser.add_argument( + "--pbf", + type=Path, + default=Path(DEFAULT_PBF), + help=f"Path to OSM PBF file. Downloaded from Geofabrik if absent " + f"(default: {DEFAULT_PBF}).", + ) + parser.add_argument( + "--output", + type=Path, + default=Path(DEFAULT_OUTPUT), + help=f"Output GeoJSON file (default: {DEFAULT_OUTPUT}).", + ) + parser.add_argument( + "--no-download", + action="store_true", + help="Do not attempt to download the PBF; fail if it is missing.", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable debug logging.", + ) + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + ) + + if not args.no_download: + _download_pbf(GEOFABRIK_URL, args.pbf) + + if not args.pbf.exists(): + logging.error("PBF file not found: %s", args.pbf) + sys.exit(1) + + logging.info("Parsing admin boundaries from %s …", args.pbf) + handler = _AdminBoundaryHandler(admin_levels={"8", "9"}) + handler.apply_file(str(args.pbf), locations=True, idx="flex_mem") + + n = len(handler.features) + logging.info( + "Found %d boundary features (%d geometry errors skipped).", + n, + handler._geom_errors, + ) + if n == 0: + logging.warning( + "No boundaries found — check that the PBF covers Galicia and " + "contains boundary=administrative relations at admin_level 8/9." + ) + + geojson = { + "type": "FeatureCollection", + "features": handler.features, + } + + args.output.write_text( + json.dumps(geojson, ensure_ascii=False, indent=None), + encoding="utf-8", + ) + logging.info("Saved %d features to %s", n, args.output) + + +if __name__ == "__main__": + main() diff --git a/custom_feeds/lugo.zip b/custom_feeds/lugo.zip Binary files differnew file mode 100644 index 0000000..4ddcd29 --- /dev/null +++ b/custom_feeds/lugo.zip diff --git a/custom_feeds/ourense.zip b/custom_feeds/ourense.zip Binary files differnew file mode 100644 index 0000000..c872c89 --- /dev/null +++ b/custom_feeds/ourense.zip diff --git a/custom_feeds/shuttle.zip b/custom_feeds/shuttle.zip Binary files differnew file mode 100644 index 0000000..1000518 --- /dev/null +++ b/custom_feeds/shuttle.zip diff --git a/custom_feeds/tussa.zip b/custom_feeds/tussa.zip Binary files differnew file mode 100644 index 0000000..ee7cb45 --- /dev/null +++ b/custom_feeds/tussa.zip diff --git a/feeds/.gitignore b/feeds/.gitignore new file mode 100644 index 0000000..6f66c74 --- /dev/null +++ b/feeds/.gitignore @@ -0,0 +1 @@ +*.zip
\ No newline at end of file diff --git a/feeds/.gitkeep b/feeds/.gitkeep deleted file mode 100644 index e69de29..0000000 --- a/feeds/.gitkeep +++ /dev/null |
