From 85f628f9223fd1b5ed4cbcfdaba5eb63a7e2164b Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Thu, 10 Sep 2026 14:32:48 +0200 Subject: [PATCH 1/3] New APIs: to calculate KPIs, calculate MCDA --- mysite/dss/mcda.py | 31 +++++- mysite/dss/models.py | 21 +--- mysite/dss/serializers.py | 209 +++++++++++++++++++++++++++++++++----- mysite/dss/tests.py | 122 +++++++++++++++++++++- mysite/dss/urls.py | 6 +- mysite/dss/views.py | 171 ++++++++++++++++++------------- 6 files changed, 444 insertions(+), 116 deletions(-) diff --git a/mysite/dss/mcda.py b/mysite/dss/mcda.py index db36c17..fba9fc1 100644 --- a/mysite/dss/mcda.py +++ b/mysite/dss/mcda.py @@ -242,10 +242,34 @@ def set_fixed_weights(config: McdaConfig): config._weights = weights - # ============================================================ # WEIGHTING AND RANKING HELPER FUNCTIONS # ============================================================ + +def ranking_to_pairwise(ranking: dict) -> list[tuple]: + """ + Convert an ordinal ranking dictionary into pairwise constraints. + + Parameters: + ranking: Dict with groups/criteria as keys + and ordinal ranks (1/2/3/...) as values + Returns: + pairwise_list: List of pairwise constraint tuples + (superior, inferior, 1.0) + """ + filtered_items = [(k, v) for k, v in ranking.items() if v is not None] + sorted_items = sorted(filtered_items, key=lambda x: x[1]) + pairwise_list = [] + n = len(sorted_items) + for i in range(n): + for j in range(i + 1, n): + if sorted_items[i][1] < sorted_items[j][1]: + pairwise_list.append( + (sorted_items[i][0], sorted_items[j][0], 1.0) + ) + return pairwise_list + + def ranking_to_pairwise_constraints(ranking: list, expected_items: list=None): """ Convert a complete or incomplete ordinal ranking into @@ -856,8 +880,9 @@ def deterministic_promethee(config: McdaConfig): nfs_df = pd.DataFrame({ "FOR (phi+)": phi_plus, "AGAINST (phi-)": phi_minus, - "NFS (phi)": nfs - }).sort_values("NFS (phi)", ascending=False) + "NFS (phi)": nfs, + "rank": nfs.rank(method="dense", ascending=False), + }).sort_values("rank") return {"net_flow_scores": nfs_df, "pairwise_prefs": S} def run_performance_uncertainty(config: McdaConfig, rng=None): diff --git a/mysite/dss/models.py b/mysite/dss/models.py index 4ccec76..a8d8a1f 100644 --- a/mysite/dss/models.py +++ b/mysite/dss/models.py @@ -1,5 +1,5 @@ from django.db import models -from .mcda import McdaConfig, WeightConstraints +from .mcda import McdaConfig, WeightConstraints, ranking_to_pairwise from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator import pandas as pd @@ -107,19 +107,6 @@ def init_criteria_n_groups(self): #TODO: if self.user_type == self.UserType.KAM: # Add more groups and criteria - def ranking_to_pairwise(self, ranking: dict) -> list[tuple]: - filtered_items = [(k, v) for k, v in ranking.items() if v is not None] - sorted_items = sorted(filtered_items, key=lambda x: x[1]) - pairwise_list = [] - n = len(sorted_items) - for i in range(n): - for j in range(i + 1, n): - if sorted_items[i][1] < sorted_items[j][1]: - pairwise_list.append( - (sorted_items[i][0], sorted_items[j][0], 1.0) - ) - return pairwise_list - def build_config(self) -> McdaConfig: criteria = list(self.criteria) criterion_names = [c.name for c in criteria] @@ -138,12 +125,12 @@ def build_config(self) -> McdaConfig: group = self.group_weights, local = self.local_weights, criterion = self.criterion_weights, - group_order = self.ranking_to_pairwise(self.group_ranks), - criterion_order = self.ranking_to_pairwise(self.crit_ranks) + group_order = ranking_to_pairwise(self.group_ranks), + criterion_order = ranking_to_pairwise(self.crit_ranks), ) if self.local_ranks: constraints.local_order = { - g: self.ranking_to_pairwise(self.local_ranks[g]) + g: ranking_to_pairwise(self.local_ranks[g]) if g in self.local_ranks else [] for g in groups } diff --git a/mysite/dss/serializers.py b/mysite/dss/serializers.py index f3d2e51..490e57c 100644 --- a/mysite/dss/serializers.py +++ b/mysite/dss/serializers.py @@ -1,5 +1,31 @@ from rest_framework import serializers from .models import Criterion, CritGroup, GroupOrder, LocalOrder +from rest_framework.serializers import ValidationError + +# Validation helpers + +def _validate_range(v, positive: bool=False) -> float | tuple: + """ + Positive float or [lo, hi] with 0 ≤ lo ≤ hi. + Used for measurements, weights, thresholds. + """ + if isinstance(v, (int, float)) and not isinstance(v, bool): + result = float(v) + elif isinstance(v, (list, tuple)) and len(v) == 2: + result = (float(v[0]), float(v[1])) + else: + raise ValueError("Must be a number or a (min, max) pair.") + + if isinstance(result, float): + if positive and result < 0: + raise ValueError("Value must be positive.") + else: + lo, hi = result + if positive and lo < 0: + raise ValueError(f"First value {lo} must be positive.") + if lo > hi: + raise ValueError(f"First {lo} must be ≤ second value {hi}.") + return result # Serializers for input data (API requests) #TODO: configure https://github.com/vbabiy/djangorestframework-camel-case @@ -18,6 +44,15 @@ class KpiSerializer(serializers.Serializer): value = serializers.FloatField() target = serializers.CharField() # Can be "min", "max", or a float value + def validate(self, data): + t = data["target"] + if t not in ["min", "max"]: + try: + float(t.strip()) + except ValueError: + raise serializers.ValidationError(f"Target not recognized: {t}") + return data + class ExperimentSerializer(serializers.Serializer): experimentId = serializers.IntegerField() weldLength = serializers.FloatField() @@ -87,25 +122,138 @@ class McdaRequestSerializer(serializers.Serializer): method = serializers.ChoiceField(choices=METHOD_CHOICES, default=METHOD_CHOICES[1]) weight_mode = serializers.ChoiceField(choices=WEIGHT_CHOICES, default=WEIGHT_CHOICES[0]) - groups = serializers.DictField(required=False) # {"group": [criteria]} - group_weights = serializers.DictField(required=False) # {"group": weight} - local_weights = serializers.DictField(required=False) # {"group": {"criterion": weight}} + groups = serializers.DictField(required=False) # {"group": ["criterion",]} + group_weights = serializers.DictField(required=False) # {"group": weight|(min, max)} + local_weights = serializers.DictField(required=False) # {"group": {"criterion": weight|(min, max)}} + crit_weights = serializers.DictField(required=False) # {"criterion": weight|(min, max)} thresholds = serializers.DictField(required=False) # Promethee parameters: {"criterion": (q, p)} or {"criterion": q}, where q=indifference, p=preference veto_type = serializers.ChoiceField(choices=VETO_CHOICES, default="no") veto_thresholds = serializers.DictField(required=False) # {"criterion": value} - penalty_factor = serializers.FloatField(default=0.5) # used if veto_type=="soft" - - n_samples = serializers.IntegerField(required=False) - alpha = serializers.FloatField(required=False) - alpha_group = serializers.FloatField(required=False) - alpha_local = serializers.FloatField(required=False) - group_lb = serializers.DictField(required=False) # {"group": lower_bound} - group_ub = serializers.DictField(required=False) # {"group": upper_bound} - group_order_constraints = serializers.ListField(required=False) # [("group1", "group2", intensity)] - local_lb = serializers.DictField(required=False) # {"group": {"criterion": lower_bound}} - local_ub = serializers.DictField(required=False) # {"group": {"criterion": lower_bound}} - local_order_constraints = serializers.DictField(required=False) # {"group: [("criterion1", "criterion2", intensity)]} + penalty_factor = serializers.FloatField(min_value=0, default=0.5) # used if veto_type=="soft" + + n_samples = serializers.IntegerField(min_value=1, required=False) + alpha = serializers.FloatField(min_value=0, required=False) + alpha_group = serializers.FloatField(min_value=0, required=False) + alpha_local = serializers.FloatField(min_value=0, required=False) + group_ranks = serializers.DictField(required=False) # {"group": rank} + local_ranks = serializers.DictField(required=False) # {"group": {"criterion": rank}} + crit_ranks = serializers.DictField(required=False) # {"criterion": rank} + + # Validators for specific fields + + def validate_decision_matrix(self, value): + for alt, criteria in value.items(): + if not isinstance(criteria, dict): + raise ValidationError( + f"'{alt}': expected a dict of {{criterion: value}}." + ) + for crit, v in criteria.items(): + try: + value[alt][crit] = _validate_range(v) + except (ValueError, TypeError) as e: + raise ValidationError(f"['{alt}']['{crit}']: {e}") + return value + + def validate_directions(self, value): + for crit, v in value.items(): + if v in ("min", "max"): + continue + try: + value[crit] = float(v) + except (ValueError, TypeError): + raise ValidationError( + f"['{crit}']: must be 'min', 'max', or a numeric target value." + ) + return value + + def validate_groups(self, value): + for group, criteria in value.items(): + if not isinstance(criteria, list): + raise ValidationError( + f"['{group}']: expected a list of criterion names." + ) + for i, c in enumerate(criteria): + if not isinstance(c, str): + raise ValidationError( + f"['{group}'][{i}]: criterion name must be a string." + ) + return value + + def validate_group_weights(self, value): + for group, v in value.items(): + try: + value[group] = _validate_range(v, True) + except (ValueError, TypeError) as e: + raise ValidationError(f"['{group}']: {e}") + return value + + def validate_local_weights(self, value): + for group, criteria in value.items(): + if not isinstance(criteria, dict): + raise ValidationError( + f"['{group}']: expected a dict of {{criterion: weight}}." + ) + for crit, v in criteria.items(): + try: + value[group][crit] = _validate_range(v, True) + except (ValueError, TypeError) as e: + raise ValidationError(f"['{group}']['{crit}']: {e}") + return value + + def validate_crit_weights(self, value): + for crit, v in value.items(): + try: + value[crit] = _validate_range(v, True) + except (ValueError, TypeError) as e: + raise ValidationError(f"['{crit}']: {e}") + return value + + def validate_thresholds(self, value): + for crit, v in value.items(): + try: + value[crit] = _validate_range(v, True) + except (ValueError, TypeError) as e: + raise ValidationError(f"['{crit}']: {e}") + return value + + def validate_veto_thresholds(self, value): + for crit, v in value.items(): + try: + fv = float(v) + if fv <= 0: + raise ValueError("Veto threshold must be > 0.") + value[crit] = fv + except (ValueError, TypeError) as e: + raise ValidationError(f"['{crit}']: {e}") + return value + + def _validate_rank_dict(self, value: dict, field_name: str): + """Shared logic for rank dicts, containing positive integers.""" + for key, v in value.items(): + try: + iv = int(v) + if iv < 1: + raise ValueError("Rank must be ≥ 1.") + value[key] = iv + except (ValueError, TypeError) as e: + raise ValidationError(f"{field_name}['{key}']: {e}") + return value + + def validate_group_ranks(self, value): + return self._validate_rank_dict(value, "group_ranks") + + def validate_crit_ranks(self, value): + return self._validate_rank_dict(value, "crit_ranks") + + def validate_local_ranks(self, value): + for group, criteria in value.items(): + if not isinstance(criteria, dict): + raise ValidationError( + f"local_ranks['{group}']: expected a dict of {{criterion: rank}}." + ) + value[group] = self._validate_rank_dict(criteria, "local_ranks") + return value def validate(self, data): # Check that criteria in decision_matrix are in directions @@ -118,7 +266,7 @@ def validate(self, data): missing = matrix_criteria - direction_criteria if missing: raise serializers.ValidationError( - "Criteria found in matrix but missing from directions:" + "Criteria found in matrix but missing from 'directions':" f"{missing}" ) # Check direction values @@ -127,8 +275,23 @@ def validate(self, data): raise serializers.ValidationError(f"Wrong data in directions: {dir}") elif isinstance(dir, str) and dir not in ["min", "max"]: raise serializers.ValidationError(f"Expected 'min' or 'max', received: {dir}") - # All criteria must belong to a group + # crit_weights, thresholds, veto_thresholds, crit_ranks: criteria must be known + for field in ("crit_weights", "thresholds", "veto_thresholds", "crit_ranks"): + if field in data: + unknown = set(data[field].keys()) - direction_criteria + if unknown: + raise ValidationError({field: f"Unknown criteria: {unknown}."}) + if data["groups"]: + # Check that groups are defined consistently + for field in ["group_weights", "local_weights", "group_ranks", "local_ranks"]: + if dic := data.get(field): + missing = set(data["groups"].keys()) - set(dic.keys()) + if missing: + raise serializers.ValidationError( + f"Unrecognized groups in '{field}': {missing}" + ) + # All criteria must belong to a group grouped_criteria = set() for group in data["groups"].values(): grouped_criteria.update(group) @@ -151,21 +314,21 @@ def validate(self, data): raise serializers.ValidationError( f"Criteria missing from grouped_weights: {missing}" ) - sum = sum(data["group_weights"].values()) - if abs(1 - sum) > 1e-6: - raise serializers.ValidationError( - f"Sum of group weights should be 1, it is {sum}" + total = sum(data["group_weights"].values()) + if abs(1 - total) > 1e-6: + raise ValidationError( + f"Sum of group weights should be 1, it is {total}" ) # Check that thresholds matches the method # promethee => tuples; promethee_like => float if data["method"] == self.METHOD_CHOICES[0] and data["thresholds"]: - for th in data["thresholds"]: + for th in data["thresholds"].values(): if not isinstance(th, (tuple, list)) or len(th) != 2: raise serializers.ValidationError( f"Expected threshold as (q, p), received: {th}" ) elif data["method"] == self.METHOD_CHOICES[1] and data["thresholds"]: - for th in data["thresholds"]: + for th in data["thresholds"].values(): if not isinstance(th, (float, int)): raise serializers.ValidationError( f"Expected threshold as number, received: {th}" diff --git a/mysite/dss/tests.py b/mysite/dss/tests.py index d1c8cd8..c5cb460 100644 --- a/mysite/dss/tests.py +++ b/mysite/dss/tests.py @@ -2,12 +2,18 @@ import pytest from unittest.mock import patch -from django.test import TestCase import pandas as pd import matplotlib.pyplot as plt -from .views import lookup, calculate_experiment_kpis, calculate_kpis + +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APITestCase, APIClient +from rest_framework import status + from .models import DecisionMatrix, Criterion from .mcda import McdaConfig, WeightConstraints, mcda +from .serializers import ExperimentSerializer, WeldStationSerializer +from .views import lookup, calculate_experiment_kpis, calculate_kpis logger = logging.getLogger(__name__) logger.setLevel('DEBUG') @@ -95,6 +101,43 @@ def test_falls_back_to_default_for_unknown_country(self, country_data_df): def test_falls_back_to_default_when_value_is_false(self, country_data_df): assert lookup("wages", "DE") == 20.0 +# --------------------------------------------------------------------------- +# test serializers +# --------------------------------------------------------------------------- +class TestSerializers(TestCase): + + def test_weld_station_serializer(self): + input = { + "experimentId": 402, + "weldLength": 1300, + "weldSpeed": 100, + "country": "NL", + "laserPowerkW": 50, + "weldingStationPowerkW": 8.5, + "maintenanceCosts": 26.40, + "cycleTime": 21, + "scrapRate": 0.05, + "recyclability": 0.93, + "consumables": [ + {"name": "gold", "flowRate": 5.5, "unit": "g/h"}, + {"name": "oil", "flowRate": 1, "unit": "ml/min"}, + ], + "qualityParameters": [ + {"name": "cost", "value": 200, "target": "min"}, + {"name": "depth", "value": 5, "target": "3.9"}, + ], + "materials": [{"name": "steel", "weight": 3}], + "productivity": [ + {"name": "expertise", "value": 0.5, "target": "max"}, + ], + } + expected_output = input + + result = WeldStationSerializer(data=input) + result.is_valid(raise_exception=True) + assert result.validated_data == expected_output + + # --------------------------------------------------------------------------- # calculate_experiment_kpis # --------------------------------------------------------------------------- @@ -206,7 +249,6 @@ def test_pd_decision_matrix(self, pd_experiment): session = calculate_kpis([pd_experiment], user="pd") names = set(Criterion.objects.filter(session=session).values_list("name", flat=True)) - print(names) assert "Tensile strength" in names row = DecisionMatrix.objects.get(session=session, name="Experiment #1") @@ -590,6 +632,80 @@ def test_mcda(self): assert title == "deterministic" return + +class McdaCalculationViewTest(APITestCase): + def setUp(self): + self.client = APIClient() + self.url = reverse('mcda') # Access to McdaCalculationView + self.valid_payload = { + "decision_matrix": { + "alt1": {"crit1": 10, "crit2": 20}, + "alt2": {"crit1": (8, 9), "crit2": 25}, + }, + "directions": {"crit1": "max", "crit2": "min"}, + "scenario": "ordered", + "method": "promethee", + "weight_mode": "hierarchical", + "groups": {"group1": ["crit1", "crit2"]}, + "group_weights": {"group1": 1.0}, + "local_weights": {"group1": {"crit1": 0.4, "crit2": 0.6}}, + "crit_weights": {"crit1": 0.6, "crit2": 0.4}, + "thresholds": {"crit1": (0.5, 5), "crit2": (1, 6)}, + "veto_type": "soft", + "veto_thresholds": {"crit1": 6, "crit2": 7}, + "penalty_factor": 0.5, + "n_samples": 100, + "alpha": 0.5, + "alpha_group": 1, + "alpha_local": 1.5, + "group_ranks": {"group1": 1}, + "local_ranks": {"group1": {"crit1": 2, "crit2": 1}}, + "crit_ranks": {"crit1": 1, "crit2": 2}, + } + self.invalid_payload = { + "decision_matrix": { + "alt1": {"crit1": 10, "crit2": 20}, + "alt2": {"crit1": (8, 9), "crit3": 25}, + }, + "directions": {"crit1": 12, "crit2": "low"}, + "scenario": "bounded", + "method": "promethee", + "weight_mode": "hierarchical", + "groups": {"group1": ["crit1", "crit2"], "group2": ["crit3"]}, + "group_weights": {"group1": 0.7}, + "local_weights": {"group1": {"crit1": 0.8, "crit2": 0.6}}, + "crit_weights": {"crit1": 0.6, "crit2": 0.4}, + "thresholds": {"crit1": (5, 0.5), "crit2": (1, 6)}, + "veto_type": "true", + "veto_thresholds": {"crit1": 6, "crit2": 7}, + "group_ranks": {"group1": "1"}, + "local_ranks": {"group1": {"crit1": ["nonsense"]}}, + "crit_ranks": {"crit1": 1, "crit2": 2}, + } + self.expected_result = { + 'alt1': {'score': 0.6347775757345835, 'rank': 1.0}, + 'alt2': {'score': -0.6347775757345835, 'rank': 2.0}, + } + + def test_valid_post_request(self): + response = self.client.post( + self.url, data=self.valid_payload, format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(list(response.data.keys()), ["alt1", "alt2"]) + self.assertDictEqual(response.data, self.expected_result) + + def test_invalid_post_request(self): + response = self.client.post( + self.url, data=self.invalid_payload, format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertListEqual( + list(response.data.keys()), + ["directions", "thresholds", "veto_type", "local_ranks"] + ) + # print(response.data) + class Explanations(): # ============================================================ # ANALYSIS SCENARIO SELECTION diff --git a/mysite/dss/urls.py b/mysite/dss/urls.py index 322e3fe..71f9158 100644 --- a/mysite/dss/urls.py +++ b/mysite/dss/urls.py @@ -1,14 +1,18 @@ from django.urls import path from django.views.generic.base import TemplateView from .views import ( + ExperimentKpiView, WeldStationKpiView, ExperimentComparisonInitView, KamComparisonInitView, - McdaWizardView, McdaResultsView, plot_view + McdaCalculationView, McdaWizardView, McdaResultsView, plot_view ) urlpatterns = [ path("kam-input/", TemplateView.as_view(template_name="dss/kam_input.html"), name="kam-input"), + path("experiment/", ExperimentKpiView.as_view(), name="experiment"), + path("welding-station/", WeldStationKpiView.as_view(), name="weld-station"), path("experiments/", ExperimentComparisonInitView.as_view(), name="pd-init"), path("welding-stations/", KamComparisonInitView.as_view(), name="kam-init"), + path("calculate/", McdaCalculationView.as_view(), name="mcda"), path("/step//", McdaWizardView.as_view(), name="wizard"), path("results//", McdaResultsView.as_view(), name="results"), path("mcda//plot//", plot_view, name="mcda-plot"), diff --git a/mysite/dss/views.py b/mysite/dss/views.py index c518b84..8f1c45d 100644 --- a/mysite/dss/views.py +++ b/mysite/dss/views.py @@ -10,8 +10,12 @@ from rest_framework.response import Response from rest_framework import status -from .serializers import ExperimentComparisonSerializer, WeldStationComparisonSerializer, McdaRequestSerializer, McdaResultSerializer -from .mcda import mcda, McdaConfig, WeightConstraints +from .serializers import ( + ExperimentSerializer, ExperimentComparisonSerializer, + WeldStationSerializer, WeldStationComparisonSerializer, + McdaRequestSerializer, +) +from .mcda import McdaConfig, WeightConstraints, mcda, ranking_to_pairwise from .models import McdaSession, DecisionMatrix, Criterion, CritGroup, Results from .forms import KpiSelectionForm, BaseConfigForm, WeightsThresholdsForm, SamplingConfigForm, GroupOrderFormSet, LocalOrderFormSet from .plot import fig_to_bytes @@ -25,7 +29,7 @@ # Define display order and labels RESULT_NAMES = { - "decision_matrix": "Decision matrix", + "decision_matrix": "Performance matrix", "pairwise_prefs": "Pairwise preference matrix", "net_flow_scores": "PROMETHEE flows & Net flow scores (NFS)", "outrank_probability": "Pairwise outranking probabilities (based on NFS)", @@ -114,7 +118,7 @@ def create_criteria(session: McdaSession, user_type: str="pd"): Criterion.objects.create(session=session, name="Process energy use", group=sust, direction="min") Criterion.objects.create(session=session, name="Process carbon footprint", group=sust, direction="min") if user_type == "kam": - circ = CritGroup.objects.create(session=session, name="Circularity", weight=0) + circ = CritGroup.objects.create(session=session, name="Circularity") oper = CritGroup.objects.create(session=session, name="Productivity") Criterion.objects.create(session=session, name="Total production costs", group=cost, direction="min") Criterion.objects.create(session=session, name="Maintenance costs", group=cost, direction="min") @@ -368,7 +372,26 @@ def step3_context(session) -> dict: # The actual views # ----------------------------------- +class ExperimentKpiView(APIView): + """Receives an experiment, returns a KPI set.""" + def post(self, request): + serializer = ExperimentSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + kpi_dict = calculate_experiment_kpis(serializer.validated_data, "pd") + return Response(kpi_dict, status=status.HTTP_200_OK) + +class WeldStationKpiView(APIView): + """Receives a weld station, returns a KPI set.""" + def post(self, request): + serializer = WeldStationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + kpi_dict = calculate_experiment_kpis(serializer.validated_data, "kam") + return Response(kpi_dict, status=status.HTTP_200_OK) + class ExperimentComparisonInitView(APIView): + """Receives an experiment list, redirects to MCDA wizard.""" def post(self, request): serializer = ExperimentComparisonSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -381,6 +404,7 @@ def post(self, request): return Response({"session_id": session.id}, status=status.HTTP_201_CREATED) class KamComparisonInitView(APIView): + """Receives a weld station list, returns an MCDA session ID.""" def post(self, request): serializer = WeldStationComparisonSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -393,6 +417,7 @@ def post(self, request): ) class McdaWizardView(View): + """View multiple forms, guiding the user in seting up MCDA configuration""" def _next_step(self, current_step: int, session: McdaSession) -> int | None: """Decision tree: select the next form""" if current_step < 2: # Form 0 and 1 are always shown @@ -492,6 +517,7 @@ def post(self, request, session_id: int, step: int): class McdaResultsView(DetailView): + """View figures and tables of MCDA results""" model = Results template_name = "dss/results.html" context_object_name = 'result' @@ -519,90 +545,97 @@ def plot_view(request, session_id: int, plot_name: str) -> HttpResponse: ) return HttpResponse(fig_bytes, content_type="image/svg+xml") -def parse_request(valid_data: dict) -> McdaSession: + +class McdaCalculationView(APIView): """ - Converts validated serializer output → ORM models. - Called once when the initial API request comes in. + Receives: decision matrix & MCDA setttings. + Returns: ranking and score of alternatives. """ - session = McdaSession.objects.create() - - if valid_data["groups"] is not None: - for criterion_name, direction in valid_data["directions"].items(): - Criterion(session, name=criterion_name, direction=direction).save() - for group_name, criteria in valid_data["groups"].items(): - group = CritGroup(session, name=group_name) - group.save() - Criterion.objects.filter(name__in=criteria).update(group=group) - - quality_grp = create_criteria(session) - - for alt_name, values in valid_data["decision_matrix"].items(): - DecisionMatrix(session=session, name=alt_name, values=values).save() - - for criterion_name, direction in valid_data["directions"].items(): - if not Criterion.objects.filter(name=criterion_name).exists(): - Criterion( - session, name=criterion_name, group=quality_grp, direction=direction - ).save() - - return session - -#TODO: remove this View -class McdaInitView(APIView): - def post(self, request): - serializer = McdaRequestSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - session = parse_request(serializer.validated_data) + DEFAULT_GROUPS = { + "Technical quality": {"Porosity", "Tensile strength", "Weld depth"}, + "Economic": { + "Operating costs", + "Total production costs", + "Maintenance costs", + }, + "Sustainability": { + "Process energy use", + "Process carbon footprint", + "Product carbon footprint", + }, + "Circularity": {"Scrap rate", "Recyclability"}, + "Productivity": { + "Production cycle time", + "Process automation level", + "Operator specialisation level", + "Process monitoring", + "Production lead time", + "Machine saturation", + }, + } - return Response({"session_id": session.id}, status=status.HTTP_201_CREATED) + def serialize_results(self, data: dict): + if "net_flow_scores" in data: + df = data["net_flow_scores"][["NFS (phi)", "rank"]] + else: + df = pd.concat( + [data["mean_net_flows"], data["expected_rank"]], axis=1 + ) + df.columns = ["score", "rank"] + return df.to_dict('index') -class McdaCalculationView(APIView): def post(self, request): request_serializer = McdaRequestSerializer(data=request.data) request_serializer.is_valid(raise_exception=True) data = request_serializer.validated_data - # Convert some raw data to read-to-use data types - try: - decision_matrix = pd.DataFrame(data["decision_matrix"]) + try: # Convert decision matrix to a DataFrame + decision_matrix = pd.DataFrame.from_dict( + data["decision_matrix"], orient='index' + ) except BaseException as e: - return Response({"Issue with decision matrix": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response({"Issue with decision matrix": str(e)}, + status=status.HTTP_400_BAD_REQUEST) - try: + try: # Compile a config object, run MCDA + groups = data.get("groups", self.DEFAULT_GROUPS) constraints = WeightConstraints( group=data.get("group_weights"), - group_order=data.get("group_order_constraints"), local=data.get("local_weights"), - local_order=data.get("local_order_constraints"), + criterion=data.get("crit_weights"), + group_order=ranking_to_pairwise(data.get("group_ranks")), + criterion_order=ranking_to_pairwise(data.get("crit_ranks")), ) - config = McdaConfig( - df=decision_matrix, - directions=data["directions"], - scenario=data["scenario"], - method=data["method"], - weight_mode=data["weight_mode"], - groups=data.get("groups"), - thresholds=data.get("thresholds"), + if local_ranks := data.get("local_ranks"): + constraints.local_order = { + g: ranking_to_pairwise(local_ranks[g]) + if g in local_ranks else [] + for g in groups + } + config = McdaConfig( + df = decision_matrix, + directions = data["directions"], + scenario = data["scenario"], + method = data["method"], + weight_mode = data["weight_mode"], + groups = groups, + thresholds = data.get("thresholds"), # Veto settings - veto_type=data["veto_type"], - veto_thresholds=data.get("veto_thresholds"), - penalty_factor=data.get("penalty_factor"), - + veto_type = data.get("veto_type", "no"), + veto_thresholds = data.get("veto_thresholds"), + penalty_factor = data.get("penalty_factor", 0.5), # Sampling - n_samples=data.get("n_samples"), - alpha=data.get("alpha"), - alpha_group=data.get("alpha_group"), - alpha_local=data.get("alpha_local"), - - # Weight structure settings (group-level and local) - constraints=constraints, + n_samples = data.get("n_samples", 10000), + alpha = data.get("alpha", 1.0), + alpha_group = data.get("alpha_group", 1.0), + alpha_local = data.get("alpha_local", 1.0), + # Weights & constraints + constraints = constraints, ) - result = mcda(config) + result = mcda(config)[0] except ValueError as e: - return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response({"Error": e}, status=status.HTTP_400_BAD_REQUEST) - results = McdaResultSerializer(result) - return Response(results.data, status=status.HTTP_200_OK) + return Response(self.serialize_results(result), status=status.HTTP_200_OK) From 5a6e53b6d22cdcb8ccc1c64b4f1e5daf7b126542 Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Thu, 10 Sep 2026 14:44:25 +0200 Subject: [PATCH 2/3] Remove GroupOrder & LocalOrder everywhere Different import of serializers.ValidationError --- mysite/dss/forms.py | 57 +-------------- ...remove_mcdasession_group_order_and_more.py | 50 ++++++++++++++ .../0009_delete_localorder_group_order.py | 35 ++++++++++ mysite/dss/models.py | 28 -------- mysite/dss/serializers.py | 69 +++++-------------- mysite/dss/views.py | 19 +---- 6 files changed, 103 insertions(+), 155 deletions(-) create mode 100644 mysite/dss/migrations/0008_remove_mcdasession_group_order_and_more.py create mode 100644 mysite/dss/migrations/0009_delete_localorder_group_order.py diff --git a/mysite/dss/forms.py b/mysite/dss/forms.py index 581672d..1aa457f 100644 --- a/mysite/dss/forms.py +++ b/mysite/dss/forms.py @@ -1,6 +1,6 @@ from django import forms from django.forms import inlineformset_factory -from .models import McdaSession, Criterion, CritGroup, GroupOrder, LocalOrder, DecisionMatrix +from .models import McdaSession, Criterion, CritGroup, DecisionMatrix # ------------------------------------------------------------------ @@ -401,58 +401,3 @@ def save(self, session: McdaSession) -> None: session.local_ranks[group][crit] = data[field_name] session.save() - - -class GroupOrderForm(forms.ModelForm): - class Meta: - model = GroupOrder - fields = ["group1", "group2", "intensity"] - widgets = { - "group1": forms.Select(), - "group2": forms.Select(), - "intensity": forms.NumberInput(attrs={"min": 0, "step": "0.01"}), - } - - def clean(self): - cleaned = super().clean() - if cleaned.get("group1") == cleaned.get("group2"): - raise forms.ValidationError("Choose two different groups.") - return cleaned - - -class LocalOrderForm(forms.ModelForm): - class Meta: - model = LocalOrder - fields = ["criterion1", "criterion2", "intensity"] - widgets = { - "criterion1": forms.Select(), - "criterion2": forms.Select(), - "intensity": forms.NumberInput(attrs={"min": 0, "step": "0.01"}), - } - - def clean(self): - cleaned = super().clean() - if cleaned.get("criterion1") == cleaned.get("criterion2"): - raise forms.ValidationError("Choose two different criteria.") - # if cleaned.get("criterion1").group != cleaned.get("criterion2").group: - # raise forms.ValidationError("Choose criteria from the same group.") - return cleaned - - -# Formsets -# extra=1 shows one blank row by default; can_delete=True adds a remove checkbox -GroupOrderFormSet = inlineformset_factory( - parent_model = McdaSession, - model = GroupOrder, - form = GroupOrderForm, - extra = 1, - can_delete = True, -) - -LocalOrderFormSet = inlineformset_factory( - parent_model = McdaSession, - model = LocalOrder, - form = LocalOrderForm, - extra = 1, - can_delete = True, -) diff --git a/mysite/dss/migrations/0008_remove_mcdasession_group_order_and_more.py b/mysite/dss/migrations/0008_remove_mcdasession_group_order_and_more.py new file mode 100644 index 0000000..a9c3891 --- /dev/null +++ b/mysite/dss/migrations/0008_remove_mcdasession_group_order_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 6.0.5 on 2026-08-26 12:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dss', '0007_replace_results_data_with_sections'), + ] + + operations = [ + migrations.RemoveField( + model_name='mcdasession', + name='group_order', + ), + migrations.RemoveField( + model_name='mcdasession', + name='local_order', + ), + migrations.RemoveField( + model_name='mcdasession', + name='sampling_mode', + ), + migrations.AddField( + model_name='mcdasession', + name='crit_ranks', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='mcdasession', + name='criterion_weights', + field=models.JSONField(null=True), + ), + migrations.AddField( + model_name='mcdasession', + name='group_ranks', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='mcdasession', + name='local_ranks', + field=models.JSONField(default=dict), + ), + migrations.AlterField( + model_name='mcdasession', + name='scenario', + field=models.CharField(choices=[('deterministic', 'Deterministic'), ('random', 'Random'), ('bounded', 'Bounded'), ('ordered', 'Ordered'), ('bounded_ordered', 'Bounded Ordered')], max_length=15, null=True), + ), + ] diff --git a/mysite/dss/migrations/0009_delete_localorder_group_order.py b/mysite/dss/migrations/0009_delete_localorder_group_order.py new file mode 100644 index 0000000..75c0f30 --- /dev/null +++ b/mysite/dss/migrations/0009_delete_localorder_group_order.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.5 on 2026-09-10 12:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dss', '0008_remove_mcdasession_group_order_and_more'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='localorder', + unique_together=None, + ), + migrations.RemoveField( + model_name='localorder', + name='criterion1', + ), + migrations.RemoveField( + model_name='localorder', + name='criterion2', + ), + migrations.RemoveField( + model_name='localorder', + name='session', + ), + migrations.DeleteModel( + name='GroupOrder', + ), + migrations.DeleteModel( + name='LocalOrder', + ), + ] diff --git a/mysite/dss/models.py b/mysite/dss/models.py index a8d8a1f..40d99ee 100644 --- a/mysite/dss/models.py +++ b/mysite/dss/models.py @@ -201,34 +201,6 @@ def save(self, *args, **kwargs): self.full_clean() super().save(*args, **kwargs) -class GroupOrder(models.Model): - """Defines the importance order of groups""" - session = models.ForeignKey(McdaSession, on_delete=models.CASCADE, related_name="group_orders") - group1 = models.ForeignKey(CritGroup, on_delete=models.CASCADE, related_name="ordered_higher") - group2 = models.ForeignKey(CritGroup, on_delete=models.CASCADE, related_name="ordered_lower") - intensity = models.FloatField(validators=[MinValueValidator(0)], default=1) - - class Meta: - unique_together = ['session', 'group1', 'group2'] - -class LocalOrder(models.Model): - """Defines the importance order of criteria in a group""" - session = models.ForeignKey(McdaSession, on_delete=models.CASCADE, related_name="local_orders") - criterion1 = models.ForeignKey(Criterion, on_delete=models.CASCADE, related_name="ordered_higher") - criterion2 = models.ForeignKey(Criterion, on_delete=models.CASCADE, related_name="ordered_lower") - intensity = models.FloatField(validators=[MinValueValidator(0)], default=1) - - class Meta: - unique_together = ['session', 'criterion1', 'criterion2'] - - def clean(self): - if self.criterion1.group != self.criterion2.group: - raise ValidationError("Criteria must belong to the same group.") - - def save(self, *args, **kwargs): - self.full_clean() - super().save(*args, **kwargs) - class Results(models.Model): session = models.OneToOneField( McdaSession, on_delete=models.CASCADE, primary_key=True, related_name='results' diff --git a/mysite/dss/serializers.py b/mysite/dss/serializers.py index 490e57c..ca428fb 100644 --- a/mysite/dss/serializers.py +++ b/mysite/dss/serializers.py @@ -1,5 +1,4 @@ from rest_framework import serializers -from .models import Criterion, CritGroup, GroupOrder, LocalOrder from rest_framework.serializers import ValidationError # Validation helpers @@ -28,7 +27,6 @@ def _validate_range(v, positive: bool=False) -> float | tuple: return result # Serializers for input data (API requests) -#TODO: configure https://github.com/vbabiy/djangorestframework-camel-case class ConsumableSerializer(serializers.Serializer): name = serializers.CharField() @@ -50,7 +48,7 @@ def validate(self, data): try: float(t.strip()) except ValueError: - raise serializers.ValidationError(f"Target not recognized: {t}") + raise ValidationError(f"Target not recognized: {t}") return data class ExperimentSerializer(serializers.Serializer): @@ -85,7 +83,7 @@ def validate(self, data): qp['name'] for qp in experiment['qualityParameters'] } if current_kpis != first_kpis: - raise serializers.ValidationError( + raise ValidationError( f"Experiment {experiment['experimentId']} has different " "quality parameters than the first experiment. Expected: " f"{first_kpis}, Got: {current_kpis}" @@ -94,7 +92,7 @@ def validate(self, data): # Check ID uniqueness ids = set([exp["experimentId"] for exp in data]) if len(ids) != len(data): - raise serializers.ValidationError("Duplicate experiment IDs found") + raise ValidationError("Duplicate experiment IDs found") return data @@ -265,16 +263,16 @@ def validate(self, data): direction_criteria = set(data["directions"].keys()) missing = matrix_criteria - direction_criteria if missing: - raise serializers.ValidationError( + raise ValidationError( "Criteria found in matrix but missing from 'directions':" f"{missing}" ) # Check direction values for dir in data["directions"].values(): if not isinstance(dir, (str, int, float)): - raise serializers.ValidationError(f"Wrong data in directions: {dir}") + raise ValidationError(f"Wrong data in directions: {dir}") elif isinstance(dir, str) and dir not in ["min", "max"]: - raise serializers.ValidationError(f"Expected 'min' or 'max', received: {dir}") + raise ValidationError(f"Expected 'min' or 'max', received: {dir}") # crit_weights, thresholds, veto_thresholds, crit_ranks: criteria must be known for field in ("crit_weights", "thresholds", "veto_thresholds", "crit_ranks"): if field in data: @@ -288,8 +286,8 @@ def validate(self, data): if dic := data.get(field): missing = set(data["groups"].keys()) - set(dic.keys()) if missing: - raise serializers.ValidationError( - f"Unrecognized groups in '{field}': {missing}" + raise ValidationError( + {field: f"Unrecognized groups: {missing}"} ) # All criteria must belong to a group grouped_criteria = set() @@ -297,21 +295,23 @@ def validate(self, data): grouped_criteria.update(group) missing = matrix_criteria - grouped_criteria if missing: - raise serializers.ValidationError( + raise ValidationError( f"Criteria missing from groups: {missing}" ) # Some weight_modes require group data if data["weight_mode"] == "group": if not data["groups"]: - serializers.ValidationError("For grouped weighting, specify 'groups'") + ValidationError("For grouped weighting, specify 'groups'") elif data["weight_mode"] == "hierarchical": if (not data["group_weights"]) or (data["groups"]): - serializers.ValidationError("Hierarchical weighting requires both 'groups' and 'group_weights'") + ValidationError( + "Hierarchical weighting requires both 'groups' and 'group_weights'" + ) # Each group must have a weight; they must sum to 1 if data["group_weights"]: missing = set(data["groups"].keys()) - set(data["group_weights"].keys()) if missing: - raise serializers.ValidationError( + raise ValidationError( f"Criteria missing from grouped_weights: {missing}" ) total = sum(data["group_weights"].values()) @@ -324,13 +324,13 @@ def validate(self, data): if data["method"] == self.METHOD_CHOICES[0] and data["thresholds"]: for th in data["thresholds"].values(): if not isinstance(th, (tuple, list)) or len(th) != 2: - raise serializers.ValidationError( + raise ValidationError( f"Expected threshold as (q, p), received: {th}" ) elif data["method"] == self.METHOD_CHOICES[1] and data["thresholds"]: for th in data["thresholds"].values(): if not isinstance(th, (float, int)): - raise serializers.ValidationError( + raise ValidationError( f"Expected threshold as number, received: {th}" ) # When veto is used, veto_thresholds must be specified @@ -340,40 +340,3 @@ def validate(self, data): ) return data - -# Serializers for output data - -class GroupOrderSerializer(serializers.ModelSerializer): - group1_name = serializers.CharField(source='group1.name') - group2_name = serializers.CharField(source='group2.name') - - class Meta: - model = GroupOrder - fields = ['group1_name', 'group2_name', 'intensity'] - - def to_representation(self, instance): - return (instance.group1.name, instance.group2.name, instance.intensity) - -class LocalOrderSerializer(serializers.ModelSerializer): - criterion1_name = serializers.CharField(source='criterion1.name') - criterion2_name = serializers.CharField(source='criterion2.name') - - class Meta: - model = GroupOrder - fields = ['criterion1_name', 'criterion2_name', 'intensity'] - - def to_representation(self, instance): - return (instance.criterion1.name, instance.criterion2.name, instance.intensity) - -class IndicatorScoresSerializer(serializers.Serializer): - indicator = serializers.CharField() - score = serializers.FloatField() - -class ExperimentRankingSerializer(serializers.Serializer): - experiment_id = serializers.IntegerField() - rank = serializers.IntegerField() - score = serializers.IntegerField() - indicators = IndicatorScoresSerializer - -class McdaResultSerializer(serializers.Serializer): - experiment_ranking = serializers.ListField(child=ExperimentRankingSerializer()) diff --git a/mysite/dss/views.py b/mysite/dss/views.py index 8f1c45d..c658e24 100644 --- a/mysite/dss/views.py +++ b/mysite/dss/views.py @@ -17,7 +17,7 @@ ) from .mcda import McdaConfig, WeightConstraints, mcda, ranking_to_pairwise from .models import McdaSession, DecisionMatrix, Criterion, CritGroup, Results -from .forms import KpiSelectionForm, BaseConfigForm, WeightsThresholdsForm, SamplingConfigForm, GroupOrderFormSet, LocalOrderFormSet +from .forms import KpiSelectionForm, BaseConfigForm, WeightsThresholdsForm, SamplingConfigForm from .plot import fig_to_bytes @@ -427,23 +427,6 @@ def _next_step(self, current_step: int, session: McdaSession) -> int | None: return 3 return None # deterministic: skip Form 3 return None # step 3 is always the last - - def _get_order_formsets(self, session, data=None): - """Instantiate both formsets with querysets scoped to this session.""" - session_groups = CritGroup.objects.filter(session=session) - session_criteria = Criterion.objects.filter(session=session) - - group_formset = GroupOrderFormSet(data, instance=session, prefix="gfs") - for form in group_formset.forms: - form.fields["group1"].queryset = session_groups - form.fields["group2"].queryset = session_groups - - local_formset = LocalOrderFormSet(data, instance=session, prefix="lfs") - for form in local_formset.forms: - form.fields["criterion1"].queryset = session_criteria - form.fields["criterion2"].queryset = session_criteria - - return group_formset, local_formset def get_form(self, step: int, session: McdaSession, data=None): """Returns the right form for the current step.""" From 5ff175ba7f1053abd099f8e7b6107296590f1ffd Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Thu, 1 Oct 2026 10:12:52 +0200 Subject: [PATCH 3/3] Add psycopg2 as requirement --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 5c1e7bc..9e81db1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ django-crispy-forms crispy-tailwind environs pandas +psycopg2 brightway25