diff --git a/CHANGELOG.md b/CHANGELOG.md index 6884e6a..121f09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- Expanded programmatic SEO city coverage from 18 to 40 cities (+22 cities across ES, FR, + IT, NL, AT, CH, SE, PT, BE, AE, AU, IE) — generates 80 articles (40 cities × EN + DE) +- `scripts/refresh_from_daas.py`: syncs template_data rows from DuckDB `planner_defaults` + serving table; supports `--dry-run` and `--generate` flags; graceful no-op when DuckDB unavailable + + ### Added - `analytics.py`: DuckDB read-only reader (`open_analytics_db`, `close_analytics_db`, `fetch_analytics`) registered in app lifecycle (startup/shutdown) diff --git a/padelnomics/web/src/padelnomics/scripts/refresh_from_daas.py b/padelnomics/web/src/padelnomics/scripts/refresh_from_daas.py new file mode 100644 index 0000000..93c04cf --- /dev/null +++ b/padelnomics/web/src/padelnomics/scripts/refresh_from_daas.py @@ -0,0 +1,208 @@ +""" +Refresh template_data rows from DuckDB analytics serving layer. + +Reads per-city market data from the `padelnomics.planner_defaults` serving table +and overwrites matching static values in `template_data.data_json`. This keeps +article financial model inputs in sync with the real-world data pipeline output. + +Usage: + # Dry-run — show what would change without writing: + uv run python -m padelnomics.scripts.refresh_from_daas --dry-run + + # Apply updates: + uv run python -m padelnomics.scripts.refresh_from_daas + + # Also re-generate articles after updating: + uv run python -m padelnomics.scripts.refresh_from_daas --generate + +Graceful degradation: + - If DUCKDB_PATH is unset or the file doesn't exist, exits 0 with a warning. + - Cities not found in the analytics layer are left unchanged. + - Only fields with non-null values in the analytics layer are updated. + +Fields mapped (DuckDB → data_json camelCase key): + rate_peak → ratePeak + rate_off_peak → rateOffPeak + court_cost_dbl → courtCostDbl + court_cost_sgl → courtCostSgl + rent_sqm → rentSqm + insurance → insurance + electricity → electricity + maintenance → maintenance + marketing → marketing +""" + +import argparse +import json +import os +import sqlite3 +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +DATABASE_PATH = os.getenv("DATABASE_PATH", "data/app.db") +DUCKDB_PATH = os.getenv("DUCKDB_PATH", "data/lakehouse.duckdb") + +# DuckDB column → data_json camelCase key +_FIELD_MAP: dict[str, str] = { + "rate_peak": "ratePeak", + "rate_off_peak": "rateOffPeak", + "court_cost_dbl": "courtCostDbl", + "court_cost_sgl": "courtCostSgl", + "rent_sqm": "rentSqm", + "insurance": "insurance", + "electricity": "electricity", + "maintenance": "maintenance", + "marketing": "marketing", +} + + +def _load_analytics(city_slugs: list[str]) -> dict[str, dict]: + """Query DuckDB for planner_defaults rows matching the given city slugs. + + Returns dict of city_slug → {camelCase key: value}. + Returns {} if DuckDB is unavailable. + """ + path = Path(DUCKDB_PATH) + if not path.exists(): + print(f" [analytics] DuckDB not found at {path} — skipping analytics refresh.") + return {} + + try: + import duckdb + except ImportError: + print(" [analytics] duckdb not installed — skipping analytics refresh.") + return {} + + result: dict[str, dict] = {} + try: + conn = duckdb.connect(str(path), read_only=True) + placeholders = ", ".join(["?"] * len(city_slugs)) + rows = conn.execute( + f"SELECT * FROM padelnomics.planner_defaults WHERE city_slug IN ({placeholders})", + city_slugs, + ).fetchall() + cols = [d[0] for d in conn.description] + conn.close() + + for row in rows: + row_dict = dict(zip(cols, row)) + slug = row_dict["city_slug"] + overrides: dict = {} + for col, key in _FIELD_MAP.items(): + val = row_dict.get(col) + if val is not None: + overrides[key] = round(float(val)) + result[slug] = overrides + + except Exception as exc: + print(f" [analytics] DuckDB query failed: {exc}") + + return result + + +def refresh(dry_run: bool = False) -> int: + """Update template_data rows from analytics. Returns count of rows updated.""" + conn = sqlite3.connect(DATABASE_PATH) + conn.row_factory = sqlite3.Row + + # Fetch all template_data rows that have a city_slug field + rows = conn.execute( + "SELECT id, template_id, data_json FROM template_data" + ).fetchall() + + city_slug_to_ids: dict[str, list[int]] = {} + for row in rows: + try: + data = json.loads(row["data_json"]) + except (json.JSONDecodeError, TypeError): + continue + slug = data.get("city_slug", "") + if slug: + city_slug_to_ids.setdefault(slug, []).append(row["id"]) + + if not city_slug_to_ids: + print("No template_data rows with city_slug found.") + conn.close() + return 0 + + analytics = _load_analytics(list(city_slug_to_ids.keys())) + if not analytics: + print("No analytics data found — nothing to update.") + conn.close() + return 0 + + updated = 0 + for slug, overrides in analytics.items(): + ids = city_slug_to_ids.get(slug, []) + if not ids: + continue + + for row_id in ids: + row = conn.execute( + "SELECT data_json FROM template_data WHERE id = ?", (row_id,) + ).fetchone() + if not row: + continue + + data = json.loads(row["data_json"]) + changed = {k: v for k, v in overrides.items() if data.get(k) != v} + if not changed: + continue + + data.update(overrides) + if dry_run: + print(f" [dry-run] id={row_id} city_slug={slug}: {changed}") + else: + conn.execute( + "UPDATE template_data SET data_json = ?, updated_at = datetime('now') WHERE id = ?", + (json.dumps(data), row_id), + ) + print(f" Updated id={row_id} city_slug={slug}: {list(changed.keys())}") + updated += 1 + + if not dry_run: + conn.commit() + + conn.close() + return updated + + +def _trigger_generation() -> None: + """Trigger article generation via admin endpoint (requires running app).""" + import urllib.request + admin_key = os.getenv("ADMIN_API_KEY", "") + base_url = os.getenv("BASE_URL", "http://localhost:5000") + url = f"{base_url}/admin/generate-all" + assert admin_key, "ADMIN_API_KEY env var required to trigger generation" + + req = urllib.request.Request( + url, + method="POST", + headers={"X-Admin-Key": admin_key}, + ) + with urllib.request.urlopen(req, timeout=120) as resp: + print(f" Generation triggered: HTTP {resp.status}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dry-run", action="store_true", + help="Print changes without writing to DB") + parser.add_argument("--generate", action="store_true", + help="Trigger article re-generation after updating") + args = parser.parse_args() + + print(f"{'[DRY RUN] ' if args.dry_run else ''}Refreshing template_data from DuckDB…") + count = refresh(dry_run=args.dry_run) + print(f"{'Would update' if args.dry_run else 'Updated'} {count} rows.") + + if args.generate and count > 0 and not args.dry_run: + print("Triggering article generation…") + _trigger_generation() + + +if __name__ == "__main__": + main() diff --git a/padelnomics/web/src/padelnomics/scripts/seed_content.py b/padelnomics/web/src/padelnomics/scripts/seed_content.py index 7a3fcdc..044ce9e 100644 --- a/padelnomics/web/src/padelnomics/scripts/seed_content.py +++ b/padelnomics/web/src/padelnomics/scripts/seed_content.py @@ -705,6 +705,609 @@ CITIES = [ "rentSqm": 4, "electricity": 630, "heating": 440, "staff": 2100, "permitsCompliance": 10000, "utilTarget": 38, }, + # ------------------------------------------------------------------ Spain + { + "city": "Madrid", + "city_slug_en": "madrid", + "city_slug_de": "madrid", + "country": "ES", + "country_name_en": "Spain", + "country_name_de": "Spanien", + "region": "Europe", + "population": "3.4M", + "padel_context_en": ( + "Madrid is the world's densest padel market, with over 5,000 courts " + "nationwide and the sport embedded in everyday culture. High utilisation " + "rates and a deeply padel-literate customer base make Madrid one of the " + "best-validated markets for new investment." + ), + "padel_context_de": ( + "Madrid ist der weltweit dichteste Padelmarkt mit über 5.000 Courts " + "und dem Sport als fester Alltagskultur. Hohe Auslastungsraten und " + "eine padelerfahrene Kundschaft machen Madrid zu einem der " + "bestvalidiertesten Märkte für neue Investitionen." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 5, "sglCourts": 2, + "ratePeak": 44, "rateOffPeak": 30, + "rentSqm": 6, "electricity": 580, "heating": 50, + "staff": 2200, "permitsCompliance": 25000, "utilTarget": 60, + }, + { + "city": "Barcelona", + "city_slug_en": "barcelona", + "city_slug_de": "barcelona", + "country": "ES", + "country_name_en": "Spain", + "country_name_de": "Spanien", + "region": "Europe", + "population": "1.6M", + "padel_context_en": ( + "Barcelona combines a mature padel culture with year-round outdoor " + "conditions and strong tourism demand. Premium zones near the seafront " + "command higher rates, while suburban clubs operate at lower cost with " + "equally strong fill rates." + ), + "padel_context_de": ( + "Barcelona verbindet eine reife Padelkultur mit ganzjährig " + "günstigen Outdoor-Bedingungen und starker Tourismusnachfrage. " + "Premium-Zonen nahe der Küste erzielen höhere Preise, während " + "Vorortclubs bei niedrigeren Kosten gleichstarke Belegung aufweisen." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 5, "sglCourts": 2, + "ratePeak": 47, "rateOffPeak": 32, + "rentSqm": 8, "electricity": 590, "heating": 0, + "staff": 2300, "permitsCompliance": 22000, "utilTarget": 60, + }, + { + "city": "Valencia", + "city_slug_en": "valencia", + "city_slug_de": "valencia", + "country": "ES", + "country_name_en": "Spain", + "country_name_de": "Spanien", + "region": "Europe", + "population": "800K", + "padel_context_en": ( + "Valencia offers a compelling cost-to-utilisation ratio. Commercial " + "rents are 30–40% below Madrid, warm winters allow year-round outdoor " + "play, and the padel culture is well established — making it one of " + "Spain's most attractive second-tier markets." + ), + "padel_context_de": ( + "Valencia bietet ein überzeugendes Kosten-Auslastungs-Verhältnis. " + "Gewerbemieten liegen 30–40% unter Madrid, warme Winter ermöglichen " + "ganzjähriges Outdoor-Spiel, und die Padelkultur ist fest etabliert — " + "einer der attraktivsten spanischen Zweitplatzmärkte." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 38, "rateOffPeak": 26, + "rentSqm": 5, "electricity": 550, "heating": 0, + "staff": 1800, "permitsCompliance": 18000, "utilTarget": 55, + }, + { + "city": "Seville", + "city_slug_en": "seville", + "city_slug_de": "sevilla", + "country": "ES", + "country_name_en": "Spain", + "country_name_de": "Spanien", + "region": "Europe", + "population": "690K", + "padel_context_en": ( + "Seville's hot, dry climate enables year-round outdoor padel, dramatically " + "lowering operating costs versus indoor-only markets. Strong local sports " + "culture and lower land prices combine to produce attractive returns for " + "early-mover operators." + ), + "padel_context_de": ( + "Sevillas heißes, trockenes Klima ermöglicht ganzjähriges Outdoor-Padel " + "und senkt die Betriebskosten gegenüber reinen Innenmarktstandorten erheblich. " + "Starke lokale Sportkultur und niedrigere Grundstückspreise ergeben " + "attraktive Renditen für Erstanbieter." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 35, "rateOffPeak": 24, + "rentSqm": 4, "electricity": 520, "heating": 0, + "staff": 1600, "permitsCompliance": 20000, "utilTarget": 52, + }, + { + "city": "Málaga", + "city_slug_en": "malaga", + "city_slug_de": "malaga", + "country": "ES", + "country_name_en": "Spain", + "country_name_de": "Spanien", + "region": "Europe", + "population": "580K", + "padel_context_en": ( + "Málaga's status as a major expat and tourism hub creates dual revenue " + "streams: local membership and high-rate tourist bookings. The city's " + "tech scene and growing digital-nomad community add a further demographic " + "of high-frequency casual players." + ), + "padel_context_de": ( + "Málagas Status als bedeutendes Expat- und Tourismuszentrum schafft " + "zwei Einnahmequellen: lokale Mitgliedschaften und hochpreisige Touristenbuchungen. " + "Die Tech-Szene und wachsende Digital-Nomad-Gemeinschaft der Stadt schaffen " + "eine weitere Demografie hochfrequenter Gelegenheitsspieler." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 42, "rateOffPeak": 28, + "rentSqm": 5, "electricity": 540, "heating": 0, + "staff": 1700, "permitsCompliance": 18000, "utilTarget": 53, + }, + # ------------------------------------------------------------------ France + { + "city": "Paris", + "city_slug_en": "paris", + "city_slug_de": "paris", + "country": "FR", + "country_name_en": "France", + "country_name_de": "Frankreich", + "region": "Europe", + "population": "2.1M (metro 12M)", + "padel_context_en": ( + "Paris is France's largest and fastest-growing padel market, with demand " + "significantly outpacing supply. High disposable incomes, an appetite for " + "premium sports experiences, and tight commercial space keep prices elevated " + "but support strong utilisation." + ), + "padel_context_de": ( + "Paris ist Frankreichs größter und am schnellsten wachsender Padelmarkt, " + "mit deutlich überschüssiger Nachfrage. Hohe verfügbare Einkommen, " + "Nachfrage nach Premium-Sporterfahrungen und knappes Gewerbeflächenangebot " + "halten die Preise hoch und stützen eine starke Auslastung." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 58, "rateOffPeak": 40, + "rentSqm": 12, "electricity": 620, "heating": 330, + "staff": 3000, "permitsCompliance": 15000, "utilTarget": 47, + }, + { + "city": "Lyon", + "city_slug_en": "lyon", + "city_slug_de": "lyon", + "country": "FR", + "country_name_en": "France", + "country_name_de": "Frankreich", + "region": "Europe", + "population": "520K (metro 2.3M)", + "padel_context_en": ( + "Lyon has emerged as one of France's most active provincial padel markets, " + "backed by a large student and professional population. Lower rents than " + "Paris and a growing club network make it an attractive second-city opportunity." + ), + "padel_context_de": ( + "Lyon hat sich als einer der aktivsten provinziellen Padelmärkte Frankreichs " + "etabliert, getragen von einer großen Studenten- und Berufsschicht. " + "Günstigere Mieten als Paris und ein wachsendes Clubnetzwerk machen " + "es zu einer attraktiven Zweitstadtchance." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 42, "rateOffPeak": 30, + "rentSqm": 6, "electricity": 560, "heating": 280, + "staff": 2000, "permitsCompliance": 12000, "utilTarget": 40, + }, + { + "city": "Marseille", + "city_slug_en": "marseille", + "city_slug_de": "marseille", + "country": "FR", + "country_name_en": "France", + "country_name_de": "Frankreich", + "region": "Europe", + "population": "870K", + "padel_context_en": ( + "Marseille's Mediterranean climate reduces heating costs significantly " + "and extends the viable outdoor season. As France's second city by size, " + "it offers deep demographic reach at commercial rents well below Paris or Lyon." + ), + "padel_context_de": ( + "Marseilles Mittelmeerklima senkt die Heizkosten erheblich und verlängert " + "die nutzbare Outdoor-Saison. Als zweitgrößte Stadt Frankreichs bietet sie " + "demographische Tiefe bei Gewerbemieten weit unter Paris oder Lyon." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 38, "rateOffPeak": 27, + "rentSqm": 5, "electricity": 540, "heating": 100, + "staff": 1700, "permitsCompliance": 12000, "utilTarget": 40, + }, + # ------------------------------------------------------------------ Italy + { + "city": "Milan", + "city_slug_en": "milan", + "city_slug_de": "mailand", + "country": "IT", + "country_name_en": "Italy", + "country_name_de": "Italien", + "region": "Europe", + "population": "1.4M (metro 3.2M)", + "padel_context_en": ( + "Milan leads Italy's padel expansion, fuelled by high disposable incomes " + "and a business culture that treats sport as a social currency. Demand from " + "corporate accounts is particularly strong, with firms booking courts " + "for team activities and client entertainment." + ), + "padel_context_de": ( + "Mailand führt Italiens Padelexpansion an, angetrieben durch hohes " + "verfügbares Einkommen und eine Geschäftskultur, die Sport als soziale " + "Währung betrachtet. Die Nachfrage von Firmenkonten ist besonders stark, " + "da Unternehmen Courts für Teamaktivitäten und Kundenunterhaltung buchen." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 52, "rateOffPeak": 36, + "rentSqm": 9, "electricity": 730, "heating": 400, + "staff": 2500, "permitsCompliance": 18000, "utilTarget": 46, + }, + { + "city": "Rome", + "city_slug_en": "rome", + "city_slug_de": "rom", + "country": "IT", + "country_name_en": "Italy", + "country_name_de": "Italien", + "region": "Europe", + "population": "2.8M", + "padel_context_en": ( + "Rome's mild winters and vast population base underpin a rapidly growing " + "padel market. Outdoor courts operate profitably year-round, reducing " + "CAPEX versus fully enclosed facilities. Central government facilities " + "and sports club conversions dominate early supply." + ), + "padel_context_de": ( + "Roms milde Winter und riesige Bevölkerungsbasis stützen einen schnell " + "wachsenden Padelmarkt. Outdoor-Courts arbeiten ganzjährig profitabel, " + "was den CAPEX gegenüber vollständig geschlossenen Anlagen reduziert. " + "Staatliche Anlagen und Sportclubumbauten dominieren das frühe Angebot." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 46, "rateOffPeak": 32, + "rentSqm": 7, "electricity": 700, "heating": 200, + "staff": 2200, "permitsCompliance": 20000, "utilTarget": 43, + }, + # ---------------------------------------------- Netherlands + { + "city": "Amsterdam", + "city_slug_en": "amsterdam", + "city_slug_de": "amsterdam", + "country": "NL", + "country_name_en": "Netherlands", + "country_name_de": "Niederlande", + "region": "Europe", + "population": "920K", + "padel_context_en": ( + "Amsterdam combines a sports-active, internationally minded population " + "with one of Europe's highest purchasing powers. The city's compact geography " + "and limited available space push operators toward efficient indoor multi-court " + "venues, justifying premium pricing." + ), + "padel_context_de": ( + "Amsterdam verbindet eine sportaktive, international ausgerichtete Bevölkerung " + "mit einer der höchsten Kaufkräfte Europas. Die kompakte Geografie und begrenzte " + "Verfügbarkeit von Räumlichkeiten drängen Betreiber zu effizienten Indoor-" + "Mehrcourt-Anlagen und rechtfertigen Premium-Preise." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 54, "rateOffPeak": 38, + "rentSqm": 10, "electricity": 660, "heating": 460, + "staff": 2800, "permitsCompliance": 10000, "utilTarget": 43, + }, + # ----------------------------------------------------------------- Austria + { + "city": "Vienna", + "city_slug_en": "vienna", + "city_slug_de": "wien", + "country": "AT", + "country_name_en": "Austria", + "country_name_de": "Österreich", + "region": "Europe", + "population": "1.9M", + "padel_context_en": ( + "Vienna is Austria's dominant padel market and one of Central Europe's " + "fastest-growing cities for the sport. A large English-speaking expat " + "community and high real incomes drive premium bookings, particularly " + "in the first and third districts." + ), + "padel_context_de": ( + "Wien ist Österreichs dominanter Padelmarkt und eine der am schnellsten " + "wachsenden Städte für den Sport in Mitteleuropa. Eine große englischsprachige " + "Expat-Gemeinschaft und hohe Realeinkommen treiben Premium-Buchungen an, " + "insbesondere im ersten und dritten Bezirk." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 50, "rateOffPeak": 35, + "rentSqm": 7, "electricity": 610, "heating": 510, + "staff": 2400, "permitsCompliance": 14000, "utilTarget": 42, + }, + # --------------------------------------------------------------- Switzerland + { + "city": "Zurich", + "city_slug_en": "zurich", + "city_slug_de": "zuerich", + "country": "CH", + "country_name_en": "Switzerland", + "country_name_de": "Schweiz", + "region": "Europe", + "population": "440K (metro 1.4M)", + "padel_context_en": ( + "Zurich is Europe's most expensive padel market, driven by the highest " + "average wages on the continent. Players expect premium facilities and " + "are willing to pay for them — average court rates are 60–80% above " + "comparable German cities. Staff costs are the single largest operating " + "line item." + ), + "padel_context_de": ( + "Zürich ist Europas teuerster Padelmarkt, angetrieben durch die höchsten " + "Durchschnittslöhne des Kontinents. Spieler erwarten Premium-Anlagen und " + "sind bereit, dafür zu zahlen — durchschnittliche Courtpreise liegen 60–80% " + "über vergleichbaren deutschen Städten. Personalkosten sind die größte " + "Einzelposition im Betrieb." + ), + "currency_note_en": _EUR_NOTE_EN, + "currency_note_de": _EUR_NOTE_DE, + "dblCourts": 4, "sglCourts": 1, + "ratePeak": 75, "rateOffPeak": 52, + "rentSqm": 15, "electricity": 500, "heating": 480, + "staff": 4500, "permitsCompliance": 15000, "utilTarget": 45, + }, + # ------------------------------------------------------------------ Sweden + { + "city": "Stockholm", + "city_slug_en": "stockholm", + "city_slug_de": "stockholm", + "country": "SE", + "country_name_en": "Sweden", + "country_name_de": "Schweden", + "region": "Europe", + "population": "980K", + "padel_context_en": ( + "Stockholm has the highest padel court-per-capita ratio in the world outside " + "Spain. The sport is deeply embedded in Swedish corporate and social culture — " + "padel after work is a fixture for office teams. Indoor venues dominate " + "given the long winter, but cheap electricity (largely hydropower) keeps " + "operating costs manageable." + ), + "padel_context_de": ( + "Stockholm hat das weltweit höchste Padel-Court-pro-Kopf-Verhältnis außerhalb " + "Spaniens. Der Sport ist tief in der schwedischen Unternehmens- und Sozialkultur " + "verankert. Indoor-Anlagen dominieren aufgrund des langen Winters, aber günstige " + "Elektrizität (größtenteils Wasserkraft) hält die Betriebskosten beherrschbar." + ), + "currency_note_en": _EUR_NOTE_EN, + "currency_note_de": _EUR_NOTE_DE, + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 62, "rateOffPeak": 44, + "rentSqm": 8, "electricity": 390, "heating": 650, + "staff": 3200, "permitsCompliance": 8000, "utilTarget": 46, + }, + # ----------------------------------------------------------------- Portugal + { + "city": "Lisbon", + "city_slug_en": "lisbon", + "city_slug_de": "lissabon", + "country": "PT", + "country_name_en": "Portugal", + "country_name_de": "Portugal", + "region": "Europe", + "population": "550K (metro 2.8M)", + "padel_context_en": ( + "Lisbon is one of Europe's fastest-growing padel markets, driven by an " + "influx of international residents and a warm climate that minimises " + "heating costs. Court rates are rising rapidly as demand outpaces supply, " + "making early-mover advantage particularly valuable." + ), + "padel_context_de": ( + "Lissabon ist einer der am schnellsten wachsenden Padelmärkte Europas, " + "angetrieben durch einen Zustrom internationaler Einwohner und ein warmes " + "Klima, das Heizkosten minimiert. Courtpreise steigen rapide, da die Nachfrage " + "das Angebot übersteigt — der Erstanbieter-Vorteil ist besonders wertvoll." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 38, "rateOffPeak": 26, + "rentSqm": 6, "electricity": 540, "heating": 80, + "staff": 1700, "permitsCompliance": 12000, "utilTarget": 52, + }, + { + "city": "Porto", + "city_slug_en": "porto", + "city_slug_de": "porto", + "country": "PT", + "country_name_en": "Portugal", + "country_name_de": "Portugal", + "region": "Europe", + "population": "240K (metro 1.7M)", + "padel_context_en": ( + "Porto offers the most accessible padel investment entry point in Western Europe. " + "Commercial rents are among the lowest of any major city in this report, " + "the growing tech sector is driving demand, and the mild Atlantic climate " + "supports year-round play without heating infrastructure." + ), + "padel_context_de": ( + "Porto bietet den günstigsten Padelinvestitions-Einstiegspunkt in Westeuropa. " + "Gewerbemieten gehören zu den niedrigsten aller Großstädte in diesem Bericht, " + "der wachsende Tech-Sektor treibt die Nachfrage, und das milde Atlantikklima " + "unterstützt ganzjähriges Spiel ohne Heizinfrastruktur." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 3, "sglCourts": 2, + "ratePeak": 32, "rateOffPeak": 22, + "rentSqm": 4, "electricity": 500, "heating": 50, + "staff": 1400, "permitsCompliance": 10000, "utilTarget": 48, + }, + # ----------------------------------------------------------------- Belgium + { + "city": "Brussels", + "city_slug_en": "brussels", + "city_slug_de": "bruessel", + "country": "BE", + "country_name_en": "Belgium", + "country_name_de": "Belgien", + "region": "Europe", + "population": "1.2M (metro 2.1M)", + "padel_context_en": ( + "Brussels benefits from a large international civil-servant and lobbying " + "community with high disposable incomes and a taste for premium leisure. " + "Belgium's electricity costs are among Europe's highest, making energy " + "efficiency a critical factor in site selection." + ), + "padel_context_de": ( + "Brüssel profitiert von einer großen internationalen Beamten- und " + "Lobbyisten-Gemeinschaft mit hohem verfügbarem Einkommen. Belgiens " + "Stromkosten gehören zu den höchsten Europas — Energieeffizienz ist " + "ein entscheidender Faktor bei der Standortwahl." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 50, "rateOffPeak": 35, + "rentSqm": 8, "electricity": 660, "heating": 450, + "staff": 2600, "permitsCompliance": 12000, "utilTarget": 41, + }, + # ------------------------------------------------------------------- UAE + { + "city": "Dubai", + "city_slug_en": "dubai", + "city_slug_de": "dubai", + "country": "AE", + "country_name_en": "UAE", + "country_name_de": "Vereinigte Arabische Emirate", + "region": "Middle East", + "population": "3.7M", + "padel_context_en": ( + "Dubai is the fastest-growing padel market in the Middle East, " + "with investment from European club operators and locally founded chains " + "expanding rapidly. Year-round outdoor conditions, no income tax, " + "and a high-earning expat population create exceptional unit economics. " + "AC costs replace heating but are heavily subsidised." + ), + "padel_context_de": ( + "Dubai ist der am schnellsten wachsende Padelmarkt im Nahen Osten, " + "mit Investitionen europäischer Clubbetreiber und lokaler Ketten. " + "Ganzjährige Outdoor-Bedingungen, keine Einkommensteuer und " + "eine gut verdienende Expat-Bevölkerung schaffen außergewöhnliche Unit Economics. " + "AC-Kosten ersetzen Heizung, sind aber stark subventioniert." + ), + "currency_note_en": _EUR_NOTE_EN, + "currency_note_de": _EUR_NOTE_DE, + "dblCourts": 6, "sglCourts": 2, + "ratePeak": 85, "rateOffPeak": 58, + "rentSqm": 5, "electricity": 380, "heating": 0, + "staff": 4200, "permitsCompliance": 20000, "utilTarget": 58, + }, + # ---------------------------------------------------------------- Australia + { + "city": "Sydney", + "city_slug_en": "sydney", + "city_slug_de": "sydney", + "country": "AU", + "country_name_en": "Australia", + "country_name_de": "Australien", + "region": "Asia Pacific", + "population": "5.3M", + "padel_context_en": ( + "Sydney is Australia's largest and most developed padel market, " + "with supply ramping rapidly since 2022. High commercial rents and " + "electricity costs require strong utilisation to break even, but the " + "city's affluent coastal suburbs produce premium booking rates and " + "membership uptake above European benchmarks." + ), + "padel_context_de": ( + "Sydney ist Australiens größter und am weitesten entwickelter Padelmarkt, " + "mit rasant steigendem Angebot seit 2022. Hohe Gewerberaum- und Stromkosten " + "erfordern eine starke Auslastung, aber die wohlhabenden Küstenvorortee " + "der Stadt erzielen Premium-Buchungsraten über europäischen Benchmarks." + ), + "currency_note_en": _EUR_NOTE_EN, + "currency_note_de": _EUR_NOTE_DE, + "dblCourts": 5, "sglCourts": 2, + "ratePeak": 68, "rateOffPeak": 48, + "rentSqm": 6, "electricity": 760, "heating": 80, + "staff": 3600, "permitsCompliance": 18000, "utilTarget": 50, + }, + { + "city": "Melbourne", + "city_slug_en": "melbourne", + "city_slug_de": "melbourne", + "country": "AU", + "country_name_en": "Australia", + "country_name_de": "Australien", + "region": "Asia Pacific", + "population": "5.1M", + "padel_context_en": ( + "Melbourne's four-seasons climate and strong indoor-sport infrastructure " + "make it well suited to padel. The city trails Sydney in court density " + "but benefits from somewhat lower commercial rents and a sports culture " + "that is quick to adopt new racket sports." + ), + "padel_context_de": ( + "Melbournes Vier-Jahreszeiten-Klima und starke Hallensport-Infrastruktur " + "machen es gut geeignet für Padel. Die Stadt liegt hinter Sydney in " + "der Court-Dichte, profitiert aber von etwas niedrigeren Gewerbemieten " + "und einer Sportkultur, die neue Racketsportarten schnell annimmt." + ), + "currency_note_en": _EUR_NOTE_EN, + "currency_note_de": _EUR_NOTE_DE, + "dblCourts": 5, "sglCourts": 2, + "ratePeak": 62, "rateOffPeak": 44, + "rentSqm": 5, "electricity": 700, "heating": 100, + "staff": 3200, "permitsCompliance": 16000, "utilTarget": 47, + }, + # ----------------------------------------------------------------- Ireland + { + "city": "Dublin", + "city_slug_en": "dublin", + "city_slug_de": "dublin", + "country": "IE", + "country_name_en": "Ireland", + "country_name_de": "Irland", + "region": "Europe", + "population": "1.4M", + "padel_context_en": ( + "Dublin's booming tech sector and large young professional population " + "have driven rapid uptake of padel since 2022. Ireland's electricity " + "costs are among Europe's highest, significantly impacting operating " + "margins — energy-efficient site design is essential." + ), + "padel_context_de": ( + "Dublins boomender Tech-Sektor und eine große junge Berufsschicht " + "haben seit 2022 eine rasche Padeladoption angetrieben. Irlands Stromkosten " + "gehören zu den höchsten Europas und belasten die Betriebsmargen erheblich — " + "energieeffizientes Standortdesign ist unerlässlich." + ), + "currency_note_en": "", + "currency_note_de": "", + "dblCourts": 4, "sglCourts": 2, + "ratePeak": 55, "rateOffPeak": 38, + "rentSqm": 9, "electricity": 800, "heating": 460, + "staff": 2900, "permitsCompliance": 12000, "utilTarget": 42, + }, ] # Calc fields that get passed to the financial model (must match DEFAULTS keys)