@@ -118,6 +118,61 @@ def unpack_poi_id(key: POI_ID, definition: _Abstract_POI_Definition) -> tuple[in
118118 return region , subregion
119119
120120
121+ # ---------------------------------------------------------------------------
122+ # label_name (custom per-point / per-region names stored in POI.info["label_name"])
123+ # In-memory (new) format: {region:int -> {subregion:int -> name:str, "name": group_name:str}}
124+ # ---------------------------------------------------------------------------
125+ LABEL_NAME = "label_name"
126+ _GROUP_NAME_KEY = "name"
127+
128+
129+ def normalize_label_name (d : dict | None ) -> dict [int , dict ]:
130+ """Normalize any ``label_name`` mapping to the nested form (migration helper).
131+
132+ Target: ``{region:int -> {subregion:int -> name:str, "name": group_name:str}}``.
133+ Accepts and migrates: the old flat ``{"(1, 2)": "C2"}`` form, a JSON-loaded nested
134+ form with string keys ``{"1": {"2": "C2", "name": "Spine"}}``, and the already-nested
135+ form (idempotent).
136+ """
137+ import ast
138+
139+ if not d :
140+ return {}
141+ out : dict [int , dict ] = {}
142+ # old flat format: every key is a "(region, subregion)" tuple string
143+ if all (isinstance (k , str ) and k .strip ().startswith ("(" ) for k in d ):
144+ for k , name in d .items ():
145+ region , subregion = ast .literal_eval (k )
146+ out .setdefault (int (region ), {})[int (subregion )] = name
147+ return out
148+ # nested format (region keys may be JSON strings)
149+ for region , sub in d .items ():
150+ target = out .setdefault (int (region ), {})
151+ if isinstance (sub , dict ):
152+ for s , name in sub .items ():
153+ target [_GROUP_NAME_KEY if s == _GROUP_NAME_KEY else int (s )] = name
154+ else : # degenerate {region: name} -> treat as the region group name
155+ target [_GROUP_NAME_KEY ] = sub
156+ return out
157+
158+
159+ def label_name_dict (info : dict ) -> dict [int , dict ]:
160+ """Return the normalized nested ``label_name`` dict from ``info`` (normalizing in place)."""
161+ ln = info .get (LABEL_NAME )
162+ if not ln :
163+ return {}
164+ norm = normalize_label_name (ln )
165+ info [LABEL_NAME ] = norm # cache the normalized form back into info
166+ return norm
167+
168+
169+ def _id_of (x ) -> int :
170+ """Resolve an Enum member / int / numeric string to its integer id."""
171+ if isinstance (x , Enum ):
172+ return x .value
173+ return int (x )
174+
175+
121176class POI_Descriptor (AbstractSet , MutableMapping ):
122177 """Two-level dictionary that maps ``(region, subregion)`` pairs to 3-D coordinates.
123178
@@ -754,6 +809,57 @@ def keys_subregion(self, sort: bool = False) -> list[int]:
754809 self .sort ()
755810 return list (self .centroids .keys_subregion ())
756811
812+ def label_name (self , region , subregion ) -> str | None :
813+ """Return the human-readable name of the point ``(region, subregion)``.
814+
815+ A custom name in ``info["label_name"]`` takes priority over the ``level_two_info`` enum
816+ name (and finally falls back to the raw id as a string). Accepts ints or Enum members.
817+ A warning is raised when a custom name is set that differs from the ``level_two_info``
818+ name for that id (when ``level_two_info`` is given).
819+ """
820+ import warnings
821+
822+ region_i , subregion_i = _id_of (region ), _id_of (subregion )
823+ custom = label_name_dict (self .info ).get (region_i , {}).get (subregion_i )
824+ enum_name = None
825+ if self .level_two_info not in (None , Any ):
826+ n = self .level_two_info ._get_name (subregion_i , no_raise = True )
827+ enum_name = n if n != str (subregion_i ) else None
828+ if custom is not None :
829+ if enum_name is not None and custom != enum_name :
830+ warnings .warn (
831+ f"label_name { custom !r} for subregion { subregion_i } is not the { self .level_two_info .__name__ } name { enum_name !r} " ,
832+ stacklevel = 2 ,
833+ )
834+ return custom
835+ return enum_name if enum_name is not None else str (subregion_i )
836+
837+ def set_label_name (self , region , subregion , name : str ) -> None :
838+ """Set a custom name for the point ``(region, subregion)`` in ``info["label_name"]``."""
839+ d = label_name_dict (self .info )
840+ d .setdefault (_id_of (region ), {})[_id_of (subregion )] = name
841+ self .info [LABEL_NAME ] = d
842+
843+ def level_one_name (self , region ) -> str | None :
844+ """Return the group (level-one) name of ``region``.
845+
846+ A custom group name in ``info["label_name"][region]["name"]`` takes priority over the
847+ ``level_one_info`` enum name. Accepts an int or Enum member.
848+ """
849+ region_i = _id_of (region )
850+ custom = label_name_dict (self .info ).get (region_i , {}).get (_GROUP_NAME_KEY )
851+ if custom is not None :
852+ return custom
853+ if self .level_one_info not in (None , Any ):
854+ return self .level_one_info ._get_name (region_i , no_raise = True )
855+ return str (region_i )
856+
857+ def set_level_one_name (self , region , name : str ) -> None :
858+ """Set a custom group (level-one) name for ``region`` in ``info["label_name"]``."""
859+ d = label_name_dict (self .info )
860+ d .setdefault (_id_of (region ), {})[_GROUP_NAME_KEY ] = name
861+ self .info [LABEL_NAME ] = d
862+
757863 def values (self , sort : bool = False ) -> list [COORDINATE ]:
758864 """Return all stored ``(x, y, z)`` coordinate tuples.
759865
@@ -1030,16 +1136,23 @@ def join_left(self, pois: Self, inplace=False, _right_join=False) -> Self:
10301136 Self: The combined set of centroids, either in-place or as a new set, depending on the 'inplace' parameter.
10311137 """
10321138 ctd_list = self .centroids
1033- if "label_name" in pois .info and "label_name" not in self .info :
1034- self .info ["label_name" ] = {}
1139+ src_ln = label_name_dict (pois .info )
10351140 if not inplace :
10361141 ctd_list = ctd_list .copy ()
1142+ dst_ln = label_name_dict (self .info ) if src_ln else None
1143+ if dst_ln is not None :
1144+ self .info [LABEL_NAME ] = dst_ln
10371145 for x , y , c in pois .items ():
10381146 if (x , y ) in self and not _right_join :
10391147 continue
10401148 ctd_list [x , y ] = c
1041- if "label_name" in pois .info and f"({ x } , { y } )" in pois .info ["label_name" ]:
1042- self .info ["label_name" ][f"({ x } , { y } )" ] = pois .info ["label_name" ][f"({ x } , { y } )" ]
1149+ if dst_ln is not None :
1150+ name = src_ln .get (x , {}).get (y )
1151+ if name is not None :
1152+ dst_ln .setdefault (x , {})[y ] = name
1153+ grp = src_ln .get (x , {}).get (_GROUP_NAME_KEY )
1154+ if grp is not None :
1155+ dst_ln .setdefault (x , {}).setdefault (_GROUP_NAME_KEY , grp )
10431156 if inplace :
10441157 return self
10451158 return self .copy (ctd_list )
0 commit comments