Skip to content

Commit 573b822

Browse files
committed
Reworked weight naming and setting, adds MCDM approaches, display()
1 parent f5e737f commit 573b822

5 files changed

Lines changed: 479 additions & 149 deletions

File tree

climada/engine/option_appraisal/MCDM/category.py

Lines changed: 104 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,14 @@
2020

2121
import logging
2222
from collections.abc import Iterable
23-
from typing import Any, Dict, List, Optional, Set, Union
23+
from typing import Any, Dict, List, Optional, Sequence, Set, Union, cast
2424

2525
from climada.engine.option_appraisal.MCDM.constants import (
2626
DEFAULT_CATEGORY_WEIGHT,
2727
IMPORTANCE_MATCH,
2828
)
2929
from climada.engine.option_appraisal.MCDM.weights import WeightedItem
3030

31-
# Define type aliases for cleaner type hints
3231
CategoryName = str
3332
CategoryLike = Union[CategoryName, "CriteriaCategory"]
3433

@@ -38,14 +37,11 @@
3837
class CategorySpace:
3938
"""Manages a dedicated, isolated registry of CriteriaCategory objects."""
4039

41-
# Class attribute to hold the singleton default space
4240
_default_space: Optional["CategorySpace"] = None
4341

4442
def __init__(self):
4543
self._registry: Dict[str, "CriteriaCategory"] = {}
46-
self.category_weights = None
4744

48-
# --- New Class Method for Default Access ---
4945
@classmethod
5046
def get_default_space(cls) -> "CategorySpace":
5147
"""Returns the default CategorySpace instance, creating it if necessary."""
@@ -107,22 +103,32 @@ def remove(self, category: CategoryLike) -> None:
107103

108104
def register(self, category: "CriteriaCategory") -> None:
109105
if category.name in self._registry:
110-
raise ValueError(
111-
f"Category '{category.name}' already exists in space '{self.name}'."
112-
)
106+
raise ValueError(f"Category '{category.name}' already exists in space.")
113107
self._registry[category.name] = category
114108

