@@ -354,3 +354,149 @@ def psr_name(identifier: str) -> str:
354354 """
355355 code = lookup_psr (identifier )
356356 return PSR_TYPES [code ]
357+
358+
359+ # ── Border / neighbour data (for CLI --border flag) ───────────────────────
360+
361+ # Direct interconnections between bidding zones used by ENTSO-E.
362+ # Each key maps to the set of zones it has a physical border with.
363+ NEIGHBOURS : dict [str , set [str ]] = {
364+ "ES" : {"FR" , "PT" },
365+ "FR" : {"ES" , "BE" , "CH" , "DE_LU" , "GB" , "IT_NORTH" },
366+ "PT" : {"ES" },
367+ "BE" : {"FR" , "NL" , "DE_LU" , "GB" },
368+ "NL" : {"BE" , "DE_LU" , "GB" , "NO_2" , "DK_1" },
369+ "DE_LU" : {"FR" , "BE" , "NL" , "AT" , "CH" , "CZ" , "DK_1" , "DK_2" , "PL" , "SE_4" , "NO_2" },
370+ "AT" : {"DE_LU" , "CH" , "CZ" , "HU" , "SI" , "IT_NORTH" },
371+ "CH" : {"FR" , "DE_LU" , "AT" , "IT_NORTH" },
372+ "IT_NORTH" : {"FR" , "CH" , "AT" , "SI" },
373+ "GB" : {"FR" , "BE" , "NL" , "IE_SEM" , "NO_2" },
374+ "IE_SEM" : {"GB" },
375+ "DK_1" : {"DE_LU" , "NL" , "NO_2" , "SE_3" , "DK_2" },
376+ "DK_2" : {"DE_LU" , "DK_1" , "SE_4" },
377+ "NO_1" : {"NO_2" , "NO_3" , "NO_5" , "SE_3" },
378+ "NO_2" : {"NO_1" , "NO_5" , "NL" , "DK_1" , "DE_LU" , "GB" },
379+ "NO_3" : {"NO_1" , "NO_4" , "NO_5" , "SE_2" },
380+ "NO_4" : {"NO_3" , "SE_1" , "SE_2" , "FI" },
381+ "NO_5" : {"NO_1" , "NO_2" , "NO_3" },
382+ "SE_1" : {"NO_4" , "SE_2" , "FI" },
383+ "SE_2" : {"NO_3" , "NO_4" , "SE_1" , "SE_3" },
384+ "SE_3" : {"NO_1" , "DK_1" , "SE_2" , "SE_4" , "FI" },
385+ "SE_4" : {"DE_LU" , "DK_2" , "SE_3" , "PL" , "LT" },
386+ "FI" : {"NO_4" , "SE_1" , "SE_3" , "EE" },
387+ "EE" : {"FI" , "LV" },
388+ "LV" : {"EE" , "LT" },
389+ "LT" : {"LV" , "SE_4" , "PL" },
390+ "PL" : {"DE_LU" , "CZ" , "SK" , "LT" , "SE_4" },
391+ "CZ" : {"DE_LU" , "AT" , "PL" , "SK" },
392+ "SK" : {"CZ" , "PL" , "HU" },
393+ "HU" : {"AT" , "SK" , "RO" , "HR" , "SI" , "RS" },
394+ "SI" : {"AT" , "IT_NORTH" , "HU" , "HR" },
395+ "HR" : {"HU" , "SI" , "RS" , "BA" },
396+ "RS" : {"HU" , "HR" , "BA" , "RO" , "BG" , "MK" , "ME" , "AL" },
397+ "BA" : {"HR" , "RS" , "ME" },
398+ "RO" : {"HU" , "RS" , "BG" },
399+ "BG" : {"RO" , "RS" , "MK" , "GR" , "TR" },
400+ "MK" : {"RS" , "BG" , "GR" , "AL" },
401+ "GR" : {"BG" , "MK" , "AL" , "IT_SUD" , "TR" },
402+ "AL" : {"RS" , "MK" , "GR" , "ME" },
403+ "ME" : {"RS" , "BA" , "AL" },
404+ "TR" : {"BG" , "GR" },
405+ }
406+
407+ # Named border groups for convenient CLI usage.
408+ # ── Border parsing (library-level) ────────────────────────────────────────
409+
410+
411+ def parse_borders (specs : str | list [str ]) -> list [tuple [str , str ]]:
412+ """Parse border specifications into deduplicated (from, to) pairs.
413+
414+ Supported formats:
415+
416+ - ``"ES-FR"`` — single border
417+ - ``"ES-FR,ES-PT"`` — comma-separated within a string
418+ - ``["ES-*", "FR-*"]`` — list with wildcards (all neighbours)
419+ - ``"iberian"`` — named group
420+
421+ Deduplicates pairs while preserving insertion order, so
422+ ``"ES-*,FR-*"`` won't produce ``(ES, FR)`` and ``(FR, ES)`` twice
423+ if both wildcards expand to include each other.
424+
425+ Args:
426+ specs: A single spec string or list of spec strings.
427+
428+ Returns:
429+ Deduplicated list of ``(country_from, country_to)`` tuples.
430+
431+ Raises:
432+ InvalidParameterError: On unknown country or invalid spec format.
433+ """
434+ from .exceptions import InvalidParameterError
435+
436+ if isinstance (specs , str ):
437+ specs = [specs ]
438+
439+ flat = [v .strip () for raw in specs for v in raw .split ("," ) if v .strip ()]
440+ seen : set [tuple [str , str ]] = set ()
441+ pairs : list [tuple [str , str ]] = []
442+
443+ def _add (pair : tuple [str , str ]) -> None :
444+ if pair not in seen :
445+ seen .add (pair )
446+ pairs .append (pair )
447+
448+ for spec in flat :
449+ low = spec .lower ()
450+
451+ # Named group
452+ if low in BORDER_GROUPS :
453+ for p in BORDER_GROUPS [low ]:
454+ _add (p )
455+ continue
456+
457+ # Pair: A-B or A-*
458+ if "-" in spec :
459+ parts = spec .split ("-" , 1 )
460+ a , b = parts [0 ].strip ().upper (), parts [1 ].strip ().upper ()
461+ if b == "*" :
462+ if a not in NEIGHBOURS :
463+ raise InvalidParameterError (
464+ f"No known neighbours for '{ a } '. "
465+ f"Available: { ', ' .join (sorted (NEIGHBOURS .keys ()))} "
466+ )
467+ for n in sorted (NEIGHBOURS [a ]):
468+ _add ((a , n ))
469+ else :
470+ _add ((a , b ))
471+ continue
472+
473+ raise InvalidParameterError (
474+ f"Invalid border spec '{ spec } '. Use A-B, A-*, or a group name "
475+ f"({ ', ' .join (sorted (BORDER_GROUPS .keys ()))} )."
476+ )
477+
478+ return pairs
479+
480+
481+ BORDER_GROUPS : dict [str , list [tuple [str , str ]]] = {
482+ "iberian" : [("ES" , "FR" ), ("ES" , "PT" ), ("PT" , "ES" ), ("FR" , "ES" )],
483+ "nordic" : [
484+ ("NO_1" , "NO_2" ), ("NO_1" , "NO_3" ), ("NO_1" , "NO_5" ), ("NO_1" , "SE_3" ),
485+ ("NO_2" , "NO_1" ), ("NO_2" , "NO_5" ), ("NO_2" , "NL" ), ("NO_2" , "DK_1" ), ("NO_2" , "DE_LU" ), ("NO_2" , "GB" ),
486+ ("NO_3" , "NO_1" ), ("NO_3" , "NO_4" ), ("NO_3" , "NO_5" ), ("NO_3" , "SE_2" ),
487+ ("NO_4" , "NO_3" ), ("NO_4" , "SE_1" ), ("NO_4" , "SE_2" ), ("NO_4" , "FI" ),
488+ ("NO_5" , "NO_1" ), ("NO_5" , "NO_2" ), ("NO_5" , "NO_3" ),
489+ ("SE_1" , "NO_4" ), ("SE_1" , "SE_2" ), ("SE_1" , "FI" ),
490+ ("SE_2" , "NO_3" ), ("SE_2" , "NO_4" ), ("SE_2" , "SE_1" ), ("SE_2" , "SE_3" ),
491+ ("SE_3" , "NO_1" ), ("SE_3" , "DK_1" ), ("SE_3" , "SE_2" ), ("SE_3" , "SE_4" ), ("SE_3" , "FI" ),
492+ ("SE_4" , "DE_LU" ), ("SE_4" , "DK_2" ), ("SE_4" , "SE_3" ), ("SE_4" , "PL" ), ("SE_4" , "LT" ),
493+ ("DK_1" , "DK_2" ), ("DK_1" , "DE_LU" ), ("DK_1" , "NL" ), ("DK_1" , "NO_2" ), ("DK_1" , "SE_3" ),
494+ ("DK_2" , "DK_1" ), ("DK_2" , "DE_LU" ), ("DK_2" , "SE_4" ),
495+ ("FI" , "NO_4" ), ("FI" , "SE_1" ), ("FI" , "SE_3" ), ("FI" , "EE" ),
496+ ],
497+ "baltic" : [
498+ ("EE" , "FI" ), ("EE" , "LV" ),
499+ ("LV" , "EE" ), ("LV" , "LT" ),
500+ ("LT" , "LV" ), ("LT" , "SE_4" ), ("LT" , "PL" ),
501+ ],
502+ }
0 commit comments