Skip to content

Commit 4b9286a

Browse files
committed
fix: mcda normalize goals to 100% total
1 parent 1c3be2d commit 4b9286a

5 files changed

Lines changed: 321 additions & 131 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""
2+
Shared helpers for building MCDA goals and resolving goal weights.
3+
"""
4+
from typing import Dict, Optional
5+
6+
from ..schemas.mcda import Alternative, Goal
7+
from ..utils.data_loaders import (
8+
get_goal_weights_for_perspective,
9+
normalize_goal_weights,
10+
)
11+
from ..utils.logger import get_logger
12+
13+
14+
logger = get_logger(__name__)
15+
16+
17+
def get_weight_by_goal(
18+
goal_name: str,
19+
goal_weights: Optional[Dict[str, float]],
20+
default_weight: float,
21+
) -> float:
22+
"""Retrieve weight for a specific goal by name."""
23+
if goal_weights and goal_name in goal_weights:
24+
return goal_weights[goal_name]
25+
26+
if goal_weights:
27+
logger.warning(
28+
f"Goal '{goal_name}' not found in perspective weights. "
29+
f"Using default weight: {default_weight:.3f}"
30+
)
31+
return default_weight
32+
33+
34+
def get_min_max_values_per_goal(
35+
goal_name: str,
36+
alternatives: list[Alternative],
37+
) -> tuple[Optional[float], Optional[float]]:
38+
"""Calculate minimum and maximum values for a specific goal."""
39+
min_value = None
40+
max_value = None
41+
for alt in alternatives:
42+
alt_goal_value = alt.values.get(goal_name)
43+
if alt_goal_value is not None:
44+
if (min_value is None) or (alt_goal_value < min_value):
45+
min_value = alt_goal_value
46+
if (max_value is None) or (alt_goal_value > max_value):
47+
max_value = alt_goal_value
48+
49+
return min_value, max_value
50+
51+
52+
def build_goal_for_name(
53+
goal_name: str,
54+
alternatives: list[Alternative],
55+
goal_weights: Optional[Dict[str, float]],
56+
default_weight: float,
57+
) -> Optional[Goal]:
58+
"""Build a goal with PROMETHEE thresholds, or return None when invalid."""
59+
weight = get_weight_by_goal(goal_name, goal_weights, default_weight)
60+
min_value, max_value = get_min_max_values_per_goal(goal_name, alternatives)
61+
62+
if weight is None:
63+
logger.warning(f"Skipping goal '{goal_name}' due to missing weight")
64+
return None
65+
66+
if (min_value is None) or (max_value is None):
67+
logger.warning(
68+
f"Skipping goal '{goal_name}' due to missing min/max values "
69+
f"min='{min_value}', max='{max_value}'"
70+
)
71+
return None
72+
73+
return Goal(
74+
name=goal_name,
75+
weight=weight,
76+
direction="max",
77+
Q=0,
78+
S=0,
79+
P=max_value - min_value,
80+
F="t3",
81+
)
82+
83+
84+
def apply_normalized_goal_weights(
85+
goals: list[Goal],
86+
context_label: str,
87+
) -> None:
88+
"""Normalize existing goal weights in place when goals are present."""
89+
if not goals:
90+
return
91+
92+
raw_weights = {goal.name: goal.weight for goal in goals}
93+
normalized_weights = normalize_goal_weights(raw_weights)
94+
if not normalized_weights:
95+
return
96+
97+
pre_normalization_total = sum(raw_weights.values())
98+
if abs(pre_normalization_total - 1.0) > 0.05:
99+
logger.warning(
100+
"Goal weights total before normalization deviates from 1.0",
101+
extra={
102+
"pre_normalization_total": pre_normalization_total,
103+
"goal_count": len(goals),
104+
},
105+
)
106+
107+
logger.debug(
108+
f"Normalized goal weights for {context_label}",
109+
extra={
110+
"pre_normalization_total": pre_normalization_total,
111+
"post_normalization_total": sum(normalized_weights.values()),
112+
"weights": normalized_weights,
113+
},
114+
)
115+
116+
for goal in goals:
117+
goal.weight = normalized_weights[goal.name]
118+
119+
120+
def get_goal_weights(
121+
perspective: Optional[str],
122+
use_qualitative_prefix: bool = False,
123+
) -> Optional[Dict[str, float]]:
124+
"""Get perspective goal weights or None when perspective is missing/invalid."""
125+
if perspective:
126+
try:
127+
goal_weights = get_goal_weights_for_perspective(perspective)
128+
if use_qualitative_prefix:
129+
logger.info(
130+
f"Using qualitative goal weights for perspective: {perspective}"
131+
)
132+
else:
133+
logger.info(
134+
f"Using goal weights for perspective: {perspective}")
135+
return goal_weights
136+
except ValueError as error:
137+
logger.warning(
138+
f"Failed to load perspective weights: {error}. Using equal weights."
139+
)
140+
return None
141+
142+
logger.info("No perspective specified, using equal weights")
143+
return None
144+
145+
146+
def resolve_goal_weights(
147+
perspective: Optional[str],
148+
personalized_goal_weights: Optional[Dict[str, float]],
149+
personalized_message: str,
150+
use_qualitative_prefix: bool = False,
151+
) -> Optional[Dict[str, float]]:
152+
"""Resolve personalized or perspective-based goal weights."""
153+
if perspective == "user_personalized" and personalized_goal_weights:
154+
normalized = normalize_goal_weights(personalized_goal_weights)
155+
logger.info(personalized_message)
156+
return normalized
157+
158+
return get_goal_weights(
159+
perspective=perspective,
160+
use_qualitative_prefix=use_qualitative_prefix,
161+
)

src/sum_impact_assessment/services/mcda_qualitative_job.py

Lines changed: 30 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,15 @@
99
from ..schemas.job import JobStatusEnum
1010
from ..schemas.mcda import Goal, Alternative
1111
from ..utils.logger import get_logger
12-
from ..utils.data_loaders import get_goal_weights_for_perspective, load_mcda_config, normalize_goal_weights
12+
from ..utils.data_loaders import load_mcda_config
13+
from .mcda_goal_builder import (
14+
apply_normalized_goal_weights,
15+
build_goal_for_name,
16+
get_goal_weights as get_resolved_goal_weights,
17+
get_min_max_values_per_goal,
18+
get_weight_by_goal,
19+
resolve_goal_weights,
20+
)
1321

1422
# Initialize logger
1523
logger = get_logger(__name__)
@@ -47,15 +55,7 @@ def _get_weight_by_goal(goal_name: str, goal_weights: Optional[Dict[str, float]]
4755
Returns:
4856
Weight value for the goal
4957
"""
50-
if goal_weights and goal_name in goal_weights:
51-
return goal_weights[goal_name]
52-
else:
53-
if goal_weights:
54-
logger.warning(
55-
f"Goal '{goal_name}' not found in perspective weights. "
56-
f"Using default weight: {default_weight:.3f}"
57-
)
58-
return default_weight
58+
return get_weight_by_goal(goal_name, goal_weights, default_weight)
5959

