diff --git a/README.md b/README.md index 16467fd..5212d5c 100644 --- a/README.md +++ b/README.md @@ -349,3 +349,89 @@ A DPP is uniquely identified by its registration number. It can be accessed thro 'update_interval': 'A', } ``` + +# Decision Support System manual +The Sustainability and Cost Module features a Decision Support System (DSS) that can be accessed through an API. It is specifically designed for supporting decisions in laser welding. The DSS has a user interface to guide you through the process of setting decision preferences. + +## API access +To receive decision support for comparing different laser welding parameters, send a post request to [main-domain.com/dss/experiments/]. The request should contain a list of experiments with the following structure: +```JSON +[ + { + "experimentId": 12, + "weldLength": 29, + "weldSpeed": 100, + "country": "DE", + "laserPowerkW": 4, + "weldingStationPowerkW": 12, + "consumables": [ + { + "name": "Nitrogen", + "flowRate": 15.2, + "unit": "m3/h" + }, + { + "name": "Argon", + "flowRate": 6, + "unit": "m3/h" + }, + { + "name": "Aluminium (filler wire)", + "flowRate": 0.02, + "unit": "kg/h" + } + ], + "qualityParameters": [ + { + "name": "Porosity", + "value": 3.2, + "target": "min" + }, + { + "name": "Tensile strength", + "value": 0.18, + "target": "max" + }, + { + "name": "Weld depth", + "value": 3, + "target": 3.5 + } + ] + } +] +``` + + +## Criteria calculations + +Calculations for deriving the KPIs used as decision criteria in the DSS, for the Process Developer. These calculations are implemented in the function calculate_kpis, in script views.py. +- Amount of shielding gas = gas flow rate × processing time +- Amount of filler wire = filler wire use rate × processing time +- Consumables costs = Σ amount × price (for all consumables) +- Labour costs = processing time × 3 × wages (in the specific country) +- Process energy use = processing time × (laser power + welding station power) +- Energy costs = process energy use × electricity price (in the specific country) +- Operating costs = consumables costs + labour costs + energy costs +- Consumables footprint = Σ amount × material carbon footprint (for all consumables) +- Process carbon footprint = consumables footprint + process energy use × electricity footprint (in the specific country) +- The technical quality indicators are directly used without calculations. + +Calculations for deriving KPIs for the Key Account Manager. This is not implemented yet (July 2026). +- Amount of shielding gas = gas flow rate × processing time +- Amount of filler wire = filler wire use rate × processing time +- Consumables costs = Σ amount × price (for all consumables) +- Labour costs = cycle time × 3 × wages (in the specific country) +- Process energy use = processing time × laser power + cycle time × welding station power +- Energy costs = process energy use × electricity price (in the specific country) +- Operating costs = consumables costs + labour costs + energy costs + maintenance costs +- Materials costs = Σ material weight × material price / (1 – scrap rate) (for all parts) +- Service life = 15 years (fixed value) +- Annual production = 240 days/year × 16 hours/day × 3600 seconds/hour / cycle time +- Amortized CAPEX = investment costs / (service life × annual production) +- Total production costs = operating costs + materials costs + amortized CAPEX +- Consumables footprint = Σ amount × material carbon footprint (for all consumables) +- Process carbon footprint = consumables footprint + process energy use × electricity footprint (in the specific country) +- Materials footprint = Σ material weight × material carbon footprint / (1 – scrap rate) (for all parts) +- Product carbon footprint = process carbon footprint + materials footprint +- The remaining KPIs are directly used without calculations: recyclability, process monitoring, process automation level, operator specialization level, production lead time, machine saturation, technical quality indicators. diff --git a/mysite/dpp/migrations/0041_alter_transport_mode.py b/mysite/dpp/migrations/0041_alter_transport_mode.py new file mode 100644 index 0000000..aa3366c --- /dev/null +++ b/mysite/dpp/migrations/0041_alter_transport_mode.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.5 on 2026-07-08 11:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dpp', '0040_productionline_created_by_user'), + ] + + operations = [ + migrations.AlterField( + model_name='transport', + name='mode', + field=models.CharField(choices=[('train', 'Freight train'), ('ocean ship', 'Sea freight container ship'), ('truck', 'Lorry / truck, average'), ('inland ship', 'Inland waterway ship'), ('airplane', 'Aircraft'), ('delivery van', 'Light commercial vehicle (delivery van)'), ('NA', 'Unspecified')], default='NA', max_length=12, verbose_name='Main mode of transport'), + ), + ] diff --git a/mysite/dss/__init__.py b/mysite/dss/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mysite/dss/admin.py b/mysite/dss/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/mysite/dss/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/mysite/dss/apps.py b/mysite/dss/apps.py new file mode 100644 index 0000000..89d0f90 --- /dev/null +++ b/mysite/dss/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DssConfig(AppConfig): + name = 'dss' diff --git a/mysite/dss/forms.py b/mysite/dss/forms.py new file mode 100644 index 0000000..4b2cb3a --- /dev/null +++ b/mysite/dss/forms.py @@ -0,0 +1,447 @@ +from django import forms +from django.forms import inlineformset_factory +from .models import McdaSession, Criterion, CritGroup, GroupOrder, LocalOrder, DecisionMatrix + + +# ------------------------------------------------------------------ +# Custom fields +# ------------------------------------------------------------------ + +class WeightField(forms.Field): + """ + Accepts a single positive float ("0.5") or a range ("0.2, 0.8"). + Returns float | tuple[float, float]. + """ + widget = forms.TextInput(attrs={"placeholder": "e.g. 0.5 or 0.2, 0.8"}) + + def to_python(self, value): + if not value: + return None + value = value.strip() + + if "," in value: + parts = value.split(",") + if len(parts) != 2: + raise forms.ValidationError("Range must be exactly two values.") + try: + lo, hi = float(parts[0].strip()), float(parts[1].strip()) + except ValueError: + raise forms.ValidationError("Range values must be numbers.") + if lo < 0 or hi < 0: + raise forms.ValidationError("Values must be positive.") + if lo > hi: + raise forms.ValidationError("min must be ≤ max.") + return (lo, hi) + + try: + v = float(value) + except ValueError: + raise forms.ValidationError("Must be a positive number or range 'min, max'.") + if v < 0: + raise forms.ValidationError("Value must be positive.") + return v + + +class PositiveFloatField(forms.FloatField): + def validate(self, value): + super().validate(value) + if value is not None and value < 0: + raise forms.ValidationError("Value must be positive.") + + +class OrderingEntryField(forms.Field): + """ + Represents a single ordering constraint: "A > B, 0.8" + Returns tuple[str, str, float]. + Rendered as a set of rows via OrderingWidget (see below). + """ + def to_python(self, value): + if not value: + return None + try: + parts = [p.strip() for p in value.split(",")] + if len(parts) != 3: + raise ValueError + a, b, intensity = parts[0], parts[1], float(parts[2]) + if intensity < 0: + raise forms.ValidationError("Strength must be positive.") + return (a, b, intensity) + except ValueError: + raise forms.ValidationError("Format must be 'A, B, intensity'.") + +# ------------------------------------------------------------------ +# Form 0: criteria selection (always shown) +# ------------------------------------------------------------------ + +class KpiSelectionForm(forms.Form): + def __init__(self, session: McdaSession, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.groups = ( + CritGroup.objects.filter(session=session).prefetch_related("criteria") + ) + + for group in self.groups: + criteria = group.criteria.all() + field_name = f"group_{group.pk}" + + # Group checkbox + self.fields[field_name] = forms.BooleanField( + required=False, + initial=all(c.used for c in criteria), + label=group.name, + ) + group.field = self[field_name] + + # Criterion checkboxes + for criterion in criteria: + field_name = f"criterion_{criterion.pk}" + self.fields[field_name] = ( + forms.BooleanField( + required=False, + initial=criterion.used, + label=criterion.name, + ) + ) + criterion.field = self[field_name] + + # Find KPI values that are missing in the input + self.alternatives = session.alternatives.all() + self.value_rows = [] + + for criterion in session.criteria: + row = {"criterion": criterion, "cells": []} + has_missing = False + + for alternative in self.alternatives: + if criterion.name in alternative.values: + row["cells"].append(None) + else: + has_missing = True + field_name = (f"value_{criterion.pk}_{alternative.pk}") + self.fields[field_name] = forms.FloatField() + row["cells"].append(self[field_name]) + + if has_missing: + self.value_rows.append(row) + + def save(self, *args): + criteria = [] + + for name, value in self.cleaned_data.items(): + if name.startswith("criterion_"): + pk = int(name.split("_")[1]) + criteria.append((pk, value)) + + criterion_map = Criterion.objects.in_bulk(pk for pk, _ in criteria) + for pk, used in criteria: + criterion = criterion_map[pk] + criterion.used = used + + Criterion.objects.bulk_update(criterion_map.values(), ["used"]) + + # Add newly provided KPI values to DecisionMatrix.values + alternatives = {alt.pk: alt for alt in self.session.alternatives.all()} + + for name, value in self.cleaned_data.items(): + if not name.startswith("value_"): + continue + + _, criterion_id, alternative_id = name.split("_") + alternative = alternatives[int(alternative_id)] + criterion = Criterion.objects.get(pk=int(criterion_id)) + alternative.values[criterion.name] = value + + # Save the changes made to all alternative.values + DecisionMatrix.objects.bulk_update(alternatives.values(), ["values"]) + +# ------------------------------------------------------------------ +# Form 1: basic settings (always shown) +# ------------------------------------------------------------------ + +class BaseConfigForm(forms.Form): + """ + Collects the top-level choices that drive which fields appear in Forms 2 and 3. + Saved to: session.scenario, .method, .weight_mode, .veto_type + """ + scenario = forms.ChoiceField( + choices=McdaSession.Scenario.choices, widget=forms.RadioSelect + ) + method = forms.ChoiceField( + choices=McdaSession.Method.choices, widget=forms.RadioSelect + ) + weight_mode = forms.ChoiceField( + choices=McdaSession.WeightMode.choices, widget=forms.RadioSelect + ) + sampling_mode = forms.ChoiceField( + choices=McdaSession.SamplingMode.choices, widget=forms.RadioSelect + ) + veto_type = forms.ChoiceField( + choices=McdaSession.VetoType.choices, widget=forms.RadioSelect + ) + + def clean(self): + cleaned = super().clean() + if cleaned.get("scenario") == "uncertain" and not cleaned.get("sampling_mode"): + self.add_error("sampling_mode", "Required for uncertain analysis.") + return cleaned + + def save(self, session: McdaSession) -> None: + data = self.cleaned_data + session.scenario = data["scenario"] + session.method = data["method"] + session.weight_mode = data["weight_mode"] + session.sampling_mode = data["sampling_mode"] + session.veto_type = data["veto_type"] + session.save() + + +# ------------------------------------------------------------------ +# Form 2: conditional fields, dynamically built from session state +# ------------------------------------------------------------------ + +class WeightsThresholdsForm(forms.Form): + """ + Fields are added dynamically in __init__ based on Form 1 choices. + All fields are optional at the Django level; required-ness is + enforced in clean() based on the session context. + + Saved to: session.group_weights, .local_weights, .thresholds, + .veto_thresholds, .penalty_factor, .sampling_mode + """ + + def __init__(self, session: McdaSession, *args, **kwargs): + super().__init__(*args, **kwargs) + self.session = session + criteria = list(session.criteria.values_list("name", flat=True)) + self.groups = list(session.groups.values_list("name", flat=True)) + + if session.sampling_mode != "random": + # -- group_weights: weight_mode in {group, hierarchical} ----- + if session.weight_mode in ("group", "hierarchical"): + for group in self.groups: + self.fields[f"group_weight_{group}"] = WeightField( + label=f"Weight — {group.title()}", required=False + ) + + # -- local_weights: weight_mode in hierarchical, flat -------- + if session.weight_mode in ("hierarchical", "flat"): + for criterion in criteria: + self.fields[f"local_weight_{criterion}"] = WeightField( + label=f"Criterion weight — {criterion}", required=False + ) + + # -- thresholds: always, shape depends on method -------------- + for criterion in criteria: + if session.method == "promethee": + self.fields[f"threshold_{criterion}"] = WeightField( + label=f"Threshold — {criterion} (indifference, preference)", + required=False, + ) + else: + self.fields[f"threshold_{criterion}"] = PositiveFloatField( + label=f"Threshold — {criterion}", required=False + ) + + # -- veto_thresholds: veto_type != "no" ----------------------- + if session.veto_type != "no": + for criterion in criteria: + self.fields[f"veto_{criterion}"] = PositiveFloatField( + label=f"Veto threshold — {criterion}", required=False + ) + + # -- penalty_factor: veto_type == "soft" ---------------------- + if session.veto_type == "soft": + self.fields["penalty_factor"] = PositiveFloatField( + label="Penalty factor", required=False + ) + + def clean(self): + cleaned = super().clean() + session = self.session + + if session.sampling_mode != "random": + if session.weight_mode in ("group", "hierarchical"): + for group in self.groups: + if not cleaned.get(f"group_weight_{group}"): + self.add_error( + f"group_weight_{group}", + "Required when weight mode is group or hierarchical." + ) + if session.weight_mode in ("hierarchical", "flat"): + for name in session.criteria.values_list("name", flat=True): + if not cleaned.get(f"local_weight_{name}"): + self.add_error( + f"local_weight_{name}", + "Required for hierarchical weighting." + ) + + if session.veto_type != "no": + for name in session.criteria.values_list("name", flat=True): + if cleaned.get(f"veto_{name}") is None: + self.add_error(f"veto_{name}", "Required when veto is active.") + + if session.veto_type == "soft" and cleaned.get("penalty_factor") is None: + self.add_error("penalty_factor", "Required for soft veto.") + + return cleaned + + def save(self, session: McdaSession) -> None: + d = self.cleaned_data + criteria = list(session.criteria.values_list("name", flat=True)) + + if session.weight_mode in ("group", "hierarchical"): + session.group_weights = {g: d[f"group_weight_{g}"] for g in self.groups} + + if session.weight_mode in ("hierarchical", "flat"): + session.local_weights = {c: d[f"local_weight_{c}"] for c in criteria} + + session.thresholds = {c: d[f"threshold_{c}"] for c in criteria} + + if session.veto_type != "no": + session.veto_thresholds = {c: d[f"veto_{c}"] for c in criteria} + + if session.veto_type == "soft": + session.penalty_factor = d["penalty_factor"] + + session.save() + + +# ------------------------------------------------------------------ +# Form 3: sampling & uncertainty parameters +# ------------------------------------------------------------------ + +class SamplingConfigForm(forms.Form): + """ + Only shown when scenario == "uncertain". + Fields are added dynamically based on scenario, weight_mode, and sampling_mode. + + Saved to: session.n_samples, .alpha, .alpha_group, .alpha_local, + .group_order, .local_order + """ + + def __init__(self, session: McdaSession, *args, **kwargs): + super().__init__(*args, **kwargs) + self.session = session + + # -- n_samples: scenario != deterministic + self.fields["n_samples"] = forms.IntegerField( + label="Number of samples", min_value=1, required=False + ) + + self.fields["alpha"] = PositiveFloatField( + label="Alpha (global confidence level)", required=False + ) + if session.weight_mode in ("group", "hierarchical"): + self.fields["alpha_group"] = PositiveFloatField( + label="Alpha for group level", required=False + ) + if session.weight_mode == "hierarchical": + self.fields["alpha_local"] = PositiveFloatField( + label="Alpha for local level", required=False + ) + + def _parse_order_lines(self, raw: str, valid_names: list[str]) -> list[tuple]: + """Parses multi-line ordering constraints into list of (a, b, intensity) tuples.""" + result = [] + for i, line in enumerate(raw.strip().splitlines(), start=1): + line = line.strip() + if not line: + continue + parts = [p.strip() for p in line.split(",")] + if len(parts) != 3: + raise forms.ValidationError(f"Line {i}: expected 'A, B, intensity'.") + a, b = parts[0], parts[1] + for name in (a, b): + if name not in valid_names: + raise forms.ValidationError( + f"Line {i}: '{name}' is not a recognised name." + ) + try: + intensity = float(parts[2]) + except ValueError: + raise forms.ValidationError(f"Line {i}: intensity must be a number.") + if intensity < 0: + raise forms.ValidationError(f"Line {i}: intensity must be positive.") + result.append((a, b, intensity)) + return result + + def clean(self): + cleaned = super().clean() + session = self.session + + if not cleaned.get("n_samples"): + self.add_error("n_samples", "Required for non-deterministic scenarios.") + + if session.scenario == "uncertain": + if cleaned.get("alpha") is None: + self.add_error("alpha", "Required for uncertain scenario.") + if session.weight_mode in ("group", "hierarchical") and cleaned.get("alpha_group") is None: + self.add_error("alpha_group", "Required for uncertain + group/hierarchical weighting.") + if session.weight_mode == "hierarchical" and cleaned.get("alpha_local") is None: + self.add_error("alpha_local", "Required for uncertain + hierarchical weighting.") + + return cleaned + + def save(self, session: McdaSession) -> None: + data = self.cleaned_data + session.n_samples = data.get("n_samples") + session.alpha = data.get("alpha") + session.alpha_group = data.get("alpha_group") + session.alpha_local = data.get("alpha_local") + # session.group_order = data.get("group_order") + # session.local_order = data.get("local_order") + 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.") + 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/mcda.py b/mysite/dss/mcda.py new file mode 100644 index 0000000..865e318 --- /dev/null +++ b/mysite/dss/mcda.py @@ -0,0 +1,1287 @@ +# Generated from: Promethee_Smaa.ipynb +# Converted at: 2026-06-30 + +from dataclasses import dataclass +import numpy as np +import pandas as pd +from typing import Literal, Optional + +from . import plot + +# Aliases for reused data structures +RangeDict = dict[str, float | tuple[float, float]] +Bounds = dict[str, float] +OrderConstraint = list[tuple[str, str, float]] + +@dataclass(slots=True) +class WeightConstraints: + """ + Class for storing weights and constraints. + Each criterion and group can have a weight. + Weight can be a single value or a range. + The ordering of groups or criteria can also be specified. + """ + group: Optional[RangeDict] = None + group_order: Optional[OrderConstraint] = None + #TODO: local parameters not needed; ungrouped criterion can be used, group can be derived + local: Optional[dict[str, RangeDict]] = None + local_order: Optional[dict[str, OrderConstraint]] = None + criterion: Optional[RangeDict] = None + criterion_order: Optional[OrderConstraint] = None + +@dataclass(slots=True) +class McdaConfig: + # General + df: pd.DataFrame + # decision_matrix: dict[str, RangeDict] + directions: dict[str, Literal["min", "max"] | float] + scenario: str + method: str + criteria: Optional[list[str]] = None # automatically created + + # Weighing and grouping + weight_mode: str = "flat" + groups: Optional[dict[str, list[str]]] = None + _weights: Optional[dict[str, float]] = None + + # PROMETHEE parameters + thresholds: Optional[RangeDict] = None + veto_type: str = "no" + _use_veto: bool = False + veto_thresholds: Optional[Bounds] = None + penalty_factor: float = 0.5 + + # Sampling + sampling_mode: Optional[str] = None + n_samples: int = 10000 + alpha: float = 1.0 + alpha_group: float = 1.0 + alpha_local: float = 1.0 + + # Weight constraints + constraints: Optional[WeightConstraints] = None + + +# ============================================================ +# PERFORMANCE UNCERTAINTY SAMPLING +# ============================================================ + +def has_uncertain_performances(df): + """ + Returns True if at least one cell contains + an interval instead of a deterministic value. + """ + for col in df.columns: + for value in df[col]: + if isinstance(value, (tuple, list)): + return True + return False + +def sample_performance_matrix(df, rng): + """ + This function generates one sampled decision matrix. + If a performance value is deterministic, it is left unchanged. + If a performance value is an interval (lower, upper) + then one value is sampled uniformly inside that interval: + x ~ Uniform(lower, upper) + """ + sampled_df = df.copy() + for col in sampled_df.columns: + for alt in sampled_df.index: + value = sampled_df.loc[alt, col] + if isinstance(value, (tuple, list)): + lower, upper = value + sampled_df.loc[alt, col] = rng.uniform(lower, upper) + return sampled_df + +# ============================================================ +# PROMETHEE HELPER FUNCTIONS +# ============================================================ + +def preference_difference(x_a: float, x_b: float, direction: str) -> float: + """ + Compute the performance difference between alternatives a and b. + The returned value is positive when alternative a is preferred to + alternative b on the considered criterion. + + For benefit criteria: + higher values are better, so difference = x_a - x_b + + For cost criteria: + lower values are better, so difference = x_b - x_a + """ + if isinstance(direction, (int, float)): + return abs(x_b - direction) - abs(x_a - direction) + elif direction == "max": + return x_a - x_b + elif direction == "min": + return x_b - x_a + else: + raise ValueError(f"Unknown direction: {direction}") + + +def promethee_like_preference(d: float, q: float = 0.0) -> float: + """ + PROMETHEE-like binary preference function. + + If the advantage of a over b is greater than the indifference + threshold q, then preference is complete: + P(a,b) = 1 + Otherwise: + P(a,b) = 0 + + With q = 0, this corresponds to a usual criterion (type 1). + With q > 0, this introduces an indifference threshold (type 2). + """ + return 1.0 if d > q else 0.0 + + +def promethee_linear_preference(d: float, q: float, p: float) -> float: + """ + PROMETHEE linear preference function. + + Parameters: + q : indifference threshold + p : preference threshold + + Preference is computed as follows: + + if d <= q: + P(a,b) = 0 + + if q < d < p: + P(a,b) increases linearly from 0 to 1 + + if d >= p: + P(a,b) = 1 + + If q = 0, this corresponds to a V-shape preference function (type 3). + If q > 0, this corresponds to a linear preference function with + indifference area (type 5). + """ + if d <= q: + return 0.0 + if d >= p: + return 1.0 + return (d - q) / (p - q) + + +def veto_is_triggered( + a, b, df_current, directions_dict, veto_thresholds_dict +): + """ + Check whether alternative a is much worse than alternative b + on at least one criterion with a veto threshold. + """ + for c, v in veto_thresholds_dict.items(): + # disadvantage is the inverse of preference + disadvantage = -preference_difference( + df_current.loc[a, c], + df_current.loc[b, c], + directions_dict[c] + ) + if disadvantage > v: + return True + return False + + +def set_fixed_weights(config: McdaConfig): + """ + The dictionary `weights` contains the final weight assigned to + each criterion. + Equal weighting: + each final criterion receives weight 1 / number_of_criteria. + Group-based weighting: + each group has a fixed weight W_g. + The weight of the group is distributed uniformly among + the criteria belonging to that group. + For criterion c belonging to group g: + w_c = W_g / |G_g| + Hierarchical weighting: + each final criterion weight is obtained by multiplying: + group-level weight × normalized local weight + For criterion c belonging to group g: + w_c = W_g × w_{c|g} + where local weights are normalized within each group. + """ + + if config.weight_mode == "flat": + weights = {c: 1 / len(config.criteria) for c in config.criteria} + + elif config.weight_mode == "group": + weights = {} + for g, crits in config.groups.items(): + wg = config.constraints.group[g] + if isinstance(wg, (list, tuple)): + wg = sum(wg) + for c in crits: + weights[c] = wg / len(crits) + + elif config.weight_mode == "hierarchical": + weights = {} + for g, crits in config.groups.items(): + Wg = config.constraints.group[g] + if isinstance(Wg, (list, tuple)): + Wg = sum(Wg) + total_local = sum([ + sum(v)/len(v) if isinstance(v, (list, tuple)) else v + for v in config.constraints.local[g].values() + ]) + for c in crits: + w_local = config.constraints.local[g][c] + if isinstance(w_local, (list, tuple)): + w_local = sum(w_local) + weights[c] = Wg * w_local / total_local + + config._weights = weights + + +# ============================================================ +# WEIGHTING AND RANKING HELPER FUNCTIONS +# ============================================================ +def ranking_to_pairwise_constraints(ranking: list, expected_items: list=None): + """ + Convert a complete or incomplete ordinal ranking into + pairwise constraints. + + Example 1 — complete ranking: + [ + ["C1"], + ["C2", "C3"], + ["C4"] + ] + + Example 2 — incomplete ranking: + [ + ["C1"] + ] + + If expected_items contains C1, C2, C3, and C4, the second + example means that C1 is first, while no order is imposed + among C2, C3, and C4. + + Items belonging to the same rank are not compared. + """ + + if ranking is None: + return [] + + # Copy the ranking to avoid modifying the original input. + effective_ranking = [list(rank_level) for rank_level in ranking] + + ranked_items = [ + item for rank_level in effective_ranking for item in rank_level + ] + ranked_items_set = set(ranked_items) + + if len(ranked_items) != len(ranked_items_set): + raise ValueError("Each item can appear only once in the ranking.") + + if expected_items is not None: + expected_items_set = set(expected_items) + unknown_items = ranked_items_set - expected_items_set + + if unknown_items: + raise ValueError( + f"Unknown items in ranking: {sorted(unknown_items)}" + ) + + # Items omitted from the ranking are interpreted as + # belonging to a final, unordered rank. + unranked_items = list(expected_items_set - ranked_items_set) + + if unranked_items: + effective_ranking.append(unranked_items) + + constraints = [] + + for higher_position in range(len(effective_ranking)): + for lower_position in range(higher_position + 1, len(effective_ranking)): + for higher_item in effective_ranking[higher_position]: + for lower_item in effective_ranking[lower_position]: + constraints.append((higher_item, lower_item, 1.0)) + + return constraints + + +def combine_order_constraints( + ranking=None, pairwise_constraints=None, expected_items=None +): + """ + Combine: + - a complete or incomplete ordinal ranking; + - optional partial pairwise constraints. + + Pairwise constraints can be: + (more_important, less_important) + or: + (more_important, less_important, intensity) + """ + + constraints = ranking_to_pairwise_constraints(ranking, expected_items) + + if pairwise_constraints is not None: + constraints.extend(pairwise_constraints) + + # Remove duplicate constraints. + # If the same comparison has different intensities, + # retain the strongest one. + + merged_constraints = {} + + for constraint in constraints: + intensity = 1.0 if len(constraint) == 2 else constraint[2] + key = constraint[:2] + merged_constraints[key] = max( + intensity, merged_constraints.get(key, 0.0) + ) + + return [ + (*key, intensity) + for key, intensity in merged_constraints.items() + ] + +# ============================================================ +# ACTIVATE CONSTRAINTS ACCORDING TO SAMPLING MODE +# ============================================================ +def get_active_constraints(config: McdaConfig) -> WeightConstraints: + """ + This block translates the selected sampling_mode into the + actual constraints used by the rejection sampler. + + The active constraints depend on sampling_mode: + + ------------------------------------------------------------ + sampling_mode = "random" + - group weights are only constrained by the simplex: + W_g >= 0, sum_g W_g = 1 + - local weights are only constrained by the simplex: + w_{j|g} >= 0, sum_j w_{j|g} = 1 + - no specific lower/upper bounds are used; + - no ordinal constraints are used. + + ------------------------------------------------------------ + sampling_mode = "bounded" + - group lower/upper bounds are activated; + - local lower/upper bounds are activated; + - ordinal constraints are not activated. + + ------------------------------------------------------------ + sampling_mode = "ordered" + - ordinal constraints and preference intensities are activated; + - specific lower/upper bounds are not activated; + - only natural simplex bounds 0 <= w <= 1 are used. + """ + groups = config.groups + active = WeightConstraints() + + if config.sampling_mode == "random": + active.group = {g: (0.0, 1.0) for g in groups} + active.group_order = [] + + active.local = { + g: {c: (0.0, 1.0) for c in crits} + for g, crits in groups.items() + } + active.local_order = {g: [] for g in groups} + + elif config.sampling_mode == "bounded": + active = config.constraints + active.group_order = [] + active.local_order = {g: [] for g in groups} + + elif config.sampling_mode == "ordered": + active.group = {g: (0.0, 1.0) for g in groups} + active.group_order = config.constraints.group_order + + active.local = { + g: {c: (0.0, 1.0) for c in crits} + for g, crits in groups.items() + } + active.local_order = config.constraints.local_order + + elif config.sampling_mode == "bounded_ordered": + active = config.constraints + + else: + raise ValueError( + "sampling_mode must be either " + "'random', 'bounded', 'ordered', or 'bounded_ordered'" + ) + + # Add ungrouped criterion dict + if config.weight_mode == "flat": + active.criterion = {} + active.criterion_order = [] + for group in groups: + active.criterion.update(active.local[group]) + active.criterion_order.append(active.local_order[group]) + + return active + + +def sample_weights_dirichlet_constrained( + bounds_dict: dict, + order_cons: list=[], + max_tries=100000, + batch_size=1000, + alpha=1.0, + rng=None, + stats=None, + stats_key="weights" +): + """ + Sample one feasible weight vector from a constrained Dirichlet + distribution using rejection sampling. + + The function first draws a candidate vector: + w ~ Dirichlet(alpha, ..., alpha) + + and then accepts it only if all active constraints are satisfied. + + The constraints are: + 1) Lower and upper bounds (`bounds_dict`): + lb_i <= w_i <= ub_i + + 2) Optional ordinal constraints (`order_cons`): + w_superior >= w_inferior + + 3) Optional preference intensity constraints (`order_cons`): + w_superior >= intensity × w_inferior + + Notes + ----- + With alpha = 1, the Dirichlet distribution is uniform on the + simplex. Therefore, rejection sampling produces samples uniformly + distributed over the feasible region defined by the active + constraints. + + The optional `stats` dictionary records how many candidate + vectors are generated, accepted and rejected. This is useful to + evaluate the efficiency of rejection sampling. + """ + + if rng is None: + rng = np.random.default_rng() + + if stats is not None and stats_key not in stats: + stats[stats_key] = {"draws": 0, "accepted": 0, "rejected": 0} + + keys = list(bounds_dict.keys()) + idx = {x: i for i, x in enumerate(keys)} + bounds_arr = np.array(list(bounds_dict.values()), dtype=float) + alpha_vec = np.full(len(keys), alpha, dtype=float) + + # Precompute constraint indices/intensities once, outside the loop + hi_idx, lo_idx, intensities = [], [], [] + for constraint in order_cons: + if len(constraint) == 2: + hi, lo = constraint + intensity = 1.0 + elif len(constraint) == 3: + hi, lo, intensity = constraint + else: + raise ValueError( + "Each ordinal constraint must be either " + "(superior, inferior) or " + "(superior, inferior, intensity)." + ) + hi_idx.append(idx[hi]) + lo_idx.append(idx[lo]) + intensities.append(intensity) + + hi_idx = np.array(hi_idx, dtype=int) + lo_idx = np.array(lo_idx, dtype=int) + intensities = np.array(intensities, dtype=float) + + tries_done = 0 + while tries_done < max_tries: + n = min(batch_size, max_tries - tries_done) + + # Draw a whole batch of weights at once (vectorized) + w_batch = rng.dirichlet(alpha_vec, size=n) # shape (n, keys) + tries_done += n + + if stats is not None: + stats[stats_key]["draws"] += n + + # Vectorized bound check across the batch + ok = np.all( + ( w_batch >= bounds_arr[:, 0] - 1e-12) + & (w_batch <= bounds_arr[:, 1] + 1e-12), + axis=1, + ) + + # Vectorized ordinal constraint check across the batch + if ok.any() and len(hi_idx) > 0: + super_w = w_batch[:, hi_idx] + 1e-12 + infer_w = w_batch[:, lo_idx] * intensities + ok &= np.all(super_w >= infer_w, axis=1) + + n_accepted = int(ok.sum()) + if n_accepted > 0: + if stats is not None: + stats[stats_key]["accepted"] += 1 + stats[stats_key]["rejected"] += n - n_accepted + weights = w_batch[np.argmax(ok)] + return {x: float(weights[i]) for x, i in idx.items()} + + elif stats is not None: + stats[stats_key]["rejected"] += n + + raise RuntimeError( + "Could not sample a feasible weight vector. " + "Relax bounds/constraints or increase max_tries." + ) + + +def sample_hierarchical_weights( + groups, + constraints, + + alpha_group=1.0, + alpha_local=1.0, + rng=None, + sampler_stats=None +): + """ + Sample hierarchical criterion weights. + + The hierarchical model is based on two sampling levels. + + Level 1: group weights + ---------------------- + A vector of group weights is sampled: + W = (W_CRM, W_Circularity, W_Environmental, W_Manufacturer) + with: + sum_g W_g = 1 + and with the active group-level constraints. + + Level 2: local criterion weights + --------------------------------- + For each group g, a local weight vector is sampled: + w_{j|g} + with: + sum_{j in G_g} w_{j|g} = 1 + and with the active local constraints for that group. + + Final global weights + -------------------- + The final criterion weight is obtained as: + w_j = W_g × w_{j|g} + where criterion j belongs to group g. + + The optional sampler_stats object stores rejection sampling + diagnostics separately for: + - group weights; + - local weights of each group. + """ + + if rng is None: + rng = np.random.default_rng() + + group_weights_sampled = sample_weights_dirichlet_constrained( + bounds_dict=constraints.group, + order_cons=constraints.group_order, + alpha=alpha_group, + rng=rng, + stats=sampler_stats, + stats_key="group_weights" + ) + + final_weights = {} + + for g, crits in groups.items(): + + local_weights_sampled = sample_weights_dirichlet_constrained( + bounds_dict=constraints.local[g], + order_cons=constraints.local_order[g], + alpha=alpha_local, + rng=rng, + stats=sampler_stats, + stats_key=f"local_weights_{g}" + ) + + for c in crits: + final_weights[c] = group_weights_sampled[g] * local_weights_sampled[c] + + return final_weights + + +def sample_smaa_weights(config: McdaConfig, sampler_stats={}, rng=None): + """ + Sample one criterion-weight vector according to the selected + SMAA weight structure. + + Available weight structures + --------------------------- + + 1) flat + Final criterion weights are sampled directly: + w ~ Dirichlet(alpha, ..., alpha) + + No group structure is used. + + 2) group + Only group weights are sampled: + W_g + + Then each group weight is uniformly distributed among the + criteria in that group: + w_j = W_g / |G_g| + + This mode is useful when the decision maker can express + uncertainty at the group level, but not within groups. + + 3) hierarchical + Group weights and local within-group weights are both sampled: + w_j = W_g × w_{j|g} + + This is the most flexible structured model, because it allows + both group-level and criterion-level uncertainty. + + Returns + ------- + final_weights : dict + Dictionary mapping each final criterion to its sampled weight. + """ + if rng is None: + rng = np.random.default_rng() + + if config.weight_mode == "flat": + active_constr = get_active_constraints(config) + config._weights = sample_weights_dirichlet_constrained( + bounds_dict=active_constr.criterion, + order_cons=[], + alpha=config.alpha, + rng=rng, + stats=sampler_stats, + stats_key="flat_weights" + ) + + elif config.weight_mode == "group": + active_constr = get_active_constraints(config) + group_weights_sampled = sample_weights_dirichlet_constrained( + bounds_dict=active_constr.group, + order_cons=active_constr.group_order, + alpha=config.alpha_group, + rng=rng, + stats=sampler_stats, + stats_key="group_weights" + ) + + final_weights = {} + for g, crits in config.groups.items(): + for c in crits: + final_weights[c] = group_weights_sampled[g] / len(crits) + config._weights = final_weights + + elif config.weight_mode == "hierarchical": + config._weights = sample_hierarchical_weights( + groups=config.groups, + constraints=config.constraints, + alpha_group=config.alpha_group, + alpha_local=config.alpha_local, + rng=rng, + sampler_stats=sampler_stats, + ) + + else: + raise ValueError( + "weight_mode must be either " + "'flat', 'group', or 'hierarchical'" + ) + +# ============================================================ +# PROMETHEE EVALUATION ENGINE +# ============================================================ + +def promethee_nfs(config: McdaConfig, decision_matrix: pd.DataFrame=None): + """ + Compute PROMETHEE net flow scores for a given decision matrix + and a given criterion-weight vector. + + For each ordered pair of alternatives (a,b), the function computes + an aggregated preference score: + S(a,b) = sum_j w_j × P_j(a,b) + + where: + w_j = criterion weight + P_j(a,b) = unicriterion preference of a over b + + Two preference models are supported: + + 1) promethee_like + Binary preference function: + P_j(a,b) = 1 if d_j(a,b) > q_j + = 0 otherwise + + 2) promethee + Linear PROMETHEE preference function with indifference and + preference thresholds: + P_j(a,b) = 0 if d <= q + = (d - q) / (p - q) if q < d < p + = 1 if d >= p + + After computing S(a,b), an optional veto or penalty can be applied. + + The final PROMETHEE flows are: + + phi_plus(a) = sum_b S(a,b) + phi_minus(a) = sum_b S(b,a) + phi(a) = phi_plus(a) - phi_minus(a) + + Returns + ------- + nfs : pandas.Series + Net flow score for each alternative. + """ + # Fall-back to defaults for unspecified parameters + df = decision_matrix if decision_matrix is not None else config.df + method = config.method or "promethee_like" + alts = config.df.index.tolist() + + S = pd.DataFrame(0.0, index=alts, columns=alts) + + if config._use_veto and config.veto_thresholds is None: + raise ValueError("veto_thresholds must be provided when use_veto=True.") + + for a in alts: + for b in alts: + if a == b: + continue + + score = 0.0 + + for c in df.columns: + d = preference_difference( + df.loc[a, c], + df.loc[b, c], + config.directions[c] + ) + + if method == "promethee_like": + q = config.thresholds[c] + pref = promethee_like_preference(d, q) + elif method == "promethee": + q, p = config.thresholds[c] + pref = promethee_linear_preference(d, q, p) + else: + raise ValueError("method must be either 'promethee_like' or 'promethee'.") + + score += config._weights[c] * pref + + # Apply optional veto / penalty after aggregation + if config._use_veto and veto_is_triggered( + a=a, + b=b, + df=df, + directions_dict=config.directions, + veto_thresholds_dict=config.veto_thresholds, + ): + if config.veto_type == "hard": + score = 0.0 + elif config.veto_type == "soft": + score *= config.penalty_factor + else: + raise ValueError("veto_type must be either 'hard' or 'soft'.") + + S.loc[a, b] = score + + phi_plus = S.sum(axis=1) + phi_minus = S.sum(axis=0) + nfs = phi_plus - phi_minus + + return nfs + +def deterministic_promethee(config: McdaConfig): + """ + This function is applicable when: + scenario = "deterministic" + and the decision matrix does not contain interval-valued performances. + + In this case: + - the decision matrix is fixed; + - the weights are fixed; + - one PROMETHEE evaluation is performed; + - the final output is a deterministic ranking. + """ + alts = config.df.index.tolist() + S = pd.DataFrame(0.0, index=alts, columns=alts) + + for a in alts: + for b in alts: + if a == b: + continue + + score = 0.0 + + for c in config.criteria: + d = preference_difference( + config.df.loc[a, c], config.df.loc[b, c], config.directions[c] + ) + + if config.method == "promethee_like": + q = config.thresholds[c] + pref = promethee_like_preference(d, q) + elif config.method == "promethee": + if isinstance(config.thresholds[c], (float, int)): + print(config.method, c, config.thresholds[c]) + q, p = config.thresholds[c] + pref = promethee_linear_preference(d, q, p) + else: + raise ValueError( + "method must be either 'promethee_like' or 'promethee'" + ) + score += config._weights[c] * pref + + if config._use_veto and veto_is_triggered( + a, b, config.df, config.directions, config.veto_thresholds + ): + if config.veto_type == "hard": + score = 0.0 + elif config.veto_type == "soft": + score *= config.penalty_factor + else: + raise ValueError( + "veto_type must be either 'hard' or 'soft'" + ) + + S.loc[a, b] = score + + # ============================================================ + # OUTRANKING FLOWS + # ============================================================ + + phi_plus = S.sum(axis=1) + phi_minus = S.sum(axis=0) + nfs = phi_plus - phi_minus + + results = pd.DataFrame({ + "FOR (phi+)": phi_plus, + "AGAINST (phi-)": phi_minus, + "NFS (phi)": nfs + }).sort_values("NFS (phi)", ascending=False) + return results, S + +def run_performance_uncertainty(config: McdaConfig, rng=None): + """ + Run a Monte Carlo analysis with uncertainty only on alternative + performances. + + In this analysis: + - criterion weights are fixed; + - performance values may be uncertain; + - uncertain performances are sampled from their intervals; + - PROMETHEE is applied to each sampled decision matrix; + - rank acceptability indices are computed from the simulated rankings. + """ + + if rng is None: + rng = np.random.default_rng() + + n_samples = config.n_samples + alts = config.df.index.tolist() + n_alts = len(alts) + + rank_counts = pd.DataFrame( + 0, + index=alts, + columns=[f"rank_{r}" for r in range(1, n_alts + 1)], + dtype=int + ) + outrank_counts = pd.DataFrame( + 0, index=alts, columns=alts, dtype=int + ) + + win_counts = pd.Series(0, index=alts, dtype=int) + + nfs_samples = {a: [] for a in alts} + rank_samples = {a: [] for a in alts} + + for s in range(n_samples): + current_df = sample_performance_matrix(config.df, rng) + nfs = promethee_nfs(config, decision_matrix=current_df) + + order = nfs.sort_values(ascending=False).index.tolist() + rank_map = {a: r for r, a in enumerate(order, start=1)} + + for a in alts: + nfs_samples[a].append(float(nfs[a])) + rank_samples[a].append(rank_map[a]) + + for r, a in enumerate(order, start=1): + rank_counts.loc[a, f"rank_{r}"] += 1 + + win_counts[order[0]] += 1 + + for a in alts: + for b in alts: + if a != b and nfs[a] > nfs[b]: + outrank_counts.loc[a, b] += 1 + + rank_accept = rank_counts / n_samples + win_prob = win_counts / n_samples + outrank_prob = outrank_counts / n_samples + + ranks = np.arange(1, n_alts + 1, dtype=float) + + exp_rank = (rank_accept.values * ranks).sum(axis=1) + exp_rank = pd.Series(exp_rank, index=alts).sort_values() + + mean_nfs = pd.Series( + {a: float(np.mean(nfs_samples[a])) for a in alts} + ).sort_values(ascending=False) + + sim_rows = [] + + for a in alts: + for i in range(n_samples): + sim_rows.append({ + "Alternative": a, + "Simulation": i + 1, + "NFS": nfs_samples[a][i], + "Rank": rank_samples[a][i] + }) + + sim_df = pd.DataFrame(sim_rows) + + return { + "rank_acceptability": rank_accept, + "expected_rank": exp_rank, + "win_probability": win_prob.sort_values(ascending=False), + "outrank_probability": outrank_prob, + "mean_nfs": mean_nfs, + "simulations": sim_df + } + + +# ============================================================ +# SMAA MONTE CARLO SIMULATION +# ============================================================ + +def run_smaa(config: McdaConfig, analysis_type="full_smaa", rng=None): + """ + Run the SMAA Monte Carlo simulation. + + At each simulation, the procedure performs three steps: + + 1) Generate one realization of the decision problem + ------------------------------------------------ + If analysis_type = "smaa_weights": + the decision matrix is fixed. + + If analysis_type = "full_smaa": + uncertain performance values are sampled from their + intervals, generating a new decision matrix. + + 2) Sample one feasible criterion-weight vector + ------------------------------------------- + The sampled weights depend on weight_mode: + flat -> sample final criterion weights directly + group -> sample group weights only + hierarchical -> sample group and local weights + + Active constraints depend on sampling_mode: + random -> no specific constraints + bounded -> lower/upper bounds + ordered -> ordinal and intensity constraints + bounded_ordered -> bounds + constraints + + 3) Evaluate alternatives using PROMETHEE + ------------------------------------- + PROMETHEE net flow scores are computed and converted into + rankings. + + The function then aggregates all simulations to compute: + - rank acceptability indices; + - winning probabilities; + - pairwise outranking probabilities; + - expected ranks; + - mean sampled weights; + - rejection sampling diagnostics. + + Returns + ------- + dict + Dictionary containing SMAA outputs and diagnostic information. + """ + + if rng is None: + rng = np.random.default_rng() + + # The flat model samples final criterion weights directly. + # Since no group or local structure is used, this implementation + # allows flat sampling only in the unconstrained random case. + + if config.weight_mode == "flat" and config.sampling_mode != "random": + raise ValueError( + "With weight_mode='flat', only sampling_mode='random' is supported. " + "Use weight_mode='group' or weight_mode='hierarchical' for bounded or ordered sampling." + ) + + alts = config.df.index.tolist() + n_alts = len(alts) + + rank_counts = pd.DataFrame( + 0, + index=alts, + columns=[f"rank_{r}" for r in range(1, n_alts + 1)], + dtype=int + ) + + win_counts = pd.Series(0, index=alts, dtype=int) + outrank_counts = pd.DataFrame(0, index=alts, columns=alts, dtype=int) + weight_samples = {c: [] for c in config.criteria} + + nfs_samples = {a: [] for a in alts} + rank_samples = {a: [] for a in alts} + accepted = 0 + sampler_stats = {} + + for _ in range(config.n_samples): + # Generate one realization of uncertainty + if analysis_type == "smaa_weights": + current_df = config.df + elif analysis_type == "full_smaa": + current_df = sample_performance_matrix(config.df, rng) + else: + raise ValueError( + "analysis_type must be either " + "'smaa_weights' or 'full_smaa'" + ) + + # Sample one feasible weight vector + sample_smaa_weights(config, sampler_stats, rng) + + for c in config.criteria: + weight_samples[c].append(config._weights[c]) + + # Evaluate alternatives using PROMETHEE + nfs = promethee_nfs(config, decision_matrix=current_df) + # Convert NFS values into a ranking + order = nfs.sort_values(ascending=False).index.tolist() + rank_map = {a: r for r, a in enumerate(order, start=1)} + + # Update SMAA statistics + for a in alts: + nfs_samples[a].append(float(nfs[a])) + rank_samples[a].append(rank_map[a]) + + for r, a in enumerate(order, start=1): + rank_counts.loc[a, f"rank_{r}"] += 1 + + win_counts[order[0]] += 1 + + for a in alts: + for b in alts: + if a == b: + continue + if nfs[a] > nfs[b]: + outrank_counts.loc[a, b] += 1 + + accepted += 1 + + rank_accept = rank_counts / accepted + win_prob = win_counts / accepted + outrank_prob = outrank_counts / accepted + + ranks = np.arange(1, n_alts + 1, dtype=float) + exp_rank = (rank_accept.values * ranks).sum(axis=1) + exp_rank = pd.Series(exp_rank, index=alts).sort_values() + + # Mean PROMETHEE net flow across all simulations + mean_net_flows = pd.Series( + {a: float(np.mean(nfs_samples[a])) for a in alts}, + name="Mean net flow", + ).sort_values(ascending=False) + + w_mean = pd.Series( + {c: float(np.mean(weight_samples[c])) for c in config.criteria} + ).sort_values(ascending=False) + + sim_rows = [] + + for a in alts: + for i in range(accepted): + sim_rows.append({ + "Alternative": a, + "Simulation": i + 1, + "NFS": nfs_samples[a][i], + "Rank": rank_samples[a][i] + }) + + sim_df = pd.DataFrame(sim_rows) + + sampler_stats_df = pd.DataFrame.from_dict( + sampler_stats, orient="index" + ) + + if not sampler_stats_df.empty: + sampler_stats_df["acceptance_rate"] = ( + sampler_stats_df["accepted"] / sampler_stats_df["draws"] + ) + + sampler_stats_df["rejection_rate"] = ( + sampler_stats_df["rejected"] / sampler_stats_df["draws"] + ) + + return { + "rank_acceptability": rank_accept, + "expected_rank": exp_rank, + "win_probability": win_prob.sort_values(ascending=False), + "outrank_probability": outrank_prob, + "mean_weights": w_mean, + "mean_net_flows": mean_net_flows, + "n_samples": accepted, + "simulations": sim_df, + "sampler_stats": sampler_stats_df + } + + +def mcda(config: McdaConfig): + # ============================================================ + # UNCERTAINTY SETTINGS + # ============================================================ + + # Detect whether the decision matrix contains interval-valued performances. + # If at least one cell is a tuple/list, performance uncertainty is activated. + performance_uncertainty = has_uncertain_performances(config.df) + config._use_veto = (config.veto_type!="no") + config.criteria = list(config.directions.keys()) + set_fixed_weights(config) + + if config.method == "promethee_like": # Try to fix if thresholds has tuples + for c, val in config.thresholds.items(): + if not isinstance(val, float): + config.thresholds[c] = sum(val)/len(val) + + # Select the actual analysis type. + if config.scenario == "deterministic": + if performance_uncertainty: + analysis_type = "performance_uncertainty" + else: + analysis_type = "deterministic" + + elif config.scenario == "uncertain": + if performance_uncertainty: + analysis_type = "full_smaa" + else: + analysis_type = "smaa_weights" + + else: + raise ValueError( + "scenario must be either 'deterministic' or 'uncertain'" + ) + + # ============================================================ + # CASE 1: DETERMINISTIC PROMETHEE ANALYSIS + # ============================================================ + + if analysis_type == "deterministic": + results, pairwise = deterministic_promethee(config) + + pd.set_option("display.precision", 6) + print("\n--- DETERMINISTIC SCENARIO ---") + print("\nDecision matrix (numeric):") + print(config.df) + print("\nPairwise preference matrix S(a,b):") + print(pairwise) + print("\nPROMETHEE flows and Net Flow Scores:") + print(results) + print("\nRanking (best to worst):") + print(list(results.index)) + + # plot.deterministic_promethee_figures(results, pairwise) + + # ============================================================ + # CASE 2: PERFORMANCE UNCERTAINTY ANALYSIS + # ============================================================ + """ + This block is executed when: + scenario = "deterministic" + but the decision matrix contains interval-valued performances. + + In this case: + - criterion weights are fixed; + - performance values are sampled through Monte Carlo; + - PROMETHEE is applied to each sampled matrix; + - rank acceptability indices and winning probabilities are + computed from the resulting rankings. + """ + + elif analysis_type == "performance_uncertainty": + rng = np.random.default_rng(42) + results = run_performance_uncertainty(config, rng) + + print("\n--- PERFORMANCE UNCERTAINTY SCENARIO ---") + print(f"Samples used: {config.n_samples}") + + print("\nWinning probabilities (P[rank=1]):") + print(results["win_probability"]) + + print("\nExpected rank (lower is better):") + print(results["expected_rank"]) + + print("\nRank acceptability indices b_{i,r}:") + print(results["rank_acceptability"]) + + print("\nMean NFS:") + print(results["mean_nfs"]) + + # plot.performance_uncertainty_figures(results) + + # ============================================================ + # UNCERTAIN SCENARIO EXECUTION + # ============================================================ + """ + This block is executed when: + scenario = "uncertain" + + Depending on the automatically selected analysis_type, the function + run_smaa performs either: + smaa_weights + uncertainty only on weights; + + full_smaa + uncertainty on both weights and performance values. + + The outputs include: + - winning probabilities; + - expected ranks; + - rank acceptability indices; + - mean sampled weights; + - rejection sampling diagnostics; + - pairwise outranking probabilities. + """ + + if config.scenario == "uncertain": + rng = np.random.default_rng(42) + results = run_smaa(config, analysis_type=analysis_type, rng=rng) + + print("\n--- SMAA SCENARIO ---") + print(f"Samples used: {results['n_samples']}") + + print("\nWinning probabilities (P[rank=1]):") + print(results["win_probability"]) + + mean_net_flow_ranking = list(results["mean_net_flows"].index) + print("\\nRanking based on mean net flows:") + print(mean_net_flow_ranking) + + print("\nExpected rank (lower is better):") + print(results["expected_rank"]) + + print("\nRank acceptability indices b_{i,r}:") + print(results["rank_acceptability"]) + + print("\nMean sampled weights (barycenter):") + print(results["mean_weights"]) + + print("\nRejection sampling diagnostics:") + print(results["sampler_stats"]) + + print("\nPairwise outranking probabilities P(i outranks j) based on NFS:") + print(results["outrank_probability"]) + + # plot.smaa_figures(results) diff --git a/mysite/dss/migrations/0001_initial.py b/mysite/dss/migrations/0001_initial.py new file mode 100644 index 0000000..1046c10 --- /dev/null +++ b/mysite/dss/migrations/0001_initial.py @@ -0,0 +1,95 @@ +# Generated by Django 6.0.5 on 2026-07-08 12:48 + +import django.core.validators +import django.db.models.deletion +import dss.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + dependencies = [] + + operations = [ + migrations.CreateModel( + name='Group', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=40)), + ('weight', models.CharField(max_length=10, null=True, validators=[dss.models.validate_number_or_range])), + ], + ), + migrations.CreateModel( + name='McdaSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('in_progress', 'In Progress'), ('complete', 'Complete')], default='pending', max_length=11)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('scenario', models.CharField(choices=[('deterministic', 'Deterministic'), ('uncertain', 'Uncertain')], max_length=15, null=True)), + ('method', models.CharField(choices=[('promethee_like', 'Promethee Like'), ('promethee', 'Promethee')], max_length=15, null=True)), + ('weight_mode', models.CharField(choices=[('flat', 'Flat'), ('group', 'Group'), ('hierarchical', 'Hierarchical')], max_length=12, null=True)), + ('veto_type', models.CharField(choices=[('no', 'No'), ('hard', 'Hard'), ('soft', 'Soft')], max_length=4, null=True)), + ('group_weights', models.JSONField(null=True)), + ('local_weights', models.JSONField(null=True)), + ('thresholds', models.JSONField(null=True)), + ('veto_thresholds', models.JSONField(null=True)), + ('penalty_factor', models.FloatField(default=0.5)), + ('sampling_mode', models.CharField(choices=[('random', 'Random'), ('bounded', 'Bounded'), ('ordered', 'Ordered'), ('bounded_ordered', 'Bounded Ordered')], max_length=15, null=True)), + ('n_samples', models.IntegerField(null=True)), + ('alpha', models.FloatField(null=True)), + ('alpha_group', models.FloatField(null=True)), + ('alpha_local', models.FloatField(null=True)), + ('group_order', models.JSONField(null=True)), + ('local_order', models.JSONField(null=True)), + ], + ), + migrations.CreateModel( + name='Criterion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=40)), + ('direction', models.CharField(help_text="Enter 'min', 'max' or a target value", max_length=10, validators=[dss.models.validate_number_or_range])), + ('weight', models.CharField(max_length=10, null=True, validators=[dss.models.validate_number_or_range])), + ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='criteria', to='dss.group')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='criteria', to='dss.mcdasession')), + ], + options={ + 'verbose_name_plural': 'Criteria', + 'unique_together': {('name', 'group')}, + }, + ), + migrations.CreateModel( + name='GroupOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('intensity', models.FloatField(validators=[django.core.validators.MinValueValidator(0)])), + ('group1', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ordered_higher', to='dss.group')), + ('group2', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ordered_lower', to='dss.group')), + ], + ), + migrations.CreateModel( + name='LocalOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('intensity', models.FloatField(validators=[django.core.validators.MinValueValidator(0)])), + ('criterion1', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ordered_higher', to='dss.criterion')), + ('criterion2', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ordered_lower', to='dss.criterion')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dss.group')), + ], + ), + migrations.CreateModel( + name='DecisionMatrix', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('alternative', models.CharField(max_length=40)), + ('values', models.JSONField()), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alternatives', to='dss.mcdasession')), + ], + ), + migrations.AddField( + model_name='group', + name='session', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='dss.mcdasession'), + ), + ] diff --git a/mysite/dss/migrations/0002_rename_group.py b/mysite/dss/migrations/0002_rename_group.py new file mode 100644 index 0000000..91b64e9 --- /dev/null +++ b/mysite/dss/migrations/0002_rename_group.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.5 on 2026-07-08 13:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dss', '0001_initial'), + ] + + operations = [ + migrations.RenameModel( + old_name='Group', + new_name='CritGroup', + ), + ] \ No newline at end of file diff --git a/mysite/dss/migrations/0003_alter_criterion_fields.py b/mysite/dss/migrations/0003_alter_criterion_fields.py new file mode 100644 index 0000000..1f21da3 --- /dev/null +++ b/mysite/dss/migrations/0003_alter_criterion_fields.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.5 on 2026-07-08 14:34 + +import dss.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dss', '0002_rename_group'), + ] + + operations = [ + migrations.AlterField( + model_name='criterion', + name='direction', + field=models.CharField(blank=True, help_text="Enter 'min', 'max' or a target value", max_length=10, validators=[dss.models.validate_number_or_range]), + ), + migrations.AlterField( + model_name='criterion', + name='weight', + field=models.CharField(blank=True, default='', max_length=10, validators=[dss.models.validate_number_or_range]), + preserve_default=False, + ), + migrations.RenameField( + model_name='decisionmatrix', + old_name='alternative', + new_name='name', + ), + migrations.AlterField( + model_name='criterion', + name='direction', + field=models.CharField(blank=True, help_text="Enter 'min', 'max' or a target value", max_length=10, validators=[dss.models.validate_direction]), + ), + migrations.AlterUniqueTogether( + name='decisionmatrix', + unique_together={('session', 'name')}, + ), + ] diff --git a/mysite/dss/migrations/0004_alter_localorder_grouporder.py b/mysite/dss/migrations/0004_alter_localorder_grouporder.py new file mode 100644 index 0000000..53574c0 --- /dev/null +++ b/mysite/dss/migrations/0004_alter_localorder_grouporder.py @@ -0,0 +1,38 @@ +# Generated by Django 6.0.5 on 2026-07-14 13:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dss', '0003_alter_criterion_fields'), + ] + + operations = [ + migrations.AddField( + model_name='grouporder', + name='session', + field=models.ForeignKey(default=24, on_delete=django.db.models.deletion.CASCADE, related_name='group_orders', to='dss.mcdasession'), + preserve_default=False, + ), + migrations.RemoveField( + model_name='localorder', + name='group', + ), + migrations.AddField( + model_name='localorder', + name='session', + field=models.ForeignKey(default=24, on_delete=django.db.models.deletion.CASCADE, related_name='local_orders', to='dss.mcdasession'), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name='localorder', + unique_together={('session', 'criterion1', 'criterion2')}, + ), + migrations.AlterUniqueTogether( + name='grouporder', + unique_together={('session', 'group1', 'group2')}, + ), + ] diff --git a/mysite/dss/migrations/0005_criterion_used_mcdasession_user_type.py b/mysite/dss/migrations/0005_criterion_used_mcdasession_user_type.py new file mode 100644 index 0000000..cd1b886 --- /dev/null +++ b/mysite/dss/migrations/0005_criterion_used_mcdasession_user_type.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.5 on 2026-08-05 12:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dss', '0004_alter_localorder_grouporder'), + ] + + operations = [ + migrations.AddField( + model_name='criterion', + name='used', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='mcdasession', + name='user_type', + field=models.CharField(choices=[('pd', 'Process developer'), ('kam', 'Key account manager')], max_length=3, null=True), + ), + ] diff --git a/mysite/dss/migrations/__init__.py b/mysite/dss/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mysite/dss/models.py b/mysite/dss/models.py new file mode 100644 index 0000000..ad21a15 --- /dev/null +++ b/mysite/dss/models.py @@ -0,0 +1,232 @@ +from django.db import models +from .mcda import McdaConfig, WeightConstraints +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator + + +def validate_direction(value): + try: + float(value) + except ValueError: + if value not in ["min", "max"]: + raise ValidationError("Must be either 'min', 'max' or target value.") + +def validate_number_or_range(value): + """ + Validates that the value is either a single number or a range (min-max). + Raises ValidationError if the format is invalid. + """ + if not isinstance(value, str): + raise ValidationError("Must be a string in the format 'number' or 'min,max'.") + elif value == "": + return + + try: # Check for single number + float(value) + except ValueError: + try: # Check for range + min_val, max_val = map(float, value.split(',')) + if min_val > max_val: + raise ValidationError("'min' cannot be greater than 'max'.") + return {'min': min_val, 'max': max_val} + except ValueError: + raise ValidationError("Invalid value, use 'min,max' or a number.") + +class McdaSession(models.Model): + """Ties the whole flow together. Tracks where the user is in the decision tree.""" + class Status(models.TextChoices): + PENDING = "pending" # request received, forms not started + IN_PROGRESS = "in_progress" # user is in the form wizard + COMPLETE = "complete" # mcda() has run + + class Scenario(models.TextChoices): + DETERMINISTIC = "deterministic" + UNCERTAIN = "uncertain" + + class Method(models.TextChoices): + PROMETHEE_LIKE = "promethee_like" + PROMETHEE = "promethee" + + class WeightMode(models.TextChoices): + FLAT = "flat" + GROUP = "group" + HIERARCHICAL = "hierarchical" + + class VetoType(models.TextChoices): + NO = "no" + HARD = "hard" + SOFT = "soft" + + class SamplingMode(models.TextChoices): + RANDOM = "random" + BOUNDED = "bounded" + ORDERED = "ordered" + BOUNDED_ORDERED = "bounded_ordered" + + status = models.CharField(max_length=11, choices=Status, default=Status.PENDING) + created_at = models.DateTimeField(auto_now_add=True) + user_type = models.CharField(max_length=3, choices={"pd": "Process developer", "kam": "Key account manager"}, null=True) + + # Form 1 - always present + scenario = models.CharField(max_length=15, choices=Scenario, null=True) + method = models.CharField(max_length=15, choices=Method, null=True) + weight_mode = models.CharField(max_length=12, choices=WeightMode, null=True) + sampling_mode = models.CharField(max_length=15, choices=SamplingMode, null=True) + veto_type = models.CharField(max_length=4, choices=VetoType, null=True) + + # Form 2 - conditional; stored as JSON to handle float|tuple values + group_weights = models.JSONField(null=True) # {"group_name": float | [lo, hi]} + local_weights = models.JSONField(null=True) # {"criterion": float | [lo, hi]} + thresholds = models.JSONField(null=True) # {"criterion": float | [lo, hi]} + veto_thresholds = models.JSONField(null=True) # {"criterion": float} + penalty_factor = models.FloatField(default=0.5) + + # Form 3 - conditional + n_samples = models.IntegerField(null=True) + alpha = models.FloatField(null=True) + alpha_group = models.FloatField(null=True) + alpha_local = models.FloatField(null=True) + group_order = models.JSONField(null=True) # [[group1, group2, float], ...] + local_order = models.JSONField(null=True) # [[criterion1, criterion2, float], ...] + + @property + def criteria(self): + return self.all_criteria.filter(used=True) + + def init_criteria_n_groups(self): + """Create all criteria and groups needed for the MCDA. + Some criteria and all groups are pre-defined. + """ + cost = CritGroup(session=self, name="Costs") + sust = CritGroup(session=self, name="Sustainability") + circ = CritGroup(session=self, name="Circularity", weight=0) + qual = CritGroup(session=self, name="Technical quality") + cost.save() + sust.save() + circ.save() + qual.save() + Criterion(session=self, name="Operating costs", group=cost, direction="min").save() + Criterion(session=self, name="Process energy use", group=sust, direction="min").save() + Criterion(session=self, name="Process carbon footprint", group=sust, direction="min").save() + #TODO: if self.user_type == self.UserType.KAM: + # Add more groups and criteria + + def build_config(self) -> McdaConfig: + import pandas as pd + criteria = list(self.criteria) + criterion_names = [c.name for c in criteria] + criterion_names.sort() + + matrix = { + alt.name: [alt.values[c] for c in criterion_names] + for alt in self.alternatives.all() + } + groups = { + group.name: [c.name for c in group.criteria.all()] + for group in self.groups.all() + } + + constraints = WeightConstraints( + group = self.group_weights, + group_order = self.group_order, + local = self.local_weights, + local_order = self.local_order, + ) + + return McdaConfig( + df = pd.DataFrame.from_dict(matrix, orient='index', columns=criterion_names), + directions = {c.name: c.direction for c in criteria}, + scenario = self.scenario, + method = self.method, + criteria = criterion_names, + weight_mode = self.weight_mode, + groups = groups, + thresholds = self.thresholds, + veto_type = self.veto_type, + veto_thresholds = self.veto_thresholds, + penalty_factor = self.penalty_factor, + sampling_mode = self.sampling_mode, + n_samples = self.n_samples, + alpha = self.alpha, + alpha_group = self.alpha_group, + alpha_local = self.alpha_local, + constraints = constraints + ) + +class DecisionMatrix(models.Model): + """One row with performance values per alternative.""" + session = models.ForeignKey(McdaSession, on_delete=models.CASCADE, + related_name="alternatives") + name = models.CharField(max_length=40) + values = models.JSONField() # {"price": 100, "quality": 0.8} + + class Meta: + unique_together = ['session', 'name'] + +class CritGroup(models.Model): + session = models.ForeignKey(McdaSession, on_delete=models.CASCADE, + related_name="groups") + name = models.CharField(max_length=40) + weight = models.CharField(max_length=10, null=True, validators=[validate_number_or_range]) # filled in during form wizard + + def __str__(self): + return self.name + +class Criterion(models.Model): + """One row per criterion. Partially populated from the request, + completed during the form wizard. + """ + session = models.ForeignKey(McdaSession, on_delete=models.CASCADE, + related_name="all_criteria") + used = models.BooleanField(default=True) + name = models.CharField(max_length=40) # e.g. "price" + group = models.ForeignKey(CritGroup, on_delete=models.SET_NULL, blank=True, null=True, related_name="criteria") + direction = models.CharField(max_length=10, blank=True, validators=[validate_direction], help_text="Enter 'min', 'max' or a target value") + weight = models.CharField(max_length=10, blank=True, validators=[validate_number_or_range]) # filled in during form wizard + + def __str__(self): + return f"{self.name} ({self.group})" + + class Meta: + # To have distinct criteria in the decision matrix, they need to be unique in a session + verbose_name_plural = "Criteria" + unique_together = ['name', 'session'] + + def clean(self): + if self.direction not in ["min", "max"]: + try: + float(self.direction) + except ValueError: + raise ValidationError("The direction must be either 'min', 'max', or a numeric target value.") + + 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) diff --git a/mysite/dss/plot.py b/mysite/dss/plot.py new file mode 100644 index 0000000..41a8074 --- /dev/null +++ b/mysite/dss/plot.py @@ -0,0 +1,470 @@ +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt + +# ============================================================ +# FIGURES FOR SHOWING THE RESULTS +# ============================================================ + +def deterministic_promethee_figures(results: pd.DataFrame, S: pd.DataFrame): + """Plot two figures for the deterministic PROMETHEE case + """ + plt.rcParams.update({ + "font.size": 20, + "axes.titlesize": 20, + "axes.labelsize": 20, + "xtick.labelsize": 20, + "ytick.labelsize": 20 + }) + + # ============================================================ + # FIGURE 1: NET FLOW SCORE BAR PLOT + # ============================================================ + + # Sort NFS values according to the final PROMETHEE ranking + sorted_nfs = results["NFS (phi)"].sort_values(ascending=False) + alternatives = sorted_nfs.index + nfs_values = sorted_nfs.values + + # Bar plot of the Net Flow Scores + plt.figure(figsize=(12, 8)) + bars = plt.bar(alternatives, nfs_values) + + # Add a horizontal line at zero to distinguish positive and negative NFS + plt.axhline(0, linewidth=1) + + # Place numerical labels just above the zero line + offset = max(abs(nfs_values)) * 0.03 + + for i, v in enumerate(nfs_values): + plt.text( + i, + 0 + offset, + f"{v:.3f}", + ha="center", + va="bottom", + fontsize=20 + ) + + plt.ylabel("Net Flow Score") + plt.xticks(rotation=45, ha="right") + plt.tight_layout() + + plt.savefig( + "Net_Flow_Score.pdf", format="pdf", bbox_inches="tight" + ) + plt.show() + + # ============================================================ + # FIGURE 2: PAIRWISE PREFERENCE MATRIX WITH FLOWS + # ============================================================ + """ + Create augmented matrix: + - original pairwise preference matrix S(a,b) + - FOR column, i.e. phi_plus + - AGAINST row, i.e. phi_minus + """ + S_aug = S.copy() + S_aug["FOR"] = results["FOR (phi+)"] + + against_row = pd.DataFrame( + [list(results["AGAINST (phi-)"]) + [np.nan]], + columns=S_aug.columns, + index=["AGAINST"] + ) + S_aug = pd.concat([S_aug, against_row]) + + # Convert to numerical array for plotting + data = S_aug.values.astype(float) + + fig, ax = plt.subplots(figsize=(12, 8)) + + # Heatmap of pairwise preference scores and flows + im = ax.imshow( + data, + aspect="auto", + cmap="Oranges", + vmin=0, + vmax=np.nanmax(data) + ) + + # Axis labels + ax.set_xticks(np.arange(len(S_aug.columns))) + ax.set_yticks(np.arange(len(S_aug.index))) + + ax.set_xticklabels(S_aug.columns, rotation=45, ha="right") + ax.set_yticklabels(S_aug.index) + + # Separation lines to visually separate: + # - the FOR column from the pairwise preference matrix; + # - the AGAINST row from the pairwise preference matrix. + n_rows, n_cols = data.shape + ax.axvline(x=n_cols - 1.5, color="black", linewidth=3) + ax.axhline(y=n_rows - 1.5, color="black", linewidth=3) + + # ------------------------------------------------------------ + # Add numerical values inside each cell + # ------------------------------------------------------------ + + max_value = np.nanmax(data) + + for i in range(data.shape[0]): + for j in range(data.shape[1]): + if not np.isnan(data[i, j]): + text_color = "white" if data[i, j] > max_value * 0.55 else "black" + + ax.text( + j, + i, + f"{data[i, j]:.3f}", + ha="center", + va="center", + fontsize=20, + color=text_color + ) + + plt.tight_layout() + # Save as PDF + plt.savefig( + "Pairwise_Preference_Matrix.pdf", format="pdf", bbox_inches="tight" + ) + plt.show() + +def performance_uncertainty_figures(perf_out: dict): + """Create two figures for the performance uncertainty case + """ + FIG_W = 12 + FIG_H = 8 + FONT = 20 + CELL_FONT = 20 + + plt.rcParams.update({ + "font.size": FONT, + "axes.titlesize": FONT, + "axes.labelsize": FONT, + "xtick.labelsize": FONT, + "ytick.labelsize": FONT + }) + + # ============================================================ + # FIGURE 1: RANK ACCEPTABILITY HEATMAP + # ============================================================ + + rank_accept = perf_out["rank_acceptability"].copy() + + # Rename columns for clearer plotting + rank_accept.columns = [ + f"Rank {i}" + for i in range(1, len(rank_accept.columns) + 1) + ] + + # Reorder alternatives according to expected rank + rank_accept = rank_accept.loc[perf_out["expected_rank"].index] + + fig, ax = plt.subplots(figsize=(FIG_W, FIG_H)) + + im = ax.imshow( + rank_accept.values, cmap="Blues", vmin=0, vmax=1 + ) + + ax.set_xticks(np.arange(rank_accept.shape[1])) + ax.set_xticklabels(rank_accept.columns, fontsize=FONT) + + ax.set_yticks(np.arange(rank_accept.shape[0])) + ax.set_yticklabels(rank_accept.index, fontsize=FONT) + + for i in range(rank_accept.shape[0]): + for j in range(rank_accept.shape[1]): + val = rank_accept.iloc[i, j] + ax.text( + j, + i, + f"{val:.2f}", + ha="center", + va="center", + fontsize=CELL_FONT, + color="white" if val > 0.4 else "black" + ) + + cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) + cbar.set_label("Probability", fontsize=FONT) + cbar.ax.tick_params(labelsize=FONT) + + for spine in ax.spines.values(): + spine.set_visible(False) + + plt.tight_layout() + plt.savefig( + "Performance_Uncertainty_Rank_Acceptability.pdf", + format="pdf", + bbox_inches="tight" + ) + plt.show() + + # ============================================================ + # FIGURE 2: PAIRWISE OUTRANKING PROBABILITIES + # WITH EXPECTED RANK + # ============================================================ + + outrank_prob = perf_out["outrank_probability"].copy() + expected_rank = perf_out["expected_rank"].copy() + + # Reorder alternatives according to expected rank + order = expected_rank.index + outrank_prob = outrank_prob.loc[order, order] + + # Add expected rank as last column + plot_df = outrank_prob.copy() + plot_df["Expected rank"] = expected_rank.loc[order] + + # Replace diagonal values with NaN + # because an alternative is not compared with itself + for a in order: + plot_df.loc[a, a] = np.nan + + data = plot_df.values.astype(float) + + fig, ax = plt.subplots(figsize=(FIG_W, FIG_H)) + + im = ax.imshow( + data, + aspect="auto", + cmap="Greens", + vmin=0, + vmax=np.nanmax(data) + ) + + ax.set_xticks(np.arange(len(plot_df.columns))) + ax.set_yticks(np.arange(len(plot_df.index))) + ax.set_xticklabels(plot_df.columns, rotation=45, ha="right") + ax.set_yticklabels(plot_df.index) + + # Separation line before Expected rank column + ax.axvline( + x=len(plot_df.columns) - 1.5, color="black", linewidth=3 + ) + + for i in range(data.shape[0]): + for j in range(data.shape[1]): + if not np.isnan(data[i, j]): + text_color = ( + "white" + if data[i, j] > np.nanmax(data) * 0.55 + else "black" + ) + ax.text( + j, + i, + f"{data[i, j]:.3f}", + ha="center", + va="center", + fontsize=CELL_FONT, + color=text_color + ) + + else: + ax.text( + j, i, "--", ha="center", va="center", fontsize=16, color="black" + ) + + plt.tight_layout() + plt.savefig( + "Performance_Uncertainty_Pairwise_Outranking_Expected_Rank.pdf", + format="pdf", + bbox_inches="tight" + ) + plt.show() + +def smaa_figures(smaa_out: dict): + """Create two figures for deterministic or uncertain SMAA results. + """ + # Common figure settings + FIG_W = 12 + FIG_H = 8 + FONT = 20 + CELL_FONT = 20 + + plt.rcParams.update({ + "font.size": FONT, + "axes.titlesize": FONT, + "axes.labelsize": FONT, + "xtick.labelsize": FONT, + "ytick.labelsize": FONT + }) + + # ============================================================ + # FIGURE 1: RANK ACCEPTABILITY HEATMAP + # ============================================================ + + rank_accept = smaa_out["rank_acceptability"].copy() + + # Rename columns for clearer plotting + rank_accept.columns = [ + f"Rank {i}" + for i in range(1, len(rank_accept.columns) + 1) + ] + + # Reorder alternatives according to expected rank + rank_accept = rank_accept.loc[smaa_out["expected_rank"].index] + + fig, ax = plt.subplots(figsize=(FIG_W, FIG_H)) + + im = ax.imshow( + rank_accept.values, cmap="Blues", vmin=0, vmax=1 + ) + + # Axis ticks + ax.set_xticks(np.arange(rank_accept.shape[1])) + ax.set_yticks(np.arange(rank_accept.shape[0])) + ax.set_xticklabels( + rank_accept.columns, + fontsize=FONT, + rotation=25, + ha="right" + ) + ax.set_yticklabels(rank_accept.index, fontsize=FONT) + + # Add values inside cells + for i in range(rank_accept.shape[0]): + for j in range(rank_accept.shape[1]): + val = rank_accept.iloc[i, j] + ax.text( + j, + i, + f"{val:.2f}", + ha="center", + va="center", + fontsize=CELL_FONT, + color="white" if val > 0.4 else "black" + ) + + # Colorbar + cbar = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) + cbar.set_label("Probability", fontsize=FONT) + cbar.ax.tick_params(labelsize=FONT) + + # Remove spines for a cleaner look + for spine in ax.spines.values(): + spine.set_visible(False) + + plt.tight_layout() + plt.savefig( + "Rank_Acceptability_Heatmap.pdf", + format="pdf", + bbox_inches="tight" + ) + plt.show() + + # ============================================================ + # FIGURE 2: PAIRWISE OUTRANKING PROBABILITIES + # WITH EXPECTED RANK AND MEAN NET FLOW + # ============================================================ + + outrank_prob = smaa_out["outrank_probability"].copy() + expected_rank = smaa_out["expected_rank"].copy() + mean_net_flows = smaa_out["mean_net_flows"].copy() + + # Order alternatives according to expected rank + order = expected_rank.index + outrank_prob = outrank_prob.loc[order, order] + + # Add expected rank and mean NFS as summary columns + plot_df = outrank_prob.copy() + plot_df["Expected rank"] = expected_rank.loc[order] + plot_df["Mean net flow"] = mean_net_flows.loc[order] + + # Replace diagonal values with NaN + # because an alternative is not compared with itself + for a in order: + plot_df.loc[a, a] = np.nan + + data = plot_df.values.astype(float) + n_alts = len(order) + + # ------------------------------------------------------------ + # COLOUR DATA + # ------------------------------------------------------------ + # + # Only pairwise outranking probabilities are represented + # through the green colour scale. + # + # Expected ranks and mean net flows use a neutral background + # because they are measured on different scales. + + colour_data = np.full_like(data, np.nan, dtype=float) + colour_data[:, :n_alts] = data[:, :n_alts] + + # Use a neutral colour for: + # - diagonal cells; + # - expected-rank column; + # - mean-net-flow column. + + cmap = plt.get_cmap("Greens").copy() + cmap.set_bad(color="#f2f2f2") + + fig, ax = plt.subplots(figsize=(FIG_W + 2, FIG_H)) + im = ax.imshow( + colour_data, + aspect="auto", + cmap=cmap, + vmin=0, + vmax=1, + ) + + # Axis ticks + ax.set_xticks(np.arange(len(plot_df.columns))) + ax.set_yticks(np.arange(len(plot_df.index))) + + ax.set_xticklabels(plot_df.columns, rotation=45, ha="right") + ax.set_yticklabels(plot_df.index) + + # Separation line before Expected rank column + ax.axvline(x=n_alts- 0.5, color="black", linewidth=3) + # Separate expected rank from mean net flow. + ax.axvline(x=n_alts + 0.5, color="black", linewidth=1) + + # ------------------------------------------------------------ + # NUMERICAL VALUES + # ------------------------------------------------------------ + + for i in range(data.shape[0]): + for j in range(data.shape[1]): + value = data[i, j] + if np.isnan(value): + ax.text( + j, + i, + "--", + ha="center", + va="center", + fontsize=16, + color="black" + ) + + else: + # White text is used only for high probabilities. + # Summary columns always use black text. + if j < n_alts and value > 0.55: + text_color = "white" + else: + text_color = "black" + ax.text( + j, + i, + f"{value:.3f}", + ha="center", + va="center", + fontsize=CELL_FONT, + color=text_color + ) + + for spine in ax.spines.values(): + spine.set_visible(False) + + plt.tight_layout() + plt.savefig( + "Pairwise_Outranking_Expected_Rank_Mean_Net_Flow.pdf", + format="pdf", + bbox_inches="tight" + ) + plt.show() diff --git a/mysite/dss/serializers.py b/mysite/dss/serializers.py new file mode 100644 index 0000000..eb772a6 --- /dev/null +++ b/mysite/dss/serializers.py @@ -0,0 +1,218 @@ +from rest_framework import serializers +from .models import Criterion, CritGroup, GroupOrder, LocalOrder + +# Serializers for input data (API requests) +#TODO: configure https://github.com/vbabiy/djangorestframework-camel-case + +class ConsumableSerializer(serializers.Serializer): + name = serializers.CharField() + flowRate = serializers.FloatField() + unit = serializers.CharField() + +class MaterialSerializer(serializers.Serializer): + name = serializers.CharField() + weight = serializers.FloatField() + +class KpiSerializer(serializers.Serializer): + name = serializers.CharField() + value = serializers.FloatField() + target = serializers.CharField() # Can be "min", "max", or a float value + +class ExperimentSerializer(serializers.Serializer): + experimentId = serializers.IntegerField() + weldLength = serializers.FloatField() + weldSpeed = serializers.FloatField() + country = serializers.CharField() + laserPowerkW = serializers.FloatField() + weldingStationPowerkW = serializers.FloatField() + consumables = ConsumableSerializer(many=True) + qualityParameters = KpiSerializer(many=True) + +class ExperimentComparisonSerializer(serializers.ListSerializer): + child = ExperimentSerializer() + + def validate(self, data): + """ + Validate that all experiments in the list have the same set of quality parameter names. + """ + if not data: + return data + + # Collect all quality parameter names from the first experiment + first_experiment = data[0] + first_kpis = { + qp['name'] for qp in first_experiment['qualityParameters'] + } + + # Check all other experiments + for i, experiment in enumerate(data[1:], start=1): + current_kpis = { + qp['name'] for qp in experiment['qualityParameters'] + } + if current_kpis != first_kpis: + raise serializers.ValidationError( + f"Experiment {experiment['experimentId']} has different " + "quality parameters than the first experiment. Expected: " + f"{first_kpis}, Got: {current_kpis}" + ) + + # Check ID uniqueness + ids = set([exp["experimentId"] for exp in data]) + if len(ids) != len(data): + raise serializers.ValidationError("Duplicate experiment IDs found") + + return data + +class WeldStationSerializer(ExperimentSerializer): + maintenanceCosts = serializers.FloatField() + cycleTime = serializers.FloatField() + scrapRate = serializers.FloatField(min_value=0, max_value=0.99) + recyclability = serializers.FloatField(min_value=0, max_value=1) + materials = MaterialSerializer(many=True) + productivity = KpiSerializer(many=True) + +class WeldStationComparisonSerializer(serializers.ListSerializer): + child = WeldStationSerializer() + +class McdaRequestSerializer(serializers.Serializer): + SCENARIO_CHOICES = ["uncertain", "deterministic"] + METHOD_CHOICES = ["promethee", "promethee_like"] + WEIGHT_CHOICES = ["flat", "group", "hierarchical"] + VETO_CHOICES = ["no", "soft", "hard"] + SAMPLING_CHOICES = ["random", "bounded", "ordered", "bounded_ordered"] + + decision_matrix = serializers.DictField() # {"alternative": {"criterion": value|(min, max)},} + directions = serializers.DictField() # {"criterion": "min"|"max"|value} + + scenario = serializers.ChoiceField(choices=SCENARIO_CHOICES) + 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}} + + 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" + + sampling_mode = serializers.ChoiceField(SAMPLING_CHOICES, required=False) # If weight_mode=="flat", this must be "random" + 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)]} + + def validate(self, data): + # Check that criteria in decision_matrix are in directions + matrix_criteria = set( + criterion + for values in data["decision_matrix"].values() + for criterion in values.keys() + ) + direction_criteria = set(data["directions"].keys()) + missing = matrix_criteria - direction_criteria + if missing: + raise serializers.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}") + 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 + if data["groups"]: + grouped_criteria = set() + for group in data["groups"].values(): + grouped_criteria.update(group) + missing = matrix_criteria - grouped_criteria + if missing: + raise serializers.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'") + elif data["weight_mode"] == "hierarchical": + if (not data["group_weights"]) or (data["groups"]): + serializers.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( + 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}" + ) + # 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"]: + 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"]: + if not isinstance(th, (float, int)): + raise serializers.ValidationError( + f"Expected threshold as number, received: {th}" + ) + # When veto is used, veto_thresholds must be specified + if data["veto_type"] != "no" and not data["veto_thresholds"]: + raise serializers.ValueError( + "veto_thresholds must be provided when veto is used." + ) + + 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/templates/dss/step_0.html b/mysite/dss/templates/dss/step_0.html new file mode 100644 index 0000000..8dc8f49 --- /dev/null +++ b/mysite/dss/templates/dss/step_0.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% block title %}Step 1 — KPI selection{% endblock %} + +{% block content %} +
Step 1 of 4
++ Select which KPIs should be used as decision criteria. +
+Step 2 of 4
++ These four choices drive which options appear in the next step. +
+Step 3 of 4
+
+ Weights accept a single value 0.5
+ or a range 0.2, 0.8.
+ All values must be positive.
+
Step 4 of 4
++ To assess the effect of uncertain parameters, sampling and uncertainty parameters + are configured here. +
+{{ hint }}
+{% endif %} diff --git a/mysite/dss/templates/partials/weight_field.html b/mysite/dss/templates/partials/weight_field.html new file mode 100644 index 0000000..b274061 --- /dev/null +++ b/mysite/dss/templates/partials/weight_field.html @@ -0,0 +1,24 @@ +{% comment %} + Partial: weight_field.html + Variables: + field — form field (WeightField instance) + label — display label string +{% endcomment %} +{{ field.errors|join:", " }}
+ {% endif %} +