Skip to content

Commit a39ecb7

Browse files
committed
feat: custom mcda analysis route
1 parent 2518c80 commit a39ecb7

9 files changed

Lines changed: 444 additions & 46 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ API_VERSION=0.1.0
2020
LOG_LEVEL=DEBUG
2121
LOG_FORMAT=json
2222

23+
#openssl rand -base64 32
2324
INTERNAL_API_KEY=xxx

src/sum_impact_assessment/api/routes/jobs.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,50 @@ def trigger_job(
116116
}
117117
}
118118
},
119+
"mcda_custom_analysis": {
120+
"summary": "Custom MCDA Analysis with user-defined goals and alternatives",
121+
"description": "Run MCDA analysis using fully customized goals, weights, and alternative scores",
122+
"value": {
123+
"params": {
124+
"name": "Custom MCDA Run",
125+
"goals": [
126+
{
127+
"name": "Environmental Impact",
128+
"weight": 0.35,
129+
"direction": "max"
130+
},
131+
{
132+
"name": "Economic Cost",
133+
"weight": 0.25,
134+
"direction": "min"
135+
},
136+
{
137+
"name": "Social Acceptance",
138+
"weight": 0.40,
139+
"direction": "max"
140+
}
141+
],
142+
"alternatives": [
143+
{
144+
"name": "Project A",
145+
"values": {
146+
"Environmental Impact": 0.82,
147+
"Economic Cost": 1200.0,
148+
"Social Acceptance": 0.67
149+
}
150+
},
151+
{
152+
"name": "Project B",
153+
"values": {
154+
"Environmental Impact": 0.75,
155+
"Economic Cost": 980.0,
156+
"Social Acceptance": 0.74
157+
}
158+
}
159+
]
160+
}
161+
}
162+
},
119163
"mcda_quantitative_user_personalized": {
120164
"summary": "Quantitative MCDA Analysis for user-personalized perspective",
121165
"description": "Run MCDA analysis with user-personalized stakeholder weights, from Quantitative data from KPI/measures impact analysis",

src/sum_impact_assessment/jobs/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
from ..services.kpi_measures_analysis_job import KpiMeasuresAnalysisJob
66
from ..services.mcda_quantitative_job import McdaQuantitativeJob
77
from ..services.mcda_qualitative_job import McdaQualitativeJob
8+
from ..services.mcda_custom_job import McdaCustomJob
89
from ..schemas.job import JobNameEnum
910

1011
# Job registry mapping job names to job classes
1112
JOB_REGISTRY: Dict[JobNameEnum, Type] = {
1213
JobNameEnum.KPI_MEASURES_ANALYSIS: KpiMeasuresAnalysisJob,
1314
JobNameEnum.MCDA_ANALYSIS_QUANTITATIVE: McdaQuantitativeJob,
14-
JobNameEnum.MCDA_ANALYSIS_QUALITATIVE: McdaQualitativeJob
15+
JobNameEnum.MCDA_ANALYSIS_QUALITATIVE: McdaQualitativeJob,
16+
JobNameEnum.MCDA_ANALYSIS_CUSTOM: McdaCustomJob
1517
}
1618

1719

src/sum_impact_assessment/models/mcda_analysis/promethee_gaia_analysis.py

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -321,29 +321,36 @@ def run_analysis(self, run_visualizations: bool = False) -> MCDAAnalysisOutput:
321321
# Run all analyses
322322
pI_results = self.run_prometheeI(graph=False)
323323
pII_results = self.run_prometheeII(graph=False)
324-
gaia_results = self.run_gaia_custom(n_components=2)
325-
326-
# Build GAIA alternative coordinates list (using keys)
327-
gaia_alternatives = [
328-
GAIAAlternativeCoordinate(
329-
# Already 'a1', 'a2', etc.
330-
key=gaia_results['alternative_names'][i],
331-
x=float(gaia_results['alternative_coords'][i][0]),
332-
y=float(gaia_results['alternative_coords'][i][1])
333-
)
334-
for i in range(len(gaia_results['alternative_names']))
335-
]
336-
337-
# Build GAIA criterion vectors list (using keys)
338-
gaia_criteria = [
339-
GAIACriterionVector(
340-
# Already 'c1', 'c2', etc.
341-
key=gaia_results['criteria_names'][i],
342-
x=float(gaia_results['criteria_coords'][i][0]),
343-
y=float(gaia_results['criteria_coords'][i][1])
344-
)
345-
for i in range(len(gaia_results['criteria_names']))
346-
]
324+
325+
# GAIA requires min(n_alternatives, n_criteria) >= 2 for PCA
326+
gaia_alternatives = None
327+
gaia_criteria = None
328+
gaia_decision_stick = None
329+
gaia_quality = None
330+
gaia_method = None
331+
try:
332+
gaia_results = self.run_gaia_custom(n_components=2)
333+
gaia_alternatives = [
334+
GAIAAlternativeCoordinate(
335+
key=gaia_results['alternative_names'][i],
336+
x=float(gaia_results['alternative_coords'][i][0]),
337+
y=float(gaia_results['alternative_coords'][i][1])
338+
)
339+
for i in range(len(gaia_results['alternative_names']))
340+
]
341+
gaia_criteria = [
342+
GAIACriterionVector(
343+
key=gaia_results['criteria_names'][i],
344+
x=float(gaia_results['criteria_coords'][i][0]),
345+
y=float(gaia_results['criteria_coords'][i][1])
346+
)
347+
for i in range(len(gaia_results['criteria_names']))
348+
]
349+
gaia_decision_stick = [float(x) for x in gaia_results['decision_stick']]
350+
gaia_quality = float(gaia_results['quality_percentage'])
351+
gaia_method = gaia_results['method']
352+
except Exception:
353+
pass
347354

348355
# Create the output model
349356
output = MCDAAnalysisOutput(
@@ -355,10 +362,9 @@ def run_analysis(self, run_visualizations: bool = False) -> MCDAAnalysisOutput:
355362
ranking=pII_results['ranking'],
356363
gaia_alternatives=gaia_alternatives,
357364
gaia_criteria=gaia_criteria,
358-
gaia_decision_stick=[float(x)
359-
for x in gaia_results['decision_stick']],
360-
gaia_quality=float(gaia_results['quality_percentage']),
361-
gaia_method=gaia_results['method']
365+
gaia_decision_stick=gaia_decision_stick,
366+
gaia_quality=gaia_quality,
367+
gaia_method=gaia_method
362368
)
363369

364370
# Optionally show visualizations

src/sum_impact_assessment/schemas/job.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class JobNameEnum(str, Enum):
1414
KPI_MEASURES_ANALYSIS = "kpi_measures_analysis"
1515
MCDA_ANALYSIS_QUANTITATIVE = "mcda_analysis_quantitative"
1616
MCDA_ANALYSIS_QUALITATIVE = "mcda_analysis_qualitative"
17+
MCDA_ANALYSIS_CUSTOM = "mcda_analysis_custom"
1718

1819

1920
class JobStatusEnum(str, Enum):

src/sum_impact_assessment/schemas/mcda/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@
66
from .goal import Goal
77
from .alternative import Alternative
88
from .mcda_analysis_output import MCDAAnalysisOutput, GAIAAlternativeCoordinate, GAIACriterionVector
9+
from .mcda_custom_params import McdaCustomAnalysisParams, CustomGoalInput, CustomAlternativeInput
910

1011
__all__ = [
1112
'Goal',
1213
'Alternative',
1314
'MCDAAnalysisOutput',
1415
'GAIAAlternativeCoordinate',
1516
'GAIACriterionVector',
17+
'McdaCustomAnalysisParams',
18+
'CustomGoalInput',
19+
'CustomAlternativeInput',
1620
]

src/sum_impact_assessment/schemas/mcda/mcda_analysis_output.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
- Use alternative_labels and criteria_labels to map keys to full names
1111
"""
1212
from pydantic import BaseModel, Field
13-
from typing import Dict, List, Literal
13+
from typing import Dict, List, Literal, Optional
1414

1515

1616
class GAIAAlternativeCoordinate(BaseModel):
@@ -84,29 +84,29 @@ class MCDAAnalysisOutput(BaseModel):
8484
)
8585

8686
# GAIA: Visual decision plane data (using keys)
87-
gaia_alternatives: List[GAIAAlternativeCoordinate] = Field(
88-
...,
89-
description="2D coordinates for each alternative in GAIA plane. Each has 'key' (a1, a2, etc), 'x', and 'y'."
87+
gaia_alternatives: Optional[List[GAIAAlternativeCoordinate]] = Field(
88+
default=None,
89+
description="2D coordinates for each alternative in GAIA plane. Each has 'key' (a1, a2, etc), 'x', and 'y'. None if GAIA projection could not be computed."
9090
)
91-
gaia_criteria: List[GAIACriterionVector] = Field(
92-
...,
93-
description="2D vectors for each criterion in GAIA plane. Each has 'key' (c1, c2, etc), 'x', and 'y'. Arrow direction indicates preference direction."
91+
gaia_criteria: Optional[List[GAIACriterionVector]] = Field(
92+
default=None,
93+
description="2D vectors for each criterion in GAIA plane. Each has 'key' (c1, c2, etc), 'x', and 'y'. Arrow direction indicates preference direction. None if GAIA projection could not be computed."
9494
)
95-
gaia_decision_stick: List[float] = Field(
96-
...,
97-
description="[x, y] coordinates of the decision stick (weighted sum of criterion vectors).",
95+
gaia_decision_stick: Optional[List[float]] = Field(
96+
default=None,
97+
description="[x, y] coordinates of the decision stick (weighted sum of criterion vectors). None if GAIA projection could not be computed.",
9898
min_length=2,
9999
max_length=2
100100
)
101-
gaia_quality: float = Field(
102-
...,
103-
ge=0,
104-
le=100,
105-
description="Quality percentage of the 2D GAIA projection. Represents how much variance is explained by PC1 and PC2."
101+
gaia_quality: Optional[float] = Field(
102+
default=None,
103+
# ge=0,
104+
# le=100,
105+
description="Quality percentage of the 2D GAIA projection. Represents how much variance is explained by PC1 and PC2. None if GAIA projection could not be computed."
106106
)
107-
gaia_method: Literal['pca', 'svd'] = Field(
108-
default='pca',
109-
description="Dimensionality reduction method used for GAIA plane construction."
107+
gaia_method: Optional[Literal['pca', 'svd']] = Field(
108+
default=None,
109+
description="Dimensionality reduction method used for GAIA plane construction. None if GAIA projection could not be computed."
110110
)
111111

112112
class Config:
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""
2+
MCDA Custom Analysis Request Schemas
3+
4+
Defines input schemas for fully customized MCDA analysis:
5+
- User-defined goals with weights and directions
6+
- User-defined alternatives with scores per goal
7+
"""
8+
from pydantic import BaseModel, Field, model_validator
9+
from typing import Dict, List, Literal, Optional, Set
10+
11+
12+
class CustomGoalInput(BaseModel):
13+
"""
14+
Represents a user-defined goal in a custom MCDA analysis.
15+
"""
16+
name: str = Field(..., description="Goal name")
17+
weight: float = Field(..., gt=0, description="Goal weight (must be > 0)")
18+
direction: Literal["max", "min"] = Field(
19+
"max", description="Optimization direction for the goal"
20+
)
21+
22+
23+
class CustomAlternativeInput(BaseModel):
24+
"""
25+
Represents a user-defined alternative with scores per goal.
26+
"""
27+
name: str = Field(..., description="Alternative name")
28+
values: Dict[str, float] = Field(
29+
..., description="Scores per goal (goal name -> score)"
30+
)
31+
32+
33+
class McdaCustomAnalysisParams(BaseModel):
34+
"""
35+
Request schema for a fully customized MCDA analysis.
36+
"""
37+
name: Optional[str] = Field(None, description="Optional analysis name")
38+
goals: List[CustomGoalInput] = Field(
39+
..., description="List of goals with weights and directions"
40+
)
41+
alternatives: List[CustomAlternativeInput] = Field(
42+
..., description="List of alternatives with scores per goal"
43+
)
44+
45+
@model_validator(mode="after")
46+
def validate_structure(self):
47+
goals = self.goals or []
48+
alternatives = self.alternatives or []
49+
50+
if len(goals) < 2:
51+
raise ValueError("At least two goals are required for MCDA analysis")
52+
if len(alternatives) < 2:
53+
raise ValueError("At least two alternatives are required for MCDA analysis")
54+
55+
goal_names = [goal.name for goal in goals]
56+
unique_goal_names: Set[str] = set(goal_names)
57+
if len(unique_goal_names) != len(goal_names):
58+
raise ValueError("Goal names must be unique")
59+
60+
alt_names = [alt.name for alt in alternatives]
61+
if len(set(alt_names)) != len(alt_names):
62+
raise ValueError("Alternative names must be unique")
63+
64+
for alt in alternatives:
65+
if not alt.values:
66+
raise ValueError(
67+
f"Alternative '{alt.name}' must define scores for all goals"
68+
)
69+
missing = unique_goal_names.difference(alt.values.keys())
70+
extra = set(alt.values.keys()).difference(unique_goal_names)
71+
if missing:
72+
missing_list = ", ".join(sorted(missing))
73+
raise ValueError(
74+
f"Alternative '{alt.name}' is missing scores for goals: {missing_list}"
75+
)
76+
if extra:
77+
extra_list = ", ".join(sorted(extra))
78+
raise ValueError(
79+
f"Alternative '{alt.name}' has scores for unknown goals: {extra_list}"
80+
)
81+
82+
return self
83+
84+
class Config:
85+
json_schema_extra = {
86+
"example": {
87+
"name": "Custom MCDA Run",
88+
"goals": [
89+
{"name": "Environmental Impact", "weight": 0.35, "direction": "max"},
90+
{"name": "Economic Cost", "weight": 0.25, "direction": "min"},
91+
{"name": "Social Acceptance", "weight": 0.40, "direction": "max"}
92+
],
93+
"alternatives": [
94+
{
95+
"name": "Project A",
96+
"values": {
97+
"Environmental Impact": 0.82,
98+
"Economic Cost": 1200.0,
99+
"Social Acceptance": 0.67
100+
}
101+
},
102+
{
103+
"name": "Project B",
104+
"values": {
105+
"Environmental Impact": 0.75,
106+
"Economic Cost": 980.0,
107+
"Social Acceptance": 0.74
108+
}
109+
}
110+
]
111+
}
112+
}

0 commit comments

Comments
 (0)