115109
def add_category(
116110
self,
117111
name: CategoryName,
118-
parent_cats: Optional[Union[CategoryLike, List[CategoryLike]]] = None,
112+
parent_cats: Optional[Union[CategoryLike, Sequence[CategoryLike]]] = None,
119113
category_type: Optional[str] = None,
114+
weight: Optional[float] = None,
120115
overwrite: bool = False,
121116
) -> "CriteriaCategory":
117+
"""Create and register a new category in this space.
118+
119+
Parameters
120+
----------
121+
name : str
122+
parent_cats : str or CriteriaCategory or list, optional
123+
category_type : str, optional
124+
weight : float or str, optional
125+
overwrite : bool, optional
126+
"""
122127
return CriteriaCategory(
123128
name,
124129
parents=parent_cats,
125130
category_type=category_type,
131+
weight=weight,
126132
overwrite=overwrite,
127133
space=self,
128134
)
@@ -153,46 +159,74 @@ def create_subset_by_type(
153159
return subspace
154160

155161
def create_subspace(
156-
self, selection: Union[CategoryLike, List[CategoryLike]]
162+
self, selection: Union[CategoryLike, Sequence[CategoryLike]]
157163
) -> "CategorySpace":
158164
if not isinstance(selection, Iterable):
159165
selection = [selection]
160166

161167
subspace = CategorySpace()
162168
for category in selection:
163169
if isinstance(category, str):
164-
category = self.get(category)
170+
category = cast(CriteriaCategory, self.get(category))
165171

166172
subspace.register(category)
167173

168174
return subspace
169175

170176
@property
171-
def category_weights(self):
172-
return {k: self._registry[k].item_weight for k in self._registry.keys()}
177+
def effective_weights(self):
178+
return {name: cat.effective_weight for name, cat in self._registry.items()}
173179

174-
@category_weights.setter
175-
def category_weights(self, value, /):
176-
if value is None:
177-
for k, v in {
178-
cat_name: DEFAULT_CATEGORY_WEIGHT for cat_name in self._registry.keys()
179-
}.items():
180-
self._registry[k].item_weight = v
181-
else:
182-
no_match = [k for k in value.keys() if k not in self._registry.keys()]
183-
no_weight = [k for k in self._registry.keys() if k not in value.keys()]
184-
if len(no_match) > 0:
185-
LOGGER.warning(
186-
f"Some weights do not correspond to any category: {no_match}"
187-
)
180+
@property
181+
def weights(self):
182+
return {name: cat.weight for name, cat in self._registry.items()}
188183

189-
if len(no_weight) > 0:
190-
LOGGER.warning(
191-
f"No weight given for one or more categories: {no_weight}\n(will use existing, {DEFAULT_CATEGORY_WEIGHT} by default)"
192-
)
184+
@weights.setter
185+
def weights(self, value: Dict[str, float]):
186+
"""Set weights from a dict. Missing keys keep their current value.
193187
194-
for k, v in value.items():
195-
self._registry[k].item_weight = v
188+
Parameters
189+
----------
190+
value : dict[str, float]
191+
Mapping of category name to weight. Values can be float or
192+
importance strings (e.g. ``"high"``).
193+
"""
194+
if not isinstance(value, dict):
195+
raise TypeError(f"weights must be a dict, got {type(value).__name__}.")
196+
197+
unrecognized = [k for k in value if k not in self._registry]
198+
unset = [k for k in self._registry if k not in value]
199+
200+
if unrecognized:
201+
LOGGER.warning(
202+
"Weights given for unknown categories (ignored): %s", unrecognized
203+
)
204+
if unset:
205+
LOGGER.warning("No weight given for categories (unchanged): %s", unset)
206+
207+
for name, w in value.items():
208+
if name in self._registry:
209+
self._registry[name].weight = w # WeightedItem setter validates
210+
211+
def reset_weights(self, weight=None) -> None:
212+
"""Reset all category weights to ``DEFAULT_WEIGHT``."""
213+
for cat in self._registry.values():
214+
cat.weight = weight if weight else DEFAULT_CATEGORY_WEIGHT
215+
216+
def set_weight(self, category: CategoryLike, weight) -> None:
217+
"""Set the weight of a single category.
218+
219+
Parameters
220+
----------
221+
category : str or CriteriaCategory
222+
Target category name or object.
223+
weight : float or str
224+
New weight value.
225+
"""
226+
name = category if isinstance(category, str) else category.name
227+
if name not in self._registry:
228+
raise KeyError(f"Category '{name}' not in this space.")
229+
self._registry[name].weight = weight
196230

197231
@property
198232
def all_categories(self) -> List["CriteriaCategory"]:
@@ -241,11 +275,11 @@ def print_node_recursive(
241275
print(
242276
prefix
243277
+ connector
244-
+ node.category_type
278+
+ str(node.category_type)
245279
+ ": "
246280
+ node.name
247281
+ " category weight: "
248-
+ str(self.category_weights[node.name])
282+
+ str(self.weights[node.name])
249283
)
250284

251285
# Determine the prefix for the children
@@ -297,9 +331,9 @@ class CriteriaCategory(WeightedItem):
297331
def __init__(
298332
self,
299333
name: CategoryName,
300-
parents: Optional[Union[CategoryLike, List[CategoryLike]]] = None,
334+
parents: Optional[Union[CategoryLike, Sequence[CategoryLike]]] = None,
301335
category_type: Optional[str] = None,
302-
category_weight: Optional[float] = None,
336+
weight: Optional[float] = None,
303337
space: Optional[CategorySpace] = None,
304338
overwrite: Optional[bool] = False,
305339
) -> None:
@@ -310,7 +344,7 @@ def __init__(
310344
----------
311345
name : CategoryName
312346
The unique name of the category.
313-
parents : Optional[Union[CategoryLike, List[CategoryLike]]], optional
347+
parents : Optional[Union[CategoryLike, Sequence[CategoryLike]]], optional
314348
The parent criteria(s) this category inherits from. Can be a single
315349
name/object or a list of names/objects. By default, None.
316350
@@ -319,31 +353,38 @@ def __init__(
319353
ValueError
320354
If a category with the given name already exists in the registry.
321355
"""
322-
WeightedItem.__init__(self, category_weight)
356+
WeightedItem.__init__(self, weight)
323357
self._space = space if space is not None else CategorySpace.get_default_space()
324358
self.name: CategoryName = name
325359
self.category_type: str | None = category_type
326360
self._parents: Set[CriteriaCategory] = set()
327361
self._children: Set[CriteriaCategory] = set()
328362

329363
if self in self.space:
364+
existing = cast(CriteriaCategory, self.space.get(name))
330365
if not overwrite:
331-
if not self.space.get(name).has_parents_exactly(parents):
366+
if not existing.has_parents_exactly(parents):
332367
raise ValueError(
333-
f"CriteriaCategory '{name}' with different parents ({self.space.get(name)._parents_name} != {parents}) already exists in current category space ({self.space}). You can overwrite with `overwrite=True`."
368+
f"CriteriaCategory '{name}' with different parents ({existing.parents} != {parents})"
369+
f" already exists in current category space ({self.space})."
370+
" You can overwrite with `overwrite=True`."
334371
)
335372
return
336373

337374
self.space.register(self)
338375
if parents:
339-
self._add_parents(parents)
376+
self.add_parents(parents)
340377

341378
@property
342-
def weight(self):
379+
def effective_weight(self):
380+
"""Hierarchical weight: max of own weight and all ancestors' weights.
381+
382+
Distinct from ``weight``, which is this category's direct assignment.
383+
"""
343384
if len(self.parents) > 0:
344-
return max(self.item_weight, max([p.weight for p in self.parents]))
385+
return max(self.weight, max([p.effective_weight for p in self.parents]))
345386
else:
346-
return self.item_weight
387+
return self.weight
347388

348389
@property
349390
def space(self):
@@ -380,7 +421,7 @@ def _parents_names(self):
380421
return [p.name for p in self.parents]
381422

382423
def has_parents_exactly(
383-
self, check_parents: Union[CategoryLike, List[CategoryLike], None]
424+
self, check_parents: Union[CategoryLike, Sequence[CategoryLike], None]
384425
) -> bool:
385426
"""
386427
Checks if the criteria's set of parents is exactly equal to the provided set of parents.
@@ -389,7 +430,7 @@ def has_parents_exactly(
389430
390431
Parameters
391432
----------
392-
check_parents : Union[CategoryLike, List[CategoryLike]]
433+
check_parents : Union[CategoryLike, Sequence[CategoryLike]]
393434
A single parent or a list of parent names or CriteriaCategory objects
394435
to compare against the criteria's actual parents.
395436
@@ -408,19 +449,19 @@ def has_parents_exactly(
408449
return True
409450

410451
if not isinstance(check_parents, list):
411-
check_parents = [check_parents]
452+
check_parents = [cast(CategoryLike, check_parents)]
412453

413454
return check_parents == self._parents_names
414455

415-
def _add_parents(
416-
self, parent_names: Union[CategoryLike, List[CategoryLike]]
456+
def add_parents(
457+
self, parent_names: Union[CategoryLike, Sequence[CategoryLike]]
417458
) -> None:
418459
"""
419460
Internal helper to resolve and establish parent links.
420461
421462
Parameters
422463
----------
423-
parent_names : Union[CategoryLike, List[CategoryLike]]
464+
parent_names : Union[CategoryLike, Sequence[CategoryLike]]
424465
The parent criteria(s) to link.
425466
426467
Raises
@@ -430,7 +471,7 @@ def _add_parents(
430471
TypeError
431472
If an item in the parent list is not a string or CriteriaCategory object.
432473
"""
433-
if not isinstance(parent_names, list):
474+
if not isinstance(parent_names, Sequence):
434475
parent_names = [parent_names]
435476

436477
for p_name in parent_names:
@@ -502,12 +543,12 @@ def __repr__(self, indent=0) -> str:
502543
parent_names = sorted([p.name for p in self.parents])
503544
parent_str = f" Parents: {', '.join(parent_names)}" if parent_names else "none"
504545
indent_space = " " * indent
505-
return f"""{indent_space}name: {self.name} weight: {self.item_weight} type: {self.category_type}\n{indent_space}parents: {parent_str}"""
546+
return f"""{indent_space}name: {self.name} weight: {self.weight} type: {self.category_type}\n{indent_space}parents: {parent_str}"""
506547

507548

508549
def create_criteria_category(
509550
name: CategoryName,
510-
parent_cats: Optional[Union[CategoryLike, List[CategoryLike]]] = None,
551+
parent_cats: Optional[Union[CategoryLike, Sequence[CategoryLike]]] = None,
511552
category_type: Optional[str] = None,
512553
space: Optional[CategorySpace] = None,
513554
overwrite: bool = False,
@@ -569,7 +610,7 @@ def update_categories_from_dict(
569610
def __categories_hierarchy_recursion(
570611
hierarchy_dict: Dict[str, Any],
571612
space: CategorySpace,
572-
current_parents: Optional[Union[CategoryLike, List[CategoryLike]]] = None,
613+
current_parents: Optional[Union[CategoryLike, Sequence[CategoryLike]]] = None,
573614
) -> None:
574615
"""
575616
Recursively creates a hierarchy of CriteriaCategory objects from a nested dictionary.
@@ -613,7 +654,7 @@ def __categories_hierarchy_recursion(
613654
)
614655
except ValueError as e:
615656
if "different parents" in str(e):
616-
space.get(category_name)._add_parents(parent_list)
657+
space.get(category_name).add_parents(parent_list) # type: ignore
617658
else:
618659
print(
619660
f"Warning: Category '{category_name}' skipped (likely duplicate). Error: {e}"
@@ -646,15 +687,15 @@ class CategorizedObject:
646687
----------
647688
name : str
648689
The name or identifier of the object.
649-
categories : set[CriteriaCategory]
690+
categories : Sequence[CriteriaCategory]
650691
The set of CriteriaCategory objects this instance is directly assigned to.
651692
"""
652693

653694
def __init__(
654695
self,
655696
name: str,
656697
categories: Optional[
657-
Union[CategoryLike, List[CategoryLike], Set[CriteriaCategory]]
698+
Union[CategoryLike, Sequence[CategoryLike], Sequence[CriteriaCategory]]
658699
] = None,
659700
space: Optional[CategorySpace] = None,
660701
) -> None:
@@ -687,7 +728,10 @@ def category_space(self):
687728
return self._space
688729

689730
def add_categories(
690-
self, categories: Union[CategoryLike, List[CategoryLike], Set[CriteriaCategory]]
731+
self,
732+
categories: Union[
733+
CategoryLike, Sequence[CategoryLike], Sequence[CriteriaCategory]
734+
],
691735
) -> None:
692736
"""
693737
Adds one or more criteria categories to the object by name.
@@ -702,7 +746,7 @@ def add_categories(
702746
ValueError
703747
If a category name does not exist in the CriteriaCategory registry.
704748
"""
705-
if not isinstance(categories, (list, set)):
749+
if not isinstance(categories, Sequence):
706750
categories = [categories]
707751

708752
for cat_to_add in categories:

climada/engine/option_appraisal/MCDM/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
DEFAULT_CATEGORY_WEIGHT = 0.0
1010
DEFAULT_CRITERION_BASE_WEIGHT = 0.0
11+
DEFAULT_ITEM_WEIGHT = 0.0
1112
IMPORTANCE_MATCH = {
1213
"none": 0.0,
1314
"very low": 0.2,

0 commit comments

Comments
 (0)