6060
@staticmethod
6161
def _get_min_max_values_per_goal(goal_name: str, alternatives: list[Alternative]) -> tuple[Optional[float], Optional[float]]:
@@ -69,16 +69,7 @@ def _get_min_max_values_per_goal(goal_name: str, alternatives: list[Alternative]
6969
Returns:
7070
Tuple of (min_value, max_value) for the goal, or (None, None) if no values found
7171
"""
72-
min_value = None
73-
max_value = None
74-
for alt in alternatives:
75-
alt_goal_value = alt.values.get(goal_name)
76-
if alt_goal_value is not None:
77-
if (min_value is None) or (alt_goal_value < min_value):
78-
min_value = alt_goal_value
79-
if (max_value is None) or (alt_goal_value > max_value):
80-
max_value = alt_goal_value
81-
return min_value, max_value
72+
return get_min_max_values_per_goal(goal_name, alternatives)
8273

8374
@staticmethod
8475
def build_alternatives(config: Dict[str, Any]) -> list[Alternative]:
@@ -137,32 +128,17 @@ def build_goals(alternatives: list[Alternative], goal_weights: Optional[Dict[str
137128
goals = []
138129

139130
for goal_name in goal_names:
140-
weight = McdaQualitativeJob._get_weight_by_goal(
141-
goal_name, goal_weights, default_weight)
142-
143-
min_value, max_value = McdaQualitativeJob._get_min_max_values_per_goal(
144-
goal_name, alternatives)
145-
146-
if weight is None:
147-
logger.warning(
148-
f"Skipping goal '{goal_name}' due to missing weight")
149-
continue
150-
if (min_value is None) or (max_value is None):
151-
logger.warning(
152-
f"Skipping goal '{goal_name}' due to missing min/max values min='{min_value}', max='{max_value}'")
131+
goal = build_goal_for_name(
132+
goal_name=goal_name,
133+
alternatives=alternatives,
134+
goal_weights=goal_weights,
135+
default_weight=default_weight,
136+
)
137+
if goal is None:
153138
continue
139+
goals.append(goal)
154140

155-
goals.append(
156-
Goal(
157-
name=goal_name,
158-
weight=weight,
159-
direction="max",
160-
Q=0,
161-
S=0,
162-
P=max_value - min_value,
163-
F='t3'
164-
)
165-
)
141+
apply_normalized_goal_weights(goals, context_label="qualitative MCDA")
166142

167143
logger.info(f"Created {len(goals)} qualitative goals")
168144
return goals
@@ -179,19 +155,10 @@ def get_goal_weights(perspective: Optional[str]) -> Optional[Dict[str, float]]:
179155
Dictionary mapping normalized goal names to weights,
180156
or None if no perspective or loading fails.
181157
"""
182-
if perspective:
183-
try:
184-
goal_weights = get_goal_weights_for_perspective(perspective)
185-
logger.info(
186-
f"Using qualitative goal weights for perspective: {perspective}")
187-
return goal_weights
188-
except ValueError as e:
189-
logger.warning(
190-
f"Failed to load perspective weights: {e}. Using equal weights.")
191-
return None
192-
else:
193-
logger.info("No perspective specified, using equal weights")
194-
return None
158+
return get_resolved_goal_weights(
159+
perspective,
160+
use_qualitative_prefix=True,
161+
)
195162

196163
@staticmethod
197164
def run(job_id: str, db: Session, params: Optional[Dict] = None) -> None:
@@ -236,12 +203,12 @@ def run(job_id: str, db: Session, params: Optional[Dict] = None) -> None:
236203
job_repo.update_job_data(
237204
job_id=job_id, input_data=input_data_snapshot)
238205

239-
if perspective == "user_personalized" and personalized_goal_weights:
240-
goal_weights = normalize_goal_weights(
241-
personalized_goal_weights)
242-
logger.info("Using user-personalized qualitative goal weights")
243-
else:
244-
goal_weights = McdaQualitativeJob.get_goal_weights(perspective)
206+
goal_weights = resolve_goal_weights(
207+
perspective=perspective,
208+
personalized_goal_weights=personalized_goal_weights,
209+
personalized_message="Using user-personalized qualitative goal weights",
210+
use_qualitative_prefix=True,
211+
)
245212

246213
alternatives = McdaQualitativeJob.build_alternatives(config)
247214
goals = McdaQualitativeJob.build_goals(alternatives, goal_weights)

0 commit comments

Comments
 (0)