diff --git a/.cache/bernReligionMap-de-2026-01-01-2026-04-30.png b/.cache/bernReligionMap-de-2026-01-01-2026-04-30.png new file mode 100644 index 0000000..6d3a158 Binary files /dev/null and b/.cache/bernReligionMap-de-2026-01-01-2026-04-30.png differ diff --git a/.cache/bernReligionMap-fr-2026-01-01-2026-04-30.png b/.cache/bernReligionMap-fr-2026-01-01-2026-04-30.png new file mode 100644 index 0000000..74b6c64 Binary files /dev/null and b/.cache/bernReligionMap-fr-2026-01-01-2026-04-30.png differ diff --git a/.github/workflows/build-feeds.yml b/.github/workflows/build-feeds.yml index a4c9836..57ab063 100644 --- a/.github/workflows/build-feeds.yml +++ b/.github/workflows/build-feeds.yml @@ -16,6 +16,11 @@ on: required: false default: "false" type: boolean + forceGenerationBernPlacesOfWorship: + description: "force feed generation of places of worship per religion in Bern" + required: false + default: "false" + type: boolean jobs: build: @@ -92,6 +97,31 @@ jobs: fi git commit -m "feeds: asian hornets sighting in Bern update $(date -u +%Y-%m-%d)" + - name: Generate feeds for places of worship per religion in Bern + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.verbose }}" = "true" ]; then + echo "Running in VERBOSE mode" + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.forceGenerationBernPlacesOfWorship }}" = "true" ]; then + resspublica --force_gen_bernReligionMap --verbose + else + resspublica --gen_bernReligionMap --verbose + fi + else + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.forceGenerationBernPlacesOfWorship }}" = "true" ]; then + resspublica --force_gen_bernReligionMap + else + resspublica --gen_bernReligionMap + fi + fi + git add feed/ .cache/ assets/ + + if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 + fi + git commit -m "feeds: places of worship per Religion in Bern update $(date -u +%Y-%m-%d)" + + - name: Push changes run: | git push origin main diff --git a/README.md b/README.md index 578f342..5d0ed25 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,9 @@ AI (ChatGPT 5.5 and Deepl) was used in this project for : * crafting the SPARQL requests (a language I had never heard of before this project) * minor debugging when alternative methods did not solve the problems. * German, Italian and Romansh translations. -* counselling the right python libraries to use +* help with Python libraries + +All code was reviewed by a human. ## Contributing diff --git a/feed/atom/de/gotteshauserNachReligionImKantonBern.atom b/feed/atom/de/gotteshauserNachReligionImKantonBern.atom new file mode 100644 index 0000000..cef6ed1 --- /dev/null +++ b/feed/atom/de/gotteshauserNachReligionImKantonBern.atom @@ -0,0 +1,12 @@ + + + https://raw.githubusercontent.com/tomasriveral/ReSSPublica/refs/heads/main/feed/atom/de/gotteshauserNachReligionImKantonBern + Gotteshäuser nach Religion im Kanton Bern + 2026-06-17T21:51:13.499795+02:00 + + ReSSPublica + + + python-feedgen + RSS-Feed der Gotteshäuser nach Religion im Kanton Bern + diff --git a/feed/atom/fr/lieuDeCulteParReligionDansLeCantonDeBerne.atom b/feed/atom/fr/lieuDeCulteParReligionDansLeCantonDeBerne.atom new file mode 100644 index 0000000..6ccd24c --- /dev/null +++ b/feed/atom/fr/lieuDeCulteParReligionDansLeCantonDeBerne.atom @@ -0,0 +1,12 @@ + + + https://raw.githubusercontent.com/tomasriveral/ReSSPublica/refs/heads/main/feed/atom/fr/lieuDeCulteParReligionDansLeCantonDeBerne + Lieu de culte par religion dans le canton de Berne + 2026-06-17T21:51:13.497469+02:00 + + ReSSPublica + + + python-feedgen + Flux RSS des Lieu de culte par religion dans le canton de Berne + diff --git a/feed/rss/de/gotteshauserNachReligionImKantonBern.xml b/feed/rss/de/gotteshauserNachReligionImKantonBern.xml new file mode 100644 index 0000000..57bf12d --- /dev/null +++ b/feed/rss/de/gotteshauserNachReligionImKantonBern.xml @@ -0,0 +1,13 @@ + + + + Gotteshäuser nach Religion im Kanton Bern + https://raw.githubusercontent.com/tomasriveral/ReSSPublica/refs/heads/main/feed/atom/de/gotteshauserNachReligionImKantonBern.atom + RSS-Feed der Gotteshäuser nach Religion im Kanton Bern + + https://github.com/tomasriveral/ReSSPublica + python-feedgen + de + Wed, 17 Jun 2026 21:51:13 +0200 + + diff --git a/feed/rss/fr/lieuDeCulteParReligionDansLeCantonDeBerne.xml b/feed/rss/fr/lieuDeCulteParReligionDansLeCantonDeBerne.xml new file mode 100644 index 0000000..3a6eaa4 --- /dev/null +++ b/feed/rss/fr/lieuDeCulteParReligionDansLeCantonDeBerne.xml @@ -0,0 +1,13 @@ + + + + Lieu de culte par religion dans le canton de Berne + https://raw.githubusercontent.com/tomasriveral/ReSSPublica/refs/heads/main/feed/atom/fr/lieuDeCulteParReligionDansLeCantonDeBerne.atom + Flux RSS des Lieu de culte par religion dans le canton de Berne + + https://github.com/tomasriveral/ReSSPublica + python-feedgen + fr + Wed, 17 Jun 2026 21:51:13 +0200 + + diff --git a/flake.nix b/flake.nix index 87a406e..c6cb92d 100644 --- a/flake.nix +++ b/flake.nix @@ -31,6 +31,7 @@ matplotlib pycurl beautifulsoup4 + seaborn ]; # you will need to point to your local git clone @@ -59,6 +60,7 @@ matplotlib beautifulsoup4 pycurl + seaborn ]; }; }; diff --git a/pyproject.toml b/pyproject.toml index 319417b..67c694c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,8 @@ dependencies = [ "pyarrow", #dep for parquet "matplotlib", "beautifulsoup4", - "pycurl" + "pycurl", + "seaborn" ] [project.scripts] diff --git a/resspublica/bernAsianHornet.py b/resspublica/bernAsianHornet.py index aae5446..d7d6f30 100644 --- a/resspublica/bernAsianHornet.py +++ b/resspublica/bernAsianHornet.py @@ -135,7 +135,7 @@ def generateBernAsianHornetFeed(ASSETS, CACHE): else: weeklyEntry["url"] = f"https://opendata.swiss/{lang}/dataset/asiatische-hornisse" weeklyEntry["title"] = translatedBernAsianHornetSightings[lang] + f" {start.isoformat()}-{end.isoformat()}" - weeklyEntry["text"] = f"\"{translatedBernAsianHornetSightings[lang]}" # yes there is an error in filename. the s in asian is capitalized. I don't really want to regenerate all the images... + weeklyEntry["text"] = f"\"{translatedBernAsianHornetSightings[lang]}" feeds[lang].append(copy.deepcopy(weeklyEntry)) generateFeed( diff --git a/resspublica/bernReligionMap.py b/resspublica/bernReligionMap.py new file mode 100644 index 0000000..83a3515 --- /dev/null +++ b/resspublica/bernReligionMap.py @@ -0,0 +1,272 @@ +import pandas as pd +import copy +import geopandas as gpd +from shapely import wkb +import matplotlib.pyplot as plt +import datetime +from datetime import date, time +from bs4 import BeautifulSoup +from tinydb import TinyDB, Query +from time import sleep +from zoneinfo import ZoneInfo +from pathlib import Path +from seaborn import color_palette + + +import logging +logger = logging.getLogger("resspublica") + +from .translations import * +from .utils import * + +# We don't have a date fields in this dataset. +# What we do i create a snapshot (an image) each trimester to see the evolution + +def generateBernReligionMap(ASSETS, CACHE): + + arbitraryStartDate = date.fromisoformat("2026-01-01") # start date for weekly image generation. The data source starts at 2021-05-22, but as there is no date information, we must start at today + + logger.info("Generating Religion map in Bern feed...") + logger.info("Preparing data...") + #ASIAN_HORNETS_DB_PATH = CACHE / "bernAsianHornet.json" + #global db + #global q + #db = TinyDB(ASIAN_HORNETS_DB_PATH) + #q = Query() + + + # 1. Load data + url = "https://geofiles.be.ch/geoportal/pub/download/RELIGION/religion_relstaet.parquet" + dataframe = pd.read_parquet(url) + + # 2. Convert WKB geometry safely + def safe_load(x): + try: + return wkb.loads(x) + except Exception: + return None + + dataframe["geometry"] = dataframe["geometry"].apply(safe_load) + dataframe = dataframe.dropna(subset=["geometry"]) + + religionGeoDataFrame = gpd.GeoDataFrame(dataframe, geometry="geometry", crs="EPSG:2056") + + # We create a trimesterly image + # We just need to check if we haven't already created it + + # Each row is of the form Pandas(Index=127, objectid=23, uuid='D7CF8B83-4CD0-4F9E-9126-603BCCC0A42D', e_koord=2585316, n_koord=1221326, adresse='Rue de la Source 27', plz_ort='2502 Biel/Bienne', name_staet='Christkatholische Kirchgemeinde Biel', bez_staet='Christkatholische Kirchgemeinde Biel', symbol=8, reltra_id=1, reltrat_reltra_de='christlich', reltrat_reltra_fr='Christianisme', nkenn_id=6.0, nkennt_nkenn_de='christkatholisch', nkennt_nkenn_fr='catholique chrétien', kontakt_id=1, kontaktt_kontakt_de='offiziell', kontaktt_kontakt_fr='officiel', url='https://christkatholisch.ch/biel', adresse_k='Sekretariat und Pfarramt, General-Dufourstrasse 105', plz_ort_k='2502 Biel/Bienne', telefon='032 341 21 16', email='sekretariat.biel@christkatholisch.ch', jur_id=1, jurt_jur_de='öffentlich-rechtlich anerkannte Körperschaft', jurt_jur_fr='Collectivité reconnue de droit public', geometry=, bbox={'min_x': 2585316.0, 'min_y': 1221326.0, 'max_x': 2585316.0, 'max_y': 1221326.0}) + + # Note : some religion don't have subdivision in this dataset (ex: Judaisme) and as such don't have nkennt_* keys for them we set nkenn_id to 0 + + # Generate a dictionary that contains all (available) religions : + logger.info("Gathering information on the religions...") + df = religionGeoDataFrame.copy() + + # ensure nkenn_id exists even when NaN + df['nkenn_id_filled'] = df['nkenn_id'].fillna(0).astype(int) + + # build composite key "reltra-nkenn" + df['key'] = df['reltra_id'].astype(int).astype(str) + "-" + df['nkenn_id_filled'].astype(str) + + # keep only relevant columns + cols = [ + 'key', + 'reltra_id', + 'nkenn_id', + 'nkennt_nkenn_fr', + 'nkennt_nkenn_de', + 'reltrat_reltra_fr', + 'reltrat_reltra_de' + ] + + df = df[cols].drop_duplicates(subset=['key']) + + # sort by reltra_id so that religion groups are contiguous + df = df.sort_values(by=["reltra_id", "nkenn_id"], na_position="last") + # build dictionary + religions = ( + df.set_index('key') + .apply(lambda row: { + k: v for k, v in { + "nkenn_id": row["nkenn_id"], + "nkennt_nkenn_fr": row["nkennt_nkenn_fr"], + "nkennt_nkenn_de": row["nkennt_nkenn_de"], + "reltra_id": row["reltra_id"], + "reltrat_reltra_fr": row["reltrat_reltra_fr"], + "reltrat_reltra_de": row["reltrat_reltra_de"], + }.items() + if pd.notna(v) + }, axis=1) + .to_dict() + ) + logger.debug(religions) + + # generate color palette (1 color by religion with similar religion similar color) + palette = color_palette("husl", len(religions.keys())) + color_map = { + key: palette[i] + for i, key in enumerate(religions.keys()) + } + + # as the canton borders do not change (frequently), we just download once the data + gdbPathToCantonBoundariesDirectory = ASSETS / "swissBOUNDARIES3D_1_5_LV95_LN02.gdb" + + cantonsBoundariesData = gpd.read_file( + gdbPathToCantonBoundariesDirectory, + layer="TLM_KANTONSGEBIET" + ) + + bernCantonBoundaries = cantonsBoundariesData[ + cantonsBoundariesData["KANTONSNUMMER"] == 2 + ].to_crs(religionGeoDataFrame.crs) + + religionGeoDataFrame = religionGeoDataFrame[religionGeoDataFrame.geometry.notnull()].copy() + + municipialitiesBoundaries = gpd.read_file( + gdbPathToCantonBoundariesDirectory, + layer="TLM_HOHEITSGEBIET" + ) + + bernCityBoundaries = municipialitiesBoundaries[ + municipialitiesBoundaries["NAME"] == "Bern" + ].to_crs(religionGeoDataFrame.crs) + + bernCityPolygon = bernCityBoundaries.geometry.iloc[0] + + # subset of the scatter points of religion within the boundaries of the city of Bern + religionGeoDataFrameSubsetInBernCity = religionGeoDataFrame[religionGeoDataFrame.within(bernCityPolygon)].copy() + + for start, end in trimesterRangesFrom(arbitraryStartDate): + if Path( CACHE / f"bernReligionMap-fr-{start.isoformat()}-{end.isoformat()}.png").exists(): # we only check french, but if one language exists the other ones should also exist + logger.debug(f"Trimester {start.isoformat()}-{end.isoformat()} was already cached. Skipping...") + continue + logger.debug(f"Handling trimester {start.isoformat()}-{end.isoformat()}...") + + for lang in ["fr", "de"]: + fig, (pltAxBernCanton, pltAxBernCity) = plt.subplots( + 2, 1, + figsize=(12, 16), + gridspec_kw={"height_ratios": [1.2, 1]} + ) + bernCantonBoundaries.plot( + ax=pltAxBernCanton, + facecolor="none", + edgecolor="black", + linewidth=2 + ) + + for religion_key in religions.keys(): + logger.info(f"Handling religion {religion_key}") + reltra_id_str, nkenn_id_str = religion_key.split("-") + + reltra_id = int(reltra_id_str) + nkenn_id = int(nkenn_id_str) + + subset = religionGeoDataFrame[religionGeoDataFrame["reltra_id"] == reltra_id] + + if nkenn_id == 0: + subset.plot( + ax=pltAxBernCanton, + color=color_map[religion_key], + markersize=5, + alpha=0.6, + label=religions[religion_key][f"reltrat_reltra_{lang}"] + ) + else: + subset = subset[subset["nkenn_id"] == nkenn_id] + + subset.plot( + ax=pltAxBernCanton, + color=color_map[religion_key], + markersize=5, + alpha=0.6, + label=religions[religion_key][f"nkennt_nkenn_{lang}"] + + f" ({religions[religion_key][f'reltrat_reltra_{lang}'].strip(" ")})" + ) # for some reason the reltrat_reltra_fr for bouddisme has a final " " so we need to strip it + handles, labels = pltAxBernCanton.get_legend_handles_labels() + + fig.legend( + handles, + labels, + loc="upper right", + ncol=1, + fontsize=10, + title=translatedPlacesOfWorshipInBern[lang] + ) + fig.subplots_adjust(right=0.8) + + pltAxBernCanton.set_title("") + pltAxBernCanton.set_axis_off() + + bernCantonBoundaries.plot( + ax=pltAxBernCity, + facecolor="none", + edgecolor="black", + linewidth=2 + ) + + bernCityBoundaries.boundary.plot(ax=pltAxBernCity, color="black", linewidth=1) + + + + for religion_key in religions.keys(): + reltra_id_str, nkenn_id_str = religion_key.split("-") + + reltra_id = int(reltra_id_str) + nkenn_id = int(nkenn_id_str) + + subset = religionGeoDataFrameSubsetInBernCity[religionGeoDataFrameSubsetInBernCity["reltra_id"] == reltra_id] + + if nkenn_id != 0: + subset = subset[subset["nkenn_id"] == nkenn_id] + + subset.plot( + ax=pltAxBernCity, + color=color_map[religion_key], + markersize=5, + alpha=0.7 + ) + xmin, ymin, xmax, ymax = bernCityBoundaries.total_bounds + + pltAxBernCity.set_xlim(xmin, xmax) + pltAxBernCity.set_ylim(ymin, ymax) + pltAxBernCity.set_title(translatedCityOfBern[lang]) + pltAxBernCity.set_axis_off() + + plt.tight_layout() + plt.savefig(CACHE / f"bernReligionMap-{lang}-{start.isoformat()}-{end.isoformat()}.png", dpi=120, bbox_inches="tight") + logger.debug(f"Generated bernReligionMap-{lang}-{start.isoformat()}-{end.isoformat()}.png") + plt.close() + feeds = { + "fr": [], + "de": [] + } + for start, end in trimesterRangesFrom(arbitraryStartDate): + trimestrialEntry = {} + trimestrialEntry["id"] = datetime.combine(start, time(12, 0)).timestamp() # we use timestamp as ids + trimestrialEntry["creationDate"] = start.isoformat() + trimestrialEntry["date"] = start.isoformat() # there is no point have them different. On other feeds it's used for update date + trimestrialEntry["source"] = "opendata.swiss" + + for lang in ["fr", "de"]: + trimestrialEntry["url"] = f"https://opendata.swiss/{lang}/dataset/religionslandkarte" + trimestrialEntry["title"] = f"{translatedPlacesOfWorshipInBern[lang]}-{start.isoformat()}" + trimestrialEntry["text"] = f"\"{translatedPlacesOfWorshipInBern[lang]}" + generateFeed( + translatedPlacesOfWorshipInBern["fr"], + f"Flux RSS des {translatedPlacesOfWorshipInBern["fr"]}", + translatedPlacesOfWorshipInBernCamelCase["fr"], + "fr", + ["rss", "atom"], + datetime.now().replace(tzinfo=ZoneInfo("Europe/Zurich")), + feeds["fr"] + ) + generateFeed( + translatedPlacesOfWorshipInBern["de"], + f"RSS-Feed der {translatedPlacesOfWorshipInBern["de"]}", + translatedPlacesOfWorshipInBernCamelCase["de"], + "de", + ["rss", "atom"], + datetime.now().replace(tzinfo=ZoneInfo("Europe/Zurich")), + feeds["de"] + ) diff --git a/resspublica/main.py b/resspublica/main.py index f739502..af03317 100644 --- a/resspublica/main.py +++ b/resspublica/main.py @@ -9,6 +9,7 @@ from .utils import * from .federalInitiativesFeeds import * from .translations import * from .bernAsianHornet import * +from .bernReligionMap import * def main(): parser = argparse.ArgumentParser() @@ -16,6 +17,8 @@ def main(): parser.add_argument("--gen_federalInitiatives", action="store_true", help="Generate federal popular initiatives feed") parser.add_argument("--gen_bernAsianHornets", action="store_true", help="Generate Asian hornets sightings in Bern feed (only Mondays)") parser.add_argument("--force_gen_bernAsianHornets", action="store_true", help="Generate Asian hornets sightings in Bern feed even when not Monday") + parser.add_argument("--gen_bernReligionMap", action="store_true", help="Generate map of religions in Bern (only start of trimesters)") + parser.add_argument("--force_gen_bernReligionMap", action="store_true", help="Generate map of religions in Bern even when not start of trimester") args = parser.parse_args() @@ -35,4 +38,6 @@ def main(): generateFederalFeed(CACHE) if (date.today().weekday() == 0 and args.gen_bernAsianHornets) or args.force_gen_bernAsianHornets: generateBernAsianHornetFeed(ASSETS, CACHE) + if ( args.gen_bernReligionMap and date.today().day == 1 and date.today().month in [1, 5, 9]) or args.force_gen_bernReligionMap: + generateBernReligionMap(ASSETS, CACHE) logging.info("Done") diff --git a/resspublica/translations.py b/resspublica/translations.py index 2d0fb2a..9bb0858 100644 --- a/resspublica/translations.py +++ b/resspublica/translations.py @@ -76,3 +76,15 @@ translatedBernAsianHornetSightings = { "it": ("Avvistamenti di calabroni asiatici a Berna"), "rm": ("Observaziuns da vespras asiaticas a Berna"), } +translatedPlacesOfWorshipInBern = { + "de": "Gotteshäuser nach Religion im Kanton Bern", + "fr": "Lieu de culte par religion dans le canton de Berne" +} +translatedPlacesOfWorshipInBernCamelCase = { + "de": "gotteshauserNachReligionImKantonBern", + "fr": "lieuDeCulteParReligionDansLeCantonDeBerne" +} +translatedCityOfBern = { + "de": "Stadt Bern", + "fr": "Ville de Bern" +} diff --git a/resspublica/utils.py b/resspublica/utils.py index bfdd8a6..a6c8fdc 100644 --- a/resspublica/utils.py +++ b/resspublica/utils.py @@ -117,3 +117,35 @@ def weeklyRangesFrom(start_date: date): start += timedelta(days=7) return weeks + + +def trimester_start(d: date) -> date: + if d.month <= 4: + return date(d.year, 1, 1) + elif d.month <= 8: + return date(d.year, 5, 1) + else: + return date(d.year, 9, 1) +def next_trimester_start(d: date) -> date: + if d.month <= 4: + return date(d.year, 5, 1) + elif d.month <= 8: + return date(d.year, 9, 1) + else: + return date(d.year + 1, 1, 1) +def trimesterRangesFrom(start_date: date): + today = date.today() + + current_trimester_start = trimester_start(today) + start = trimester_start(start_date) + + trimesters = [] + + while start < current_trimester_start: + next_start = next_trimester_start(start) + end = next_start - timedelta(days=1) + + trimesters.append((start, end)) + start = next_start + + return trimesters