From f3938eec7a8a1c57c606428c6b9e0110dfe92e1b Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Thu, 2 Jul 2026 16:51:13 +0200 Subject: [PATCH 01/15] New module for decision support, based on MCDA work by Federica mostly --- mysite/dss/__init__.py | 0 mysite/dss/admin.py | 3 + mysite/dss/apps.py | 5 + mysite/dss/mcda.py | 1291 +++++++++++++++++++++++++++++ mysite/dss/migrations/__init__.py | 0 mysite/dss/models.py | 3 + mysite/dss/serializers.py | 109 +++ mysite/dss/tests.py | 446 ++++++++++ mysite/dss/views.py | 58 ++ 9 files changed, 1915 insertions(+) create mode 100644 mysite/dss/__init__.py create mode 100644 mysite/dss/admin.py create mode 100644 mysite/dss/apps.py create mode 100644 mysite/dss/mcda.py create mode 100644 mysite/dss/migrations/__init__.py create mode 100644 mysite/dss/models.py create mode 100644 mysite/dss/serializers.py create mode 100644 mysite/dss/tests.py create mode 100644 mysite/dss/views.py 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/mcda.py b/mysite/dss/mcda.py new file mode 100644 index 0000000..782683d --- /dev/null +++ b/mysite/dss/mcda.py @@ -0,0 +1,1291 @@ +# Generated from: Promethee_Smaa.ipynb +# Converted at: 2026-06-30 + +import pandas as pd +import numpy as np + + +# ============================================================ +# 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 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 disadvantage_difference(x_a: float, x_b: float, direction: str) -> float: + """ + Returns a positive value when a is worse than b. + """ + if direction == "max": + return x_b - x_a + elif direction == "min": + return x_a - x_b + else: + raise ValueError(f"Unknown direction: {direction}") + + +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 = disadvantage_difference( + df_current.loc[a, c], + df_current.loc[b, c], + directions_dict[c] + ) + if disadvantage > v: + return True + return False + + +def run_performance_uncertainty( + df, + criteria_list, + directions_dict, + weights_dict, + n_samples=10000, + rng=None, + method="promethee_like", + thresholds_dict=None, + use_veto=False, + veto_thresholds_dict=None, + veto_type="hard", + penalty_factor=0.5 +): + """ + 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() + + alts = 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) + + 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(df, rng) + nfs = promethee_nfs( + df=current_df, + criteria_list=criteria_list, + directions_dict=directions_dict, + weights_dict=weights_dict, + method=method, + thresholds_dict=thresholds_dict, + use_veto=use_veto, + veto_thresholds_dict=veto_thresholds_dict, + veto_type=veto_type, + penalty_factor=penalty_factor + ) + + 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 + + rank_accept = rank_counts / n_samples + win_prob = win_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), + "mean_nfs": mean_nfs, + "n_samples": n_samples, + "simulations": sim_df + } + + +# ============================================================ +# ACTIVATE CONSTRAINTS ACCORDING TO SAMPLING MODE +# ============================================================ +""" +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. +""" + +if sampling_mode == "random": + active_group_lb = {g: 0.0 for g in groups} + active_group_ub = {g: 1.0 for g in groups} + active_group_order = [] + + active_local_lb = { + g: {c: 0.0 for c in crits} + for g, crits in groups.items() + } + active_local_ub = { + g: {c: 1.0 for c in crits} + for g, crits in groups.items() + } + active_local_order = {g: [] for g in groups} + +elif sampling_mode == "bounded": + active_group_lb = group_lb + active_group_ub = group_ub + active_group_order = [] + + active_local_lb = local_lb + active_local_ub = local_ub + active_local_order = {g: [] for g in groups} + +elif sampling_mode == "ordered": + active_group_lb = {g: 0.0 for g in groups} + active_group_ub = {g: 1.0 for g in groups} + active_group_order = group_order_constraints + + active_local_lb = { + g: {c: 0.0 for c in crits} + for g, crits in groups.items() + } + active_local_ub = { + g: {c: 1.0 for c in crits} + for g, crits in groups.items() + } + active_local_order = local_order_constraints + +else: + raise ValueError( + "sampling_mode must be either " + "'random', 'bounded', or 'ordered'" + ) + + +def sample_weights_dirichlet_constrained( + items, + lb_dict, + ub_dict, + order_cons=None, + max_tries=100000, + 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: + lb_i <= w_i <= ub_i + + 2) Optional ordinal constraints: + w_more >= w_less + + 3) Optional preference intensity constraints: + w_more >= intensity * w_less + + 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 + } + + lb_vec = np.array([lb_dict[x] for x in items], dtype=float) + ub_vec = np.array([ub_dict[x] for x in items], dtype=float) + + idx = {x: i for i, x in enumerate(items)} + order_cons = order_cons or [] + + k = len(items) + alpha_vec = np.full(k, alpha, dtype=float) + + for _ in range(max_tries): + w = rng.dirichlet(alpha_vec) + + if stats is not None: + stats[stats_key]["draws"] += 1 + + # Check lower and upper bounds + rejected = False + if np.any(w < lb_vec - 1e-12) or np.any(w > ub_vec + 1e-12): + rejected = True + + # Check ordinal and intensity constraints + if not rejected: + for cons in order_cons: + + if len(cons) == 2: + hi, lo = cons + intensity = 1.0 + + elif len(cons) == 3: + hi, lo, intensity = cons + + else: + raise ValueError( + "Each ordinal constraint must be either " + "(more_important, less_important) or " + "(more_important, less_important, intensity)." + ) + + if w[idx[hi]] + 1e-12 < intensity * w[idx[lo]]: + rejected = True + break + + if rejected: + if stats is not None: + stats[stats_key]["rejected"] += 1 + continue + + if stats is not None: + stats[stats_key]["accepted"] += 1 + + return {x: float(w[idx[x]]) for x in items} + + raise RuntimeError( + "Could not sample a feasible weight vector. " + "Relax bounds/constraints or increase max_tries." + ) + + +def sample_hierarchical_weights( + groups, + group_names, + + active_group_lb, + active_group_ub, + active_group_order, + + active_local_lb, + active_local_ub, + active_local_order, + + 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( + items=group_names, + lb_dict=active_group_lb, + ub_dict=active_group_ub, + order_cons=active_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( + items=crits, + lb_dict=active_local_lb[g], + ub_dict=active_local_ub[g], + order_cons=active_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( + criteria_list, + + groups, + group_names, + + weight_mode, + + active_group_lb, + active_group_ub, + active_group_order, + + active_local_lb, + active_local_ub, + active_local_order, + + lb_dict, + ub_dict, + order_cons, + + alpha=1.0, + alpha_group=1.0, + alpha_local=1.0, + + rng=None, + sampler_stats=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 weight_mode == "flat": + criterion_lb = {c: 0.0 for c in criteria_list} + criterion_ub = {c: 1.0 for c in criteria_list} + final_weights = sample_weights_dirichlet_constrained( + items=criteria_list, + lb_dict=criterion_lb, + ub_dict=criterion_ub, + order_cons=[], + alpha=alpha, + rng=rng, + stats=sampler_stats, + stats_key="flat_weights" + ) + return final_weights + + elif weight_mode == "group": + group_weights_sampled = sample_weights_dirichlet_constrained( + items=group_names, + lb_dict=active_group_lb, + ub_dict=active_group_ub, + order_cons=active_group_order, + alpha=alpha_group, + rng=rng, + stats=sampler_stats, + stats_key="group_weights" + ) + + final_weights = {} + for g, crits in groups.items(): + for c in crits: + final_weights[c] = group_weights_sampled[g] / len(crits) + return final_weights + + elif weight_mode == "hierarchical": + return sample_hierarchical_weights( + groups=groups, + group_names=group_names, + + active_group_lb=active_group_lb, + active_group_ub=active_group_ub, + active_group_order=active_group_order, + + active_local_lb=active_local_lb, + active_local_ub=active_local_ub, + active_local_order=active_local_order, + + alpha_group=alpha_group, + alpha_local=alpha_local, + + sampler_stats=sampler_stats, + rng=rng + ) + + else: + raise ValueError( + "weight_mode must be either " + "'flat', 'group', or 'hierarchical'" + ) + +# ============================================================ +# PROMETHEE EVALUATION ENGINE +# ============================================================ + +def promethee_nfs( + df, + criteria_list, + directions_dict, + weights_dict, + method="promethee_like", + thresholds_dict=None, + use_veto=False, + veto_thresholds_dict=None, + veto_type="hard", + penalty_factor=0.5 +): + """ + 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. + """ + alts = df.index.tolist() + S = pd.DataFrame(0.0, index=alts, columns=alts) + + if use_veto and veto_thresholds_dict is None: + raise ValueError("veto_thresholds_dict 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 criteria_list: + d = preference_difference( + df.loc[a, c], + df.loc[b, c], + directions_dict[c] + ) + + if method == "promethee_like": + q = thresholds_dict[c]["q"] + pref = promethee_like_preference(d, q) + + elif method == "promethee": + q = thresholds_dict[c]["q"] + p = thresholds_dict[c]["p"] + pref = promethee_linear_preference(d, q, p) + + else: + raise ValueError("method must be either 'promethee_like' or 'promethee'.") + + score += weights_dict[c] * pref + + # Apply optional veto / penalty after aggregation + if use_veto and veto_is_triggered( + a=a, + b=b, + df=df, + directions_dict=directions_dict, + veto_thresholds_dict=veto_thresholds_dict + ): + if veto_type == "hard": + score = 0.0 + + elif veto_type == "soft": + score *= 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 + +# ============================================================ +# SMAA MONTE CARLO SIMULATION +# ============================================================ + +def run_smaa( + df, + criteria_list, + directions_dict, + analysis_type="full_smaa", + n_samples=100000, + lb_dict=None, + ub_dict=None, + order_cons=None, + alpha=1.0, + rng=None, + method="promethee_like", + thresholds_dict=None, + use_veto=False, + veto_thresholds_dict=None, + veto_type="hard", + penalty_factor=0.5, + weight_mode="flat", + sampling_mode="random", + groups=None, + group_lb=None, + group_ub=None, + group_order_constraints=None, + local_lb=None, + local_ub=None, + local_order_constraints=None, + alpha_group=1.0, + alpha_local=1.0 +): + """ + 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 + + 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 weight_mode == "flat" and 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 = 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 criteria_list} + + nfs_samples = {a: [] for a in alts} + rank_samples = {a: [] for a in alts} + + accepted = 0 + + sampler_stats = {} + + for _ in range(n_samples): + # Generate one realization of uncertainty + if analysis_type == "smaa_weights": + current_df = df + elif analysis_type == "full_smaa": + current_df = sample_performance_matrix(df, rng) + else: + raise ValueError( + "analysis_type must be either " + "'smaa_weights' or 'full_smaa'" + ) + + # Sample one feasible weight vector + w = sample_smaa_weights( + criteria_list=criteria_list, + + groups=groups, + group_names=group_names, + + weight_mode=weight_mode, + + active_group_lb=active_group_lb, + active_group_ub=active_group_ub, + active_group_order=active_group_order, + + active_local_lb=active_local_lb, + active_local_ub=active_local_ub, + active_local_order=active_local_order, + + lb_dict=lb_dict, + ub_dict=ub_dict, + order_cons=order_cons, + + alpha=alpha, + alpha_group=alpha_group, + alpha_local=alpha_local, + + rng=rng, + sampler_stats=sampler_stats + ) + + for c in criteria_list: + weight_samples[c].append(w[c]) + + # Evaluate alternatives using PROMETHEE + nfs = promethee_nfs( + df=current_df, + criteria_list=criteria_list, + directions_dict=directions_dict, + weights_dict=w, + method=method, + thresholds_dict=thresholds_dict, + use_veto=use_veto, + veto_thresholds_dict=veto_thresholds_dict, + veto_type=veto_type, + penalty_factor=penalty_factor + ) + # 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() + + w_mean = pd.Series( + {c: float(np.mean(weight_samples[c])) for c in criteria_list} + ).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, + "n_samples": accepted, + "simulations": sim_df, + "sampler_stats": sampler_stats_df + } + + + +def mcda(df, directions, scenario, method, weight_mode="equal"): + # ============================================================ + # 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(df) + + # Select the actual analysis type. + if scenario == "deterministic": + if performance_uncertainty: + analysis_type = "performance_uncertainty" + else: + analysis_type = "deterministic" + + elif 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 + # ============================================================ + """ + This block is executed 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. + """ + + if analysis_type == "deterministic": + + criteria = df.columns.tolist() + alts = 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 criteria: + d = preference_difference( + df.loc[a, c], df.loc[b, c], directions[c] + ) + + if method == "promethee_like": + q = thresholds[c]["q"] + pref = promethee_like_preference(d, q) + elif method == "promethee": + q = thresholds[c]["q"] + p = thresholds[c]["p"] + pref = promethee_linear_preference(d, q, p) + else: + raise ValueError( + "method must be either 'promethee_like' or 'promethee'" + ) + score += weights[c] * pref + + if use_veto and veto_is_triggered(a, b): + if veto_type == "hard": + score = 0.0 + elif veto_type == "soft": + score *= 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) + + pd.set_option("display.precision", 6) + + print("\n--- DETERMINISTIC SCENARIO ---") + print("\nDecision matrix (numeric):") + print(df) + print("\nPairwise preference matrix S(a,b):") + print(S) + print("\nPROMETHEE flows and NFS:") + print(results) + print("\nRanking (best to worst):") + print(list(results.index)) + + # ============================================================ + # 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) + + perf_out = run_performance_uncertainty( + df=df, + criteria_list=criteria, + directions_dict=directions, + weights_dict=weights, + n_samples=10000, + rng=rng, + method=method, + thresholds_dict=thresholds, + use_veto=use_veto, + veto_thresholds_dict=veto_thresholds, + veto_type=veto_type, + penalty_factor=penalty_factor + ) + + print("\n--- PERFORMANCE UNCERTAINTY SCENARIO ---") + print(f"Samples used: {perf_out['n_samples']}") + + print("\nWinning probabilities (P[rank=1]):") + print(perf_out["win_probability"]) + + print("\nExpected rank (lower is better):") + print(perf_out["expected_rank"]) + + print("\nRank acceptability indices b_{i,r}:") + print(perf_out["rank_acceptability"]) + + print("\nMean NFS:") + print(perf_out["mean_nfs"]) + + + # ============================================================ + # FIXED CRITERION WEIGHTS + # ============================================================ + """ + 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 weight_mode == "equal": + weights = {c: 1 / len(criteria) for c in criteria} + + elif weight_mode == "group": + weights = {} + for g, crits in groups.items(): + wg = group_weights[g] + for c in crits: + weights[c] = wg / len(crits) + + elif weight_mode == "hierarchical": + weights = {} + for g, crits in groups.items(): + Wg = group_weights[g] + total_local = sum(local_weights[g].values()) + for c in crits: + w_local = local_weights[g][c] / total_local + weights[c] = Wg * w_local + + else: + raise ValueError( + "weight_mode must be either 'equal', 'group', or 'hierarchical'" + ) + + # ============================================================ + # 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 scenario == "uncertain": + smaa_out = run_smaa( + df=df, + criteria_list=criteria, + directions_dict=directions, + analysis_type=analysis_type, + n_samples=10000, + + # General SMAA settings + alpha=1.0, + rng=rng, + + # PROMETHEE settings + method=method, + thresholds_dict=thresholds, + + # Veto settings + use_veto=use_veto, + veto_thresholds_dict=veto_thresholds, + veto_type=veto_type, + penalty_factor=penalty_factor, + + # Weight structure settings + weight_mode=weight_mode, + sampling_mode=sampling_mode, + + # Group-level settings + groups=groups, + group_lb=group_lb, + group_ub=group_ub, + group_order_constraints=group_order_constraints, + + # Local criterion-level settings + local_lb=local_lb, + local_ub=local_ub, + local_order_constraints=local_order_constraints, + + # Hierarchical sampling parameters + alpha_group=1.0, + alpha_local=1.0 + ) + + print("\n--- SMAA RESULTS (Scenario 2) ---") + print(f"Samples used: {smaa_out['n_samples']}") + + print("\nWinning probabilities (P[rank=1]):") + print(smaa_out["win_probability"]) + + print("\nExpected rank (lower is better):") + print(smaa_out["expected_rank"]) + + print("\nRank acceptability indices b_{i,r}:") + print(smaa_out["rank_acceptability"]) + + print("\nMean sampled weights (barycenter):") + print(smaa_out["mean_weights"]) + + print("\nRejection sampling diagnostics:") + print(smaa_out["sampler_stats"]) + + print("\nPairwise outranking probabilities P(i outranks j) based on NFS:") + print(smaa_out["outrank_probability"]) 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..71a8362 --- /dev/null +++ b/mysite/dss/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/mysite/dss/serializers.py b/mysite/dss/serializers.py new file mode 100644 index 0000000..a6cdf16 --- /dev/null +++ b/mysite/dss/serializers.py @@ -0,0 +1,109 @@ +from rest_framework import serializers + +class McdaRequestSerializer(serializers.Serializer): + SCENARIO_CHOICES = ["uncertain", "deterministic"] + WEIGHT_CHOICES = ["equal", "group", "hierarchical"] #FIXME: seems there is another option: "flat" + METHOD_CHOICES = ["promethee", "promethee_like"] + VETO_CHOICES = ["no", "soft", "hard"] + SAMPLING_CHOICES = ["random", "bounded", "ordered"] + + decision_matrix = serializers.DictField() # {"alternative": {"criterion": value},} + directions = serializers.DictField() # {"criterion": "min|max|"} + + 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], required=False) # Relevant if scenario=="deterministic" + + groups = serializers.DictField(required=False) # {"group": [criteria]} + group_weights = serializers.DictField(required=False) # {"group": weight} + + thresholds = serializers.DictField(required=False) # Promethee parameters: {"criterion": (q, p)} or {"criterion": q}, where q=indifference, p=preference #TODO: tuple, requires change in Federica's code + veto_type = serializers.ChoiceField(choices=VETO_CHOICES, defaul="no") + veto_thesholds = serializers.DictField(required=False) # {"criterion": value} + penalty_factor = serializers.FloatField(required=False, default=0.5) # used if veto_type=="soft" + + sampling_mode = serializers.ChoiceField(SAMPLING_CHOICES, requred=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}" + ) + # 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 + +class IndicatorScoresSerializer(serializers.Serializer): + indicator = serializers.CharField() + score = serializers.FloatField() + +class ExperimentRankingSerializer(serializers.Serializer): + experiment_id = serializers.IntField() + rank = serializers.IntField() + score = serializers.IntField() + indicators = IndicatorScoresSerializer + +class McdaResponseSerializer(serializers.Serializer): + experiment_ranking = serializers.ListField(ExperimentRankingSerializer) diff --git a/mysite/dss/tests.py b/mysite/dss/tests.py new file mode 100644 index 0000000..3a258a6 --- /dev/null +++ b/mysite/dss/tests.py @@ -0,0 +1,446 @@ +import pandas as pd +from django.test import TestCase + +class McdaTest(TestCase): + """ + Class for testing an MCDA request, + including decision matrix and weighting preferences. + """ + # ============================================================ + # DECISION MATRIX + # ============================================================ + + df = pd.DataFrame( + data=[ + [9.3, 0.0, 0.0, 0.0, 0.0, 1, 0.551, 0.19108, 4.95, 0.0282, 0.00500, 0.000000439, 26.9, 1.91, 0.298], + [9.3, 0.0, 0.0, 0.0, 0.0, 2, 0.551, 0.19108, 4.63, 0.0265, 0.00501, 0.000000412, 26.5, 1.94, 0.397], + [0.0, 0.027, 0.2, 0.3, 3.3, 2, 0.396, 0.01072, 15.0, 0.1160, 0.01020, 0.000001270, 52.7, 3.34, 0.592], + [0.0, 0.027, 0.2, 0.3, 3.3, 3, 0.396, 0.01072, 8.43, 0.0807, 0.00756, 0.000000774, 17.8, 9.38, 0.297], + ], + index=["Steel RoW", "Steel RER", "AlLi RoW", "AlLi Canada"], + columns=[ + "Ni concentration (%)", + "Li concentration (%)", + "Mg concentration (%)", + "Ti concentration (%)", + "Cu concentration (%)", + "Operator", + "Recycled input (kg/kg)", + "Waste output (kg/kg)", + "Climate change (GWP100)", + "Acidification (AE)", + "Eutrophication Freshwater (P)", + "Particulate Matter (human health)", + "LandUse (soil quality index)", + "WaterUse (m³ world eq deprived)", + "Ionising radiation (kBq U-235 eq)", + ], + ) + + + # ============================================================ + # CRITERION ORIENTATION + # ============================================================ + + directions = { + "Ni concentration (%)": "min", + "Li concentration (%)": "min", + "Mg concentration (%)": "min", + "Ti concentration (%)": "min", + "Cu concentration (%)": "min", + "Operator": "max", + "Recycled input (kg/kg)": "max", + "Waste output (kg/kg)": "min", + "Climate change (GWP100)": "min", + "Acidification (AE)": "min", + "Eutrophication Freshwater (P)": "min", + "Particulate Matter (human health)": "min", + "LandUse (soil quality index)": "min", + "WaterUse (m³ world eq deprived)": "min", + "Ionising radiation (kBq U-235 eq)": "min", + } + criteria = list(directions.keys()) + + # ============================================================ + # ANALYSIS SCENARIO SELECTION + # ============================================================ + """ + The variable `scenario` controls whether the analysis uses: + 1) Fixed weights + scenario = "deterministic" + 2) Uncertain weights sampled through SMAA + scenario = "uncertain" + + The uncertainty on the performances is not selected manually. + It is detected automatically by checking whether the decision + matrix contains interval values, represented as tuples or lists: + (lower_bound, upper_bound) + + Therefore, the final analysis type is determined by combining: + - scenario + - presence/absence of uncertain performances in df + + This generates four analysis_type cases: + 1. deterministic: Fixed weights + deterministic performance values + 2. performance_uncertainty: Fixed weights + uncertain performance values + 3. smaa_weights: Uncertain weights + deterministic performance values + 4. full_smaa: Uncertain weights + uncertain performance values + """ + + scenario = "uncertain" + + # ============================================================ + # DETERMINISTIC WEIGHT MODE + # ============================================================ + """ + This parameter is used only when the analysis relies on fixed + criterion weights, namely in: + - deterministic + - performance_uncertainty + + Available options: + "equal": All final criteria receive the same weight. + "group": Group weights are fixed, and each group weight is distributed + equally among the criteria belonging to that group. + "hierarchical": Final criterion weights are obtained as: + final weight = group weight x local criterion weight + This allows criteria within a group to have different relative importance. + """ + + weight_mode = "equal" + + # ============================================================ + # METHOD SELECTION + # ============================================================ + """ + method = "promethee_like" + + If q = 0: Type I / usual criterion + If q > 0: Type II / U-shape criterion + + In both cases, preference is binary: + P(a,b) = 1 if d(a,b) > q + = 0 otherwise + + method = "promethee" + + If q = 0 and p > 0: Type III / V-shape criterion + If q > 0 and p > q: Type V / linear preference with indifference area + """ + + method = "promethee_like" + + # ============================================================ + # CRITERION GROUPS + # ============================================================ + + groups = { + "CRM": [ + "Ni concentration (%)", + "Li concentration (%)", + "Mg concentration (%)", + "Ti concentration (%)", + "Cu concentration (%)", + ], + "Circularity": [ + "Recycled input (kg/kg)", + "Waste output (kg/kg)", + ], + "Environmental": [ + "Climate change (GWP100)", + "Acidification (AE)", + "Eutrophication Freshwater (P)", + "Particulate Matter (human health)", + "LandUse (soil quality index)", + "WaterUse (m³ world eq deprived)", + "Ionising radiation (kBq U-235 eq)", + ], + "Manufacturer": [ + "Operator", + ] + } + + # ============================================================ + # CRITERION WEIGHTS + # ============================================================ + + group_weights = { + "CRM": 0.25, + "Circularity": 0.25, + "Environmental": 0.40, + "Manufacturer": 0.10 + } + + local_weights = { + "CRM":{ + "Ni concentration (%)": 0.30, + "Li concentration (%)": 0.20, + "Mg concentration (%)": 0.15, + "Ti concentration (%)": 0.15, + "Cu concentration (%)": 0.20 + }, + "Circularity":{ + "Recycled input (kg/kg)": 0.70, + "Waste output (kg/kg)": 0.30 + }, + "Environmental":{ + "Climate change (GWP100)": 0.30, + "Acidification (AE)": 0.10, + "Eutrophication Freshwater (P)": 0.10, + "Particulate Matter (human health)": 0.10, + "LandUse (soil quality index)": 0.10, + "WaterUse (m³ world eq deprived)": 0.20, + "Ionising radiation (kBq U-235 eq)": 0.10 + }, + "Manufacturer":{ + "Operator": 1.0 + } + } + + # ============================================================ + # PROMETHEE SETTINGS + # ============================================================ + + thresholds = { + "Ni concentration (%)": {"q": 0.0, "p": 2.0}, + "Li concentration (%)": {"q": 0.0, "p": 0.01}, + "Mg concentration (%)": {"q": 0.0, "p": 0.05}, + "Ti concentration (%)": {"q": 0.0, "p": 0.05}, + "Cu concentration (%)": {"q": 0.0, "p": 1.0}, + "Operator": {"q": 0.0, "p": 1.0}, + "Recycled input (kg/kg)": {"q": 0.0, "p": 0.10}, + "Waste output (kg/kg)": {"q": 0.0, "p": 0.05}, + "Climate change (GWP100)": {"q": 0.0, "p": 5.0}, + "Acidification (AE)": {"q": 0.0, "p": 0.05}, + "Eutrophication Freshwater (P)": {"q": 0.0, "p": 0.003}, + "Particulate Matter (human health)": {"q": 0.0, "p": 0.0000005}, + "LandUse (soil quality index)": {"q": 0.0, "p": 20.0}, + "WaterUse (m³ world eq deprived)": {"q": 0.0, "p": 3.0}, + "Ionising radiation (kBq U-235 eq)": {"q": 0.0, "p": 0.2}, + } + + # ============================================================ + # VETO SETTINGS + # ============================================================ + + use_veto = False + + # Veto thresholds: if alternative a is worse than b by more than v, + # then S(a,b) is penalized. + veto_thresholds = { + "Climate change (GWP100)": 8.0, + "WaterUse (m³ world eq deprived)": 5.0, + "Ni concentration (%)": 5.0, + "Cu concentration (%)": 2.0, + } + + # Type of veto: + # "hard" => set S(a,b) to 0 + # "soft" => multiply S(a,b) by a penalty factor + veto_type = "soft" + + penalty_factor = 0.5 + + + # ============================================================ + # WEIGHT UNCERTAINTY MODEL + # ============================================================ + """ + This section defines how criterion weights are generated in + the SMAA analysis. + + The model supports three alternative weighting structures: + + 1) flat + Final criterion weights are sampled directly. + No group structure is imposed. + w = (w_1, ..., w_m) + sum_j w_j = 1 + + This mode is currently allowed only with: + sampling_mode = "random" + + 2) group + Only group-level weights are sampled. + Each sampled group weight is then uniformly distributed + among the criteria belonging to that group. + w_j = W_g / |G_g| + where criterion j belongs to group g. + + 3) hierarchical + Both group-level weights and local within-group weights + are sampled. + w_j = W_g * w_{j|g} + This allows criteria in the same group to have different + local importance. + + ------------------------------------------------------------ + The variable `sampling_mode` defines which constraints are + activated during weight sampling: + + random: + no bounds and no ordinal constraints are imposed. + bounded: + lower and upper bounds are imposed, but no ordinal + constraints are imposed. + ordered: + ordinal constraints and preference intensities are imposed, + but specific lower/upper bounds are not imposed. + """ + + weight_mode = "flat" + sampling_mode = "random" + + # ============================================================ + # GROUP-LEVEL WEIGHT UNCERTAINTY + # ============================================================ + """ + Group-level weights represent the relative importance of the + main dimensions of the decision problem: + - CRM + - Circularity + - Environmental + - Manufacturer + + These weights are denoted as: + W_g + and must satisfy: + W_g >= 0 + sum_g W_g = 1 + + In the "bounded" sampling mode, lower and upper bounds are + imposed on each group weight. + In the "ordered" sampling mode, ordinal constraints are imposed. + These can also include preference intensities. + """ + # ============================================================ + + group_names = tuple(groups) + + group_lb = { + "CRM": 0.10, + "Circularity": 0.10, + "Environmental": 0.20, + "Manufacturer": 0.05 + } + + group_ub = { + "CRM": 0.40, + "Circularity": 0.40, + "Environmental": 0.60, + "Manufacturer": 0.25 + } + + group_order_constraints = [ + ("Environmental", "Manufacturer", 1.5), + ("CRM", "Manufacturer", 1.2), + ("Circularity", "Manufacturer", 1.0), + ] + + # ============================================================ + # LOCAL CRITERION-LEVEL WEIGHT UNCERTAINTY + # ============================================================ + """ + Local weights represent the importance of criteria within + each group. + + For a criterion j belonging to group g, the local weight is: + w_{j|g} + and must satisfy, within each group: + w_{j|g} >= 0 + sum_{j in G_g} w_{j|g} = 1 + + In the hierarchical model, the final global criterion weight is: + w_j = W_g * w_{j|g} + + where: + W_g = sampled group weight + w_{j|g} = sampled local weight of criterion j within group g + + In the "bounded" sampling mode, local lower and upper bounds + are imposed. + In the "ordered" sampling mode, local ordinal constraints and + preference intensities are imposed. + + A local ordinal constraint can be specified as: + ("A", "B") + meaning: + w_A >= w_B + or as: + ("A", "B", intensity) + meaning: + w_A >= intensity * w_B + """ + + local_lb = { + "CRM": { + "Ni concentration (%)": 0.10, + "Li concentration (%)": 0.05, + "Mg concentration (%)": 0.05, + "Ti concentration (%)": 0.05, + "Cu concentration (%)": 0.10, + }, + "Circularity": { + "Recycled input (kg/kg)": 0.40, + "Waste output (kg/kg)": 0.20, + }, + "Environmental": { + "Climate change (GWP100)": 0.15, + "Acidification (AE)": 0.05, + "Eutrophication Freshwater (P)": 0.05, + "Particulate Matter (human health)": 0.05, + "LandUse (soil quality index)": 0.05, + "WaterUse (m³ world eq deprived)": 0.10, + "Ionising radiation (kBq U-235 eq)": 0.05, + }, + "Manufacturer": { + "Operator": 1.00, + } + } + + local_ub = { + "CRM": { + "Ni concentration (%)": 0.40, + "Li concentration (%)": 0.30, + "Mg concentration (%)": 0.25, + "Ti concentration (%)": 0.25, + "Cu concentration (%)": 0.40, + }, + "Circularity": { + "Recycled input (kg/kg)": 0.80, + "Waste output (kg/kg)": 0.60, + }, + "Environmental": { + "Climate change (GWP100)": 0.35, + "Acidification (AE)": 0.20, + "Eutrophication Freshwater (P)": 0.20, + "Particulate Matter (human health)": 0.20, + "LandUse (soil quality index)": 0.20, + "WaterUse (m³ world eq deprived)": 0.30, + "Ionising radiation (kBq U-235 eq)": 0.20, + }, + "Manufacturer": { + "Operator": 1.00, + } + } + + local_order_constraints = { + "CRM": [ + ("Ni concentration (%)", "Li concentration (%)"), + ("Ni concentration (%)", "Mg concentration (%)"), + ("Cu concentration (%)", "Mg concentration (%)"), + ("Cu concentration (%)", "Ti concentration (%)"), + ], + "Circularity": [ + ("Recycled input (kg/kg)", "Waste output (kg/kg)"), + ], + "Environmental": [ + ("Climate change (GWP100)", "Acidification (AE)"), + ("Climate change (GWP100)", "Eutrophication Freshwater (P)"), + ("Climate change (GWP100)", "Particulate Matter (human health)"), + ("Climate change (GWP100)", "LandUse (soil quality index)"), + ("Climate change (GWP100)", "Ionising radiation (kBq U-235 eq)"), + ("WaterUse (m³ world eq deprived)", "Ionising radiation (kBq U-235 eq)"), + ], + "Manufacturer": [] + } diff --git a/mysite/dss/views.py b/mysite/dss/views.py new file mode 100644 index 0000000..9ea33f0 --- /dev/null +++ b/mysite/dss/views.py @@ -0,0 +1,58 @@ +import pandas as pd +from django.shortcuts import render +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status + +from .serializers import McdaRequestSerializer, McdaResponseSerializer +from .mcda import mcda + + +class McdaCalculationView(APIView): + def post(self, request): + request_serializer = McdaRequestSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + data = request_serializer.validated_data + + # Convert some raw data to read-to-use data types + try: + decision_matrix = pd.DataFrame(data["decision_matrix"]) + except BaseException as e: + return Response({"Issue with decision matrix": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + try: + result = mcda( + df=decision_matrix, + directions=data["directions"], + scenario=data["scenario"], + method=data["method"], + weight_mode=data["weight_mode"], + groups=data.get("groups"), + group_weight=data.get("group_weights"), + thresholds=data.get("thresholds"), + + # Veto settings + veto_type=data["veto_type"], + use_veto=(data["veto_type"]!="no"), + veto_thresholds=data.get("veto_thresholds"), + penalty_factor=data.get("penalty_factor"), + + # Weight structure settings (group-level and local) + sampling_mode=data.get("sampling_mode"), + group_lb=data.get("group_lb"), + group_ub=data.get("group_ub"), + group_order_constraints=data.get("group_order_constraints"), + local_lb=data.get("local_lb"), + local_ub=data.get("local_ub"), + local_order_constraints=data.get("local_order_constraints"), + ) + except ValueError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + response_data = { + "shape": data["shape"], + "operation": data["operation"], + "result": result, + } + response_serializer = McdaResponseSerializer(response_data) + return Response(response_serializer.data, status=status.HTTP_200_OK) From 155b350a9aed170a09b7c4ab206b840e0131aec6 Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Thu, 2 Jul 2026 17:07:31 +0200 Subject: [PATCH 02/15] Allow target values as MCDA preference direction --- mysite/dss/mcda.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/mysite/dss/mcda.py b/mysite/dss/mcda.py index 782683d..7e90359 100644 --- a/mysite/dss/mcda.py +++ b/mysite/dss/mcda.py @@ -53,7 +53,9 @@ def preference_difference(x_a: float, x_b: float, direction: str) -> float: For cost criteria: lower values are better, so difference = x_b - x_a """ - if direction == "max": + 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 @@ -107,18 +109,6 @@ def promethee_linear_preference(d: float, q: float, p: float) -> float: return (d - q) / (p - q) -def disadvantage_difference(x_a: float, x_b: float, direction: str) -> float: - """ - Returns a positive value when a is worse than b. - """ - if direction == "max": - return x_b - x_a - elif direction == "min": - return x_a - x_b - else: - raise ValueError(f"Unknown direction: {direction}") - - def veto_is_triggered( a, b, df_current, directions_dict, veto_thresholds_dict ): @@ -127,7 +117,8 @@ def veto_is_triggered( on at least one criterion with a veto threshold. """ for c, v in veto_thresholds_dict.items(): - disadvantage = disadvantage_difference( + # disadvantage is the inverse of preference + disadvantage = -preference_difference( df_current.loc[a, c], df_current.loc[b, c], directions_dict[c] From 991315e253d3fd83a9125c76735590e927997a31 Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Fri, 3 Jul 2026 10:17:46 +0200 Subject: [PATCH 03/15] Refactoring of MCDA code: Derive criteria and group_names instead of passing as parameter Create functions for deriving weights, active constraints, and deterministic promethee Adjusted structure of thresholds_dict --- mysite/dss/mcda.py | 436 ++++++++++++++++++-------------------- mysite/dss/serializers.py | 4 +- mysite/dss/tests.py | 4 +- 3 files changed, 210 insertions(+), 234 deletions(-) diff --git a/mysite/dss/mcda.py b/mysite/dss/mcda.py index 7e90359..187fe1d 100644 --- a/mysite/dss/mcda.py +++ b/mysite/dss/mcda.py @@ -130,7 +130,6 @@ def veto_is_triggered( def run_performance_uncertainty( df, - criteria_list, directions_dict, weights_dict, n_samples=10000, @@ -176,7 +175,6 @@ def run_performance_uncertainty( current_df = sample_performance_matrix(df, rng) nfs = promethee_nfs( df=current_df, - criteria_list=criteria_list, directions_dict=directions_dict, weights_dict=weights_dict, method=method, @@ -232,87 +230,198 @@ def run_performance_uncertainty( "n_samples": n_samples, "simulations": sim_df } + + +# ============================================================ +# FIXED CRITERION WEIGHTS +# ============================================================ +def get_fixed_weights(weight_mode, criteria, groups, group_weights, local_weights): + """ + 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 weight_mode == "flat": + weights = {c: 1 / len(criteria) for c in criteria} + + elif weight_mode == "group": + weights = {} + for g, crits in groups.items(): + wg = group_weights[g] + for c in crits: + weights[c] = wg / len(crits) + + elif weight_mode == "hierarchical": + weights = {} + for g, crits in groups.items(): + Wg = group_weights[g] + total_local = sum(local_weights[g].values()) + for c in crits: + w_local = local_weights[g][c] / total_local + weights[c] = Wg * w_local + + return weights + + +def deterministic_promethee(df, directions, method, thresholds, use_veto, veto_type, weights, penalty_factor): + """ + 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. + """ + criteria = df.columns.tolist() + alts = 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 criteria: + d = preference_difference( + df.loc[a, c], df.loc[b, c], directions[c] + ) + + if method == "promethee_like": + q = thresholds[c] + pref = promethee_like_preference(d, q) + elif method == "promethee": + q, p = thresholds[c] + pref = promethee_linear_preference(d, q, p) + else: + raise ValueError( + "method must be either 'promethee_like' or 'promethee'" + ) + score += weights[c] * pref + + if use_veto and veto_is_triggered(a, b): + if veto_type == "hard": + score = 0.0 + elif veto_type == "soft": + score *= 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 # ============================================================ # ACTIVATE CONSTRAINTS ACCORDING TO SAMPLING MODE # ============================================================ -""" -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. -""" - -if sampling_mode == "random": - active_group_lb = {g: 0.0 for g in groups} - active_group_ub = {g: 1.0 for g in groups} - active_group_order = [] - - active_local_lb = { - g: {c: 0.0 for c in crits} - for g, crits in groups.items() - } - active_local_ub = { - g: {c: 1.0 for c in crits} - for g, crits in groups.items() - } - active_local_order = {g: [] for g in groups} +def get_active_constraints( + sampling_mode, groups, group_lb, group_ub, group_order_constraints, local_lb, local_ub, local_order_constraints, + ): + """ + 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. + """ -elif sampling_mode == "bounded": - active_group_lb = group_lb - active_group_ub = group_ub - active_group_order = [] + if sampling_mode == "random": + active_group_lb = {g: 0.0 for g in groups} + active_group_ub = {g: 1.0 for g in groups} + active_group_order = [] - active_local_lb = local_lb - active_local_ub = local_ub - active_local_order = {g: [] for g in groups} + active_local_lb = { + g: {c: 0.0 for c in crits} + for g, crits in groups.items() + } + active_local_ub = { + g: {c: 1.0 for c in crits} + for g, crits in groups.items() + } + active_local_order = {g: [] for g in groups} -elif sampling_mode == "ordered": - active_group_lb = {g: 0.0 for g in groups} - active_group_ub = {g: 1.0 for g in groups} - active_group_order = group_order_constraints + elif sampling_mode == "bounded": + active_group_lb = group_lb + active_group_ub = group_ub + active_group_order = [] - active_local_lb = { - g: {c: 0.0 for c in crits} - for g, crits in groups.items() - } - active_local_ub = { - g: {c: 1.0 for c in crits} - for g, crits in groups.items() - } - active_local_order = local_order_constraints + active_local_lb = local_lb + active_local_ub = local_ub + active_local_order = {g: [] for g in groups} -else: - raise ValueError( - "sampling_mode must be either " - "'random', 'bounded', or 'ordered'" - ) + elif sampling_mode == "ordered": + active_group_lb = {g: 0.0 for g in groups} + active_group_ub = {g: 1.0 for g in groups} + active_group_order = group_order_constraints + + active_local_lb = { + g: {c: 0.0 for c in crits} + for g, crits in groups.items() + } + active_local_ub = { + g: {c: 1.0 for c in crits} + for g, crits in groups.items() + } + active_local_order = local_order_constraints + + return active_group_lb, active_group_ub, active_group_order, active_local_lb, active_local_ub, active_local_order def sample_weights_dirichlet_constrained( - items, lb_dict, ub_dict, order_cons=None, @@ -361,6 +470,7 @@ def sample_weights_dirichlet_constrained( "draws": 0, "accepted": 0, "rejected": 0 } + items = list(lb_dict.keys()) lb_vec = np.array([lb_dict[x] for x in items], dtype=float) ub_vec = np.array([ub_dict[x] for x in items], dtype=float) @@ -411,7 +521,7 @@ def sample_weights_dirichlet_constrained( if stats is not None: stats[stats_key]["accepted"] += 1 - return {x: float(w[idx[x]]) for x in items} + return {x: float(w[idx[x]]) for x in idx} raise RuntimeError( "Could not sample a feasible weight vector. " @@ -421,7 +531,6 @@ def sample_weights_dirichlet_constrained( def sample_hierarchical_weights( groups, - group_names, active_group_lb, active_group_ub, @@ -473,7 +582,7 @@ def sample_hierarchical_weights( rng = np.random.default_rng() group_weights_sampled = sample_weights_dirichlet_constrained( - items=group_names, + items=groups.keys(), lb_dict=active_group_lb, ub_dict=active_group_ub, order_cons=active_group_order, @@ -508,21 +617,15 @@ def sample_smaa_weights( criteria_list, groups, - group_names, - weight_mode, + sampling_mode, - active_group_lb, - active_group_ub, - active_group_order, - - active_local_lb, - active_local_ub, - active_local_order, - - lb_dict, - ub_dict, - order_cons, + group_lb, + group_ub, + group_order_constraints, + local_lb, + local_ub, + local_order_constraints, alpha=1.0, alpha_group=1.0, @@ -571,6 +674,8 @@ def sample_smaa_weights( if rng is None: rng = np.random.default_rng() + active_group_lb, active_group_ub, active_group_order, active_local_lb, active_local_ub, active_local_order = get_active_constraints(sampling_mode, groups, group_lb, group_ub, group_order_constraints, local_lb, local_ub, local_order_constraints) + if weight_mode == "flat": criterion_lb = {c: 0.0 for c in criteria_list} criterion_ub = {c: 1.0 for c in criteria_list} @@ -588,7 +693,6 @@ def sample_smaa_weights( elif weight_mode == "group": group_weights_sampled = sample_weights_dirichlet_constrained( - items=group_names, lb_dict=active_group_lb, ub_dict=active_group_ub, order_cons=active_group_order, @@ -607,7 +711,6 @@ def sample_smaa_weights( elif weight_mode == "hierarchical": return sample_hierarchical_weights( groups=groups, - group_names=group_names, active_group_lb=active_group_lb, active_group_ub=active_group_ub, @@ -636,7 +739,6 @@ def sample_smaa_weights( def promethee_nfs( df, - criteria_list, directions_dict, weights_dict, method="promethee_like", @@ -698,7 +800,7 @@ def promethee_nfs( score = 0.0 - for c in criteria_list: + for c in df.columns: d = preference_difference( df.loc[a, c], df.loc[b, c], @@ -706,12 +808,11 @@ def promethee_nfs( ) if method == "promethee_like": - q = thresholds_dict[c]["q"] + q = thresholds_dict[c] pref = promethee_like_preference(d, q) elif method == "promethee": - q = thresholds_dict[c]["q"] - p = thresholds_dict[c]["p"] + q, p = thresholds_dict[c] pref = promethee_linear_preference(d, q, p) else: @@ -750,13 +851,9 @@ def promethee_nfs( def run_smaa( df, - criteria_list, directions_dict, analysis_type="full_smaa", n_samples=100000, - lb_dict=None, - ub_dict=None, - order_cons=None, alpha=1.0, rng=None, method="promethee_like", @@ -821,6 +918,7 @@ def run_smaa( dict Dictionary containing SMAA outputs and diagnostic information. """ + criteria_list = df.columns.tolist() if rng is None: rng = np.random.default_rng() @@ -850,9 +948,7 @@ def run_smaa( nfs_samples = {a: [] for a in alts} rank_samples = {a: [] for a in alts} - accepted = 0 - sampler_stats = {} for _ in range(n_samples): @@ -872,21 +968,15 @@ def run_smaa( criteria_list=criteria_list, groups=groups, - group_names=group_names, - weight_mode=weight_mode, - active_group_lb=active_group_lb, - active_group_ub=active_group_ub, - active_group_order=active_group_order, - - active_local_lb=active_local_lb, - active_local_ub=active_local_ub, - active_local_order=active_local_order, + group_lb=group_lb, + group_ub=group_ub, + group_order_constraints=group_order_constraints, - lb_dict=lb_dict, - ub_dict=ub_dict, - order_cons=order_cons, + local_lb=local_lb, + local_ub=local_ub, + local_order_constraints=local_order_constraints, alpha=alpha, alpha_group=alpha_group, @@ -985,8 +1075,7 @@ def run_smaa( } - -def mcda(df, directions, scenario, method, weight_mode="equal"): +def mcda(df, directions, scenario, method, weight_mode, groups, group_weights, local_weights, thresholds, veto_type, veto_thresholds, penalty_factor, sampling_mode, group_lb, group_ub, group_order_constraints, local_lb, local_ub, local_order_constraints): # ============================================================ # UNCERTAINTY SETTINGS # ============================================================ @@ -994,9 +1083,11 @@ def mcda(df, directions, scenario, method, weight_mode="equal"): # 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(df) + use_veto = (veto_type!="no") # Select the actual analysis type. if scenario == "deterministic": + weights = get_fixed_weights(weight_mode, list(directions.keys()), groups, group_weights, local_weights) if performance_uncertainty: analysis_type = "performance_uncertainty" else: @@ -1016,82 +1107,16 @@ def mcda(df, directions, scenario, method, weight_mode="equal"): # ============================================================ # CASE 1: DETERMINISTIC PROMETHEE ANALYSIS # ============================================================ - """ - This block is executed 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. - """ if analysis_type == "deterministic": - - criteria = df.columns.tolist() - alts = 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 criteria: - d = preference_difference( - df.loc[a, c], df.loc[b, c], directions[c] - ) - - if method == "promethee_like": - q = thresholds[c]["q"] - pref = promethee_like_preference(d, q) - elif method == "promethee": - q = thresholds[c]["q"] - p = thresholds[c]["p"] - pref = promethee_linear_preference(d, q, p) - else: - raise ValueError( - "method must be either 'promethee_like' or 'promethee'" - ) - score += weights[c] * pref - - if use_veto and veto_is_triggered(a, b): - if veto_type == "hard": - score = 0.0 - elif veto_type == "soft": - score *= 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) + results, pairwise = deterministic_promethee(df, directions, method, thresholds, use_veto, veto_type, weights, penalty_factor) pd.set_option("display.precision", 6) - print("\n--- DETERMINISTIC SCENARIO ---") print("\nDecision matrix (numeric):") print(df) print("\nPairwise preference matrix S(a,b):") - print(S) + print(pairwise) print("\nPROMETHEE flows and NFS:") print(results) print("\nRanking (best to worst):") @@ -1119,7 +1144,6 @@ def mcda(df, directions, scenario, method, weight_mode="equal"): perf_out = run_performance_uncertainty( df=df, - criteria_list=criteria, directions_dict=directions, weights_dict=weights, n_samples=10000, @@ -1147,53 +1171,6 @@ def mcda(df, directions, scenario, method, weight_mode="equal"): print("\nMean NFS:") print(perf_out["mean_nfs"]) - - # ============================================================ - # FIXED CRITERION WEIGHTS - # ============================================================ - """ - 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 weight_mode == "equal": - weights = {c: 1 / len(criteria) for c in criteria} - - elif weight_mode == "group": - weights = {} - for g, crits in groups.items(): - wg = group_weights[g] - for c in crits: - weights[c] = wg / len(crits) - - elif weight_mode == "hierarchical": - weights = {} - for g, crits in groups.items(): - Wg = group_weights[g] - total_local = sum(local_weights[g].values()) - for c in crits: - w_local = local_weights[g][c] / total_local - weights[c] = Wg * w_local - - else: - raise ValueError( - "weight_mode must be either 'equal', 'group', or 'hierarchical'" - ) - # ============================================================ # UNCERTAIN SCENARIO EXECUTION # ============================================================ @@ -1221,7 +1198,6 @@ def mcda(df, directions, scenario, method, weight_mode="equal"): if scenario == "uncertain": smaa_out = run_smaa( df=df, - criteria_list=criteria, directions_dict=directions, analysis_type=analysis_type, n_samples=10000, diff --git a/mysite/dss/serializers.py b/mysite/dss/serializers.py index a6cdf16..17237fe 100644 --- a/mysite/dss/serializers.py +++ b/mysite/dss/serializers.py @@ -2,7 +2,7 @@ class McdaRequestSerializer(serializers.Serializer): SCENARIO_CHOICES = ["uncertain", "deterministic"] - WEIGHT_CHOICES = ["equal", "group", "hierarchical"] #FIXME: seems there is another option: "flat" + WEIGHT_CHOICES = ["flat", "group", "hierarchical"] METHOD_CHOICES = ["promethee", "promethee_like"] VETO_CHOICES = ["no", "soft", "hard"] SAMPLING_CHOICES = ["random", "bounded", "ordered"] @@ -17,7 +17,7 @@ class McdaRequestSerializer(serializers.Serializer): groups = serializers.DictField(required=False) # {"group": [criteria]} group_weights = serializers.DictField(required=False) # {"group": weight} - thresholds = serializers.DictField(required=False) # Promethee parameters: {"criterion": (q, p)} or {"criterion": q}, where q=indifference, p=preference #TODO: tuple, requires change in Federica's code + 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, defaul="no") veto_thesholds = serializers.DictField(required=False) # {"criterion": value} penalty_factor = serializers.FloatField(required=False, default=0.5) # used if veto_type=="soft" diff --git a/mysite/dss/tests.py b/mysite/dss/tests.py index 3a258a6..bc433a5 100644 --- a/mysite/dss/tests.py +++ b/mysite/dss/tests.py @@ -99,7 +99,7 @@ class McdaTest(TestCase): - performance_uncertainty Available options: - "equal": All final criteria receive the same weight. + "flat": All final criteria receive the same weight. "group": Group weights are fixed, and each group weight is distributed equally among the criteria belonging to that group. "hierarchical": Final criterion weights are obtained as: @@ -107,7 +107,7 @@ class McdaTest(TestCase): This allows criteria within a group to have different relative importance. """ - weight_mode = "equal" + weight_mode = "flat" # ============================================================ # METHOD SELECTION From 18ce58b34f77eaeaf5b3a7a8d8f78524ac3d3d5b Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Fri, 3 Jul 2026 15:12:00 +0200 Subject: [PATCH 04/15] Updates provided by Federica: figures + fixes --- mysite/dss/mcda.py | 46 +++- mysite/dss/plot.py | 441 ++++++++++++++++++++++++++++++++++++++ mysite/dss/serializers.py | 2 +- mysite/dss/tests.py | 3 + 4 files changed, 485 insertions(+), 7 deletions(-) create mode 100644 mysite/dss/plot.py diff --git a/mysite/dss/mcda.py b/mysite/dss/mcda.py index 187fe1d..bc262a9 100644 --- a/mysite/dss/mcda.py +++ b/mysite/dss/mcda.py @@ -3,6 +3,7 @@ import pandas as pd import numpy as np +from . import plot # ============================================================ @@ -165,6 +166,9 @@ def run_performance_uncertainty( 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) @@ -197,8 +201,14 @@ def run_performance_uncertainty( 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) @@ -226,6 +236,7 @@ def run_performance_uncertainty( "rank_acceptability": rank_accept, "expected_rank": exp_rank, "win_probability": win_prob.sort_values(ascending=False), + "outrank_probability": outrank_prob, "mean_nfs": mean_nfs, "n_samples": n_samples, "simulations": sim_df @@ -277,7 +288,7 @@ def get_fixed_weights(weight_mode, criteria, groups, group_weights, local_weight return weights -def deterministic_promethee(df, directions, method, thresholds, use_veto, veto_type, weights, penalty_factor): +def deterministic_promethee(df, directions, method, thresholds, use_veto, veto_type, veto_thresholds, weights, penalty_factor): """ This function is applicable when: scenario = "deterministic" @@ -317,7 +328,9 @@ def deterministic_promethee(df, directions, method, thresholds, use_veto, veto_t ) score += weights[c] * pref - if use_veto and veto_is_triggered(a, b): + if use_veto and veto_is_triggered( + a, b, df, directions, veto_thresholds + ): if veto_type == "hard": score = 0.0 elif veto_type == "soft": @@ -342,7 +355,7 @@ def deterministic_promethee(df, directions, method, thresholds, use_veto, veto_t "AGAINST (phi-)": phi_minus, "NFS (phi)": nfs }).sort_values("NFS (phi)", ascending=False) - return results, S + return results, {"S": S, "phi_plus": phi_plus, "phi_minus": phi_minus} # ============================================================ @@ -418,6 +431,21 @@ def get_active_constraints( } active_local_order = local_order_constraints + elif sampling_mode == "bounded_ordered": + active_group_lb = group_lb + active_group_ub = group_ub + active_group_order = group_order_constraints + + active_local_lb = local_lb + active_local_ub = local_ub + active_local_order = local_order_constraints + + else: + raise ValueError( + "sampling_mode must be either " + "'random', 'bounded', 'ordered', or 'bounded_ordered'" + ) + return active_group_lb, active_group_ub, active_group_order, active_local_lb, active_local_ub, active_local_order @@ -1109,19 +1137,21 @@ def mcda(df, directions, scenario, method, weight_mode, groups, group_weights, l # ============================================================ if analysis_type == "deterministic": - results, pairwise = deterministic_promethee(df, directions, method, thresholds, use_veto, veto_type, weights, penalty_factor) + results, intermediates = deterministic_promethee(df, directions, method, thresholds, use_veto, veto_type, weights, penalty_factor) pd.set_option("display.precision", 6) print("\n--- DETERMINISTIC SCENARIO ---") print("\nDecision matrix (numeric):") print(df) print("\nPairwise preference matrix S(a,b):") - print(pairwise) + print(intermediates["S"]) print("\nPROMETHEE flows and NFS:") print(results) print("\nRanking (best to worst):") print(list(results.index)) + plot.deterministic_promethee_figures(**intermediates) + # ============================================================ # CASE 2: PERFORMANCE UNCERTAINTY ANALYSIS # ============================================================ @@ -1170,6 +1200,8 @@ def mcda(df, directions, scenario, method, weight_mode, groups, group_weights, l print("\nMean NFS:") print(perf_out["mean_nfs"]) + + plot.performance_uncertainty_figures(perf_out) # ============================================================ # UNCERTAIN SCENARIO EXECUTION @@ -1236,7 +1268,7 @@ def mcda(df, directions, scenario, method, weight_mode, groups, group_weights, l alpha_local=1.0 ) - print("\n--- SMAA RESULTS (Scenario 2) ---") + print("\n--- SMAA SCENARIO ---") print(f"Samples used: {smaa_out['n_samples']}") print("\nWinning probabilities (P[rank=1]):") @@ -1256,3 +1288,5 @@ def mcda(df, directions, scenario, method, weight_mode, groups, group_weights, l print("\nPairwise outranking probabilities P(i outranks j) based on NFS:") print(smaa_out["outrank_probability"]) + + plot.smaa_figures(smaa_out) diff --git a/mysite/dss/plot.py b/mysite/dss/plot.py new file mode 100644 index 0000000..50c196a --- /dev/null +++ b/mysite/dss/plot.py @@ -0,0 +1,441 @@ +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt + +# ============================================================ +# FIGURES FOR SHOWING THE RESULTS +# ============================================================ + +def deterministic_promethee_figures(S: pd.DataFrame, phi_plus: pd.Series, phi_minus: pd.Series): + """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 + nfs = phi_plus - phi_minus + sorted_nfs = nfs.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"] = phi_plus + + against_row = pd.DataFrame( + [list(phi_minus) + [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 + # ============================================================ + + outrank_prob = smaa_out["outrank_probability"].copy() + expected_rank = smaa_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) + ) + + # 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=len(plot_df.columns) - 1.5, color="black", linewidth=3 + ) + + # Add values inside cells + 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( + "Pairwise_Outranking_Expected_Rank.pdf", + format="pdf", + bbox_inches="tight" + ) + plt.show() diff --git a/mysite/dss/serializers.py b/mysite/dss/serializers.py index 17237fe..630fc45 100644 --- a/mysite/dss/serializers.py +++ b/mysite/dss/serializers.py @@ -5,7 +5,7 @@ class McdaRequestSerializer(serializers.Serializer): WEIGHT_CHOICES = ["flat", "group", "hierarchical"] METHOD_CHOICES = ["promethee", "promethee_like"] VETO_CHOICES = ["no", "soft", "hard"] - SAMPLING_CHOICES = ["random", "bounded", "ordered"] + SAMPLING_CHOICES = ["random", "bounded", "ordered", "bounded_ordered"] decision_matrix = serializers.DictField() # {"alternative": {"criterion": value},} directions = serializers.DictField() # {"criterion": "min|max|"} diff --git a/mysite/dss/tests.py b/mysite/dss/tests.py index bc433a5..cc68103 100644 --- a/mysite/dss/tests.py +++ b/mysite/dss/tests.py @@ -286,6 +286,9 @@ class McdaTest(TestCase): ordered: ordinal constraints and preference intensities are imposed, but specific lower/upper bounds are not imposed. + bounded_ordered: + lower/upper bounds and ordinal/intensity constraints are + imposed simultaneously. """ weight_mode = "flat" From 1fa3032dccf4d6ecee8688af55ad884e4f84cb0d Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Fri, 3 Jul 2026 18:48:23 +0200 Subject: [PATCH 05/15] Introduce dataclass McdaConfig to streamline parameter handling + some bug fixes in serializers.py --- mysite/dss/mcda.py | 488 ++++++++++++++------------------------ mysite/dss/serializers.py | 17 +- mysite/dss/views.py | 41 ++-- 3 files changed, 214 insertions(+), 332 deletions(-) diff --git a/mysite/dss/mcda.py b/mysite/dss/mcda.py index bc262a9..fc9d524 100644 --- a/mysite/dss/mcda.py +++ b/mysite/dss/mcda.py @@ -1,9 +1,63 @@ # Generated from: Promethee_Smaa.ipynb # Converted at: 2026-06-30 -import pandas as pd +from dataclasses import dataclass import numpy as np +import pandas as pd +from typing import Literal, Optional + from . import plot +from .serializers import McdaRequestSerializer + +# Aliases for reused data structures +Criteria = dict[str, float | tuple[float, float]] +Bounds = dict[str, float] +OrderConstraint = list[tuple[str, str, float]] + +@dataclass(slots=True) +class WeightConstraints: + """ + Class for storing weight costraints in terms of + upper and lower bounds for (groups of) criteria. + """ + group_lb: Optional[Bounds] + group_ub: Optional[Bounds] + group_order: Optional[OrderConstraint] + local_lb: Optional[dict[str, Bounds]] + local_ub: Optional[dict[str, Bounds]] + local_order: Optional[dict[str, OrderConstraint]] + +@dataclass(slots=True) +class McdaConfig: + # General + df = pd.DataFrame + # decision_matrix: dict[str, Criteria] + directions: dict[str, Literal["min", "max"] | float] + scenario: str + method: str + criteria: Optional[list[str]] + + # Weighing and grouping + weight_mode: str = McdaRequestSerializer.WEIGHT_CHOICES[0] + groups: Optional[dict[str, list[str]]] + group_weights: Optional[dict[str, float]] + local_weights: Optional[dict[str, float]] + + # PROMETHEE parameters + thresholds: Optional[Criteria] + veto_type: str = "no" + veto_thresholds: Optional[Bounds] + penalty_factor: float = 0.5 + + # Sampling + sampling_mode: Optional[str] + n_samples: int = 10000 + alpha: float = 1.0 + alpha_group: float = 1.0 + alpha_local: float = 1.0 + + # Weight constraints + constraints: Optional[WeightConstraints] # ============================================================ @@ -129,19 +183,7 @@ def veto_is_triggered( return False -def run_performance_uncertainty( - df, - directions_dict, - weights_dict, - n_samples=10000, - rng=None, - method="promethee_like", - thresholds_dict=None, - use_veto=False, - veto_thresholds_dict=None, - veto_type="hard", - penalty_factor=0.5 -): +def run_performance_uncertainty(config: McdaConfig, rng=None): """ Run a Monte Carlo analysis with uncertainty only on alternative performances. @@ -157,7 +199,8 @@ def run_performance_uncertainty( if rng is None: rng = np.random.default_rng() - alts = df.index.tolist() + n_samples = config.n_samples + alts = config.df.index.tolist() n_alts = len(alts) rank_counts = pd.DataFrame( @@ -176,18 +219,8 @@ def run_performance_uncertainty( rank_samples = {a: [] for a in alts} for s in range(n_samples): - current_df = sample_performance_matrix(df, rng) - nfs = promethee_nfs( - df=current_df, - directions_dict=directions_dict, - weights_dict=weights_dict, - method=method, - thresholds_dict=thresholds_dict, - use_veto=use_veto, - veto_thresholds_dict=veto_thresholds_dict, - veto_type=veto_type, - penalty_factor=penalty_factor - ) + 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)} @@ -238,7 +271,6 @@ def run_performance_uncertainty( "win_probability": win_prob.sort_values(ascending=False), "outrank_probability": outrank_prob, "mean_nfs": mean_nfs, - "n_samples": n_samples, "simulations": sim_df } @@ -246,7 +278,7 @@ def run_performance_uncertainty( # ============================================================ # FIXED CRITERION WEIGHTS # ============================================================ -def get_fixed_weights(weight_mode, criteria, groups, group_weights, local_weights): +def set_fixed_weights(config: McdaConfig): """ The dictionary `weights` contains the final weight assigned to each criterion. @@ -266,29 +298,29 @@ def get_fixed_weights(weight_mode, criteria, groups, group_weights, local_weight where local weights are normalized within each group. """ - if weight_mode == "flat": - weights = {c: 1 / len(criteria) for c in criteria} + if config.weight_mode == "flat": + weights = {c: 1 / len(config.criteria) for c in config.criteria} - elif weight_mode == "group": + elif config.weight_mode == "group": weights = {} - for g, crits in groups.items(): - wg = group_weights[g] + for g, crits in config.groups.items(): + wg = config.group_weights[g] for c in crits: weights[c] = wg / len(crits) - elif weight_mode == "hierarchical": + elif config.weight_mode == "hierarchical": weights = {} - for g, crits in groups.items(): - Wg = group_weights[g] - total_local = sum(local_weights[g].values()) + for g, crits in config.groups.items(): + Wg = config.group_weights[g] + total_local = sum(config.local_weights[g].values()) for c in crits: - w_local = local_weights[g][c] / total_local + w_local = config.local_weights[g][c] / total_local weights[c] = Wg * w_local - return weights + config.weights = weights -def deterministic_promethee(df, directions, method, thresholds, use_veto, veto_type, veto_thresholds, weights, penalty_factor): +def deterministic_promethee(config: McdaConfig): """ This function is applicable when: scenario = "deterministic" @@ -300,8 +332,7 @@ def deterministic_promethee(df, directions, method, thresholds, use_veto, veto_t - one PROMETHEE evaluation is performed; - the final output is a deterministic ranking. """ - criteria = df.columns.tolist() - alts = df.index.tolist() + alts = config.df.index.tolist() S = pd.DataFrame(0.0, index=alts, columns=alts) for a in alts: @@ -311,30 +342,30 @@ def deterministic_promethee(df, directions, method, thresholds, use_veto, veto_t score = 0.0 - for c in criteria: + for c in config.criteria: d = preference_difference( - df.loc[a, c], df.loc[b, c], directions[c] + config.df.loc[a, c], config.df.loc[b, c], config.directions[c] ) - if method == "promethee_like": - q = thresholds[c] + if config.method == "promethee_like": + q = config.thresholds[c] pref = promethee_like_preference(d, q) - elif method == "promethee": - q, p = thresholds[c] + elif config.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 += weights[c] * pref + score += config.weights[c] * pref - if use_veto and veto_is_triggered( - a, b, df, directions, veto_thresholds + if config.use_veto and veto_is_triggered( + a, b, config.df, config.directions, config.veto_thresholds ): - if veto_type == "hard": + if config.veto_type == "hard": score = 0.0 - elif veto_type == "soft": - score *= penalty_factor + elif config.veto_type == "soft": + score *= config.penalty_factor else: raise ValueError( "veto_type must be either 'hard' or 'soft'" @@ -355,15 +386,13 @@ def deterministic_promethee(df, directions, method, thresholds, use_veto, veto_t "AGAINST (phi-)": phi_minus, "NFS (phi)": nfs }).sort_values("NFS (phi)", ascending=False) - return results, {"S": S, "phi_plus": phi_plus, "phi_minus": phi_minus} + return results, S # ============================================================ # ACTIVATE CONSTRAINTS ACCORDING TO SAMPLING MODE # ============================================================ -def get_active_constraints( - sampling_mode, groups, group_lb, group_ub, group_order_constraints, local_lb, local_ub, local_order_constraints, - ): +def get_active_constraints(config: McdaConfig) -> WeightConstraints: """ This block translates the selected sampling_mode into the actual constraints used by the rejection sampler. @@ -391,54 +420,46 @@ def get_active_constraints( - specific lower/upper bounds are not activated; - only natural simplex bounds 0 <= w <= 1 are used. """ + groups = config.groups + active = WeightConstraints() - if sampling_mode == "random": - active_group_lb = {g: 0.0 for g in groups} - active_group_ub = {g: 1.0 for g in groups} - active_group_order = [] + if config.sampling_mode == "random": + active.group_lb = {g: 0.0 for g in groups} + active.group_ub = {g: 1.0 for g in groups} + active.group_order = [] - active_local_lb = { + active.local_lb = { g: {c: 0.0 for c in crits} for g, crits in groups.items() } - active_local_ub = { + active.local_ub = { g: {c: 1.0 for c in crits} for g, crits in groups.items() } - active_local_order = {g: [] for g in groups} - - elif sampling_mode == "bounded": - active_group_lb = group_lb - active_group_ub = group_ub - active_group_order = [] + active.local_order = {g: [] for g in groups} - active_local_lb = local_lb - active_local_ub = local_ub - 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 sampling_mode == "ordered": - active_group_lb = {g: 0.0 for g in groups} - active_group_ub = {g: 1.0 for g in groups} - active_group_order = group_order_constraints + elif config.sampling_mode == "ordered": + active.group_lb = {g: 0.0 for g in groups} + active.group_ub = {g: 1.0 for g in groups} + active.group_order = config.constraints.group_order - active_local_lb = { + active.local_lb = { g: {c: 0.0 for c in crits} for g, crits in groups.items() } - active_local_ub = { + active.local_ub = { g: {c: 1.0 for c in crits} for g, crits in groups.items() } - active_local_order = local_order_constraints - - elif sampling_mode == "bounded_ordered": - active_group_lb = group_lb - active_group_ub = group_ub - active_group_order = group_order_constraints + active.local_order = config.constraints.local_order - active_local_lb = local_lb - active_local_ub = local_ub - active_local_order = local_order_constraints + elif config.sampling_mode == "bounded_ordered": + active = config.constraints else: raise ValueError( @@ -446,13 +467,13 @@ def get_active_constraints( "'random', 'bounded', 'ordered', or 'bounded_ordered'" ) - return active_group_lb, active_group_ub, active_group_order, active_local_lb, active_local_ub, active_local_order + return active def sample_weights_dirichlet_constrained( lb_dict, ub_dict, - order_cons=None, + order_cons=[], max_tries=100000, alpha=1.0, rng=None, @@ -503,7 +524,6 @@ def sample_weights_dirichlet_constrained( ub_vec = np.array([ub_dict[x] for x in items], dtype=float) idx = {x: i for i, x in enumerate(items)} - order_cons = order_cons or [] k = len(items) alpha_vec = np.full(k, alpha, dtype=float) @@ -559,14 +579,7 @@ def sample_weights_dirichlet_constrained( def sample_hierarchical_weights( groups, - - active_group_lb, - active_group_ub, - active_group_order, - - active_local_lb, - active_local_ub, - active_local_order, + constraints, alpha_group=1.0, alpha_local=1.0, @@ -611,9 +624,9 @@ def sample_hierarchical_weights( group_weights_sampled = sample_weights_dirichlet_constrained( items=groups.keys(), - lb_dict=active_group_lb, - ub_dict=active_group_ub, - order_cons=active_group_order, + lb_dict=constraints.group_lb, + ub_dict=constraints.group_ub, + order_cons=constraints.group_order, alpha=alpha_group, rng=rng, stats=sampler_stats, @@ -626,9 +639,9 @@ def sample_hierarchical_weights( local_weights_sampled = sample_weights_dirichlet_constrained( items=crits, - lb_dict=active_local_lb[g], - ub_dict=active_local_ub[g], - order_cons=active_local_order[g], + lb_dict=constraints.local_lb[g], + ub_dict=constraints.local_ub[g], + order_cons=constraints.local_order[g], alpha=alpha_local, rng=rng, stats=sampler_stats, @@ -641,27 +654,7 @@ def sample_hierarchical_weights( return final_weights -def sample_smaa_weights( - criteria_list, - - groups, - weight_mode, - sampling_mode, - - group_lb, - group_ub, - group_order_constraints, - local_lb, - local_ub, - local_order_constraints, - - alpha=1.0, - alpha_group=1.0, - alpha_local=1.0, - - rng=None, - sampler_stats=None -): +def sample_smaa_weights(config: McdaConfig, sampler_stats={}, rng=None): """ Sample one criterion-weight vector according to the selected SMAA weight structure. @@ -698,58 +691,49 @@ def sample_smaa_weights( final_weights : dict Dictionary mapping each final criterion to its sampled weight. """ - if rng is None: rng = np.random.default_rng() - active_group_lb, active_group_ub, active_group_order, active_local_lb, active_local_ub, active_local_order = get_active_constraints(sampling_mode, groups, group_lb, group_ub, group_order_constraints, local_lb, local_ub, local_order_constraints) + active_constr = get_active_constraints(config) - if weight_mode == "flat": - criterion_lb = {c: 0.0 for c in criteria_list} - criterion_ub = {c: 1.0 for c in criteria_list} + if config.weight_mode == "flat": + criterion_lb = {c: 0.0 for c in config.criteria} + criterion_ub = {c: 1.0 for c in config.criteria} final_weights = sample_weights_dirichlet_constrained( - items=criteria_list, lb_dict=criterion_lb, ub_dict=criterion_ub, order_cons=[], - alpha=alpha, + alpha=config.alpha, rng=rng, stats=sampler_stats, stats_key="flat_weights" ) return final_weights - elif weight_mode == "group": + elif config.weight_mode == "group": group_weights_sampled = sample_weights_dirichlet_constrained( - lb_dict=active_group_lb, - ub_dict=active_group_ub, - order_cons=active_group_order, - alpha=alpha_group, + lb_dict=active_constr.group_lb, + ub_dict=active_constr.group_ub, + 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 groups.items(): + for g, crits in config.groups.items(): for c in crits: final_weights[c] = group_weights_sampled[g] / len(crits) return final_weights - elif weight_mode == "hierarchical": + elif config.weight_mode == "hierarchical": return sample_hierarchical_weights( - groups=groups, + groups=config.groups, + active_constr=config.constraints, - active_group_lb=active_group_lb, - active_group_ub=active_group_ub, - active_group_order=active_group_order, - - active_local_lb=active_local_lb, - active_local_ub=active_local_ub, - active_local_order=active_local_order, - - alpha_group=alpha_group, - alpha_local=alpha_local, + alpha_group=config.alpha_group, + alpha_local=config.alpha_local, sampler_stats=sampler_stats, rng=rng @@ -765,17 +749,7 @@ def sample_smaa_weights( # PROMETHEE EVALUATION ENGINE # ============================================================ -def promethee_nfs( - df, - directions_dict, - weights_dict, - method="promethee_like", - thresholds_dict=None, - use_veto=False, - veto_thresholds_dict=None, - veto_type="hard", - penalty_factor=0.5 -): +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. @@ -815,11 +789,16 @@ def promethee_nfs( nfs : pandas.Series Net flow score for each alternative. """ - alts = df.index.tolist() + # Fall-back to defaults for unspecified parameters + df = decision_matrix or config.df + method = config.method or "promethee_like" + config.use_veto = (config.veto_type != "no") + alts = config.df.index.tolist() + S = pd.DataFrame(0.0, index=alts, columns=alts) - if use_veto and veto_thresholds_dict is None: - raise ValueError("veto_thresholds_dict must be provided when use_veto=True.") + 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: @@ -832,36 +811,34 @@ def promethee_nfs( d = preference_difference( df.loc[a, c], df.loc[b, c], - directions_dict[c] + config.directions[c] ) if method == "promethee_like": - q = thresholds_dict[c] + q = config.thresholds[c] pref = promethee_like_preference(d, q) elif method == "promethee": - q, p = thresholds_dict[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 += weights_dict[c] * pref + score += config.weights[c] * pref # Apply optional veto / penalty after aggregation - if use_veto and veto_is_triggered( + if config.use_veto and veto_is_triggered( a=a, b=b, df=df, - directions_dict=directions_dict, - veto_thresholds_dict=veto_thresholds_dict + directions_dict=config.directions, + veto_thresholds_dict=config.veto_thresholds, ): - if veto_type == "hard": + if config.veto_type == "hard": score = 0.0 - - elif veto_type == "soft": - score *= penalty_factor - + elif config.veto_type == "soft": + score *= config.penalty_factor else: raise ValueError("veto_type must be either 'hard' or 'soft'.") @@ -877,31 +854,7 @@ def promethee_nfs( # SMAA MONTE CARLO SIMULATION # ============================================================ -def run_smaa( - df, - directions_dict, - analysis_type="full_smaa", - n_samples=100000, - alpha=1.0, - rng=None, - method="promethee_like", - thresholds_dict=None, - use_veto=False, - veto_thresholds_dict=None, - veto_type="hard", - penalty_factor=0.5, - weight_mode="flat", - sampling_mode="random", - groups=None, - group_lb=None, - group_ub=None, - group_order_constraints=None, - local_lb=None, - local_ub=None, - local_order_constraints=None, - alpha_group=1.0, - alpha_local=1.0 -): +def run_smaa(config: McdaConfig, analysis_type="full_smaa", rng=None): """ Run the SMAA Monte Carlo simulation. @@ -946,7 +899,7 @@ def run_smaa( dict Dictionary containing SMAA outputs and diagnostic information. """ - criteria_list = df.columns.tolist() + if rng is None: rng = np.random.default_rng() @@ -954,13 +907,13 @@ def run_smaa( # Since no group or local structure is used, this implementation # allows flat sampling only in the unconstrained random case. - if weight_mode == "flat" and sampling_mode != "random": + 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 = df.index.tolist() + alts = config.df.index.tolist() n_alts = len(alts) rank_counts = pd.DataFrame( @@ -972,19 +925,19 @@ def run_smaa( 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 criteria_list} + 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(n_samples): + for _ in range(config.n_samples): # Generate one realization of uncertainty if analysis_type == "smaa_weights": - current_df = df + current_df = config.df elif analysis_type == "full_smaa": - current_df = sample_performance_matrix(df, rng) + current_df = sample_performance_matrix(config.df, rng) else: raise ValueError( "analysis_type must be either " @@ -992,44 +945,13 @@ def run_smaa( ) # Sample one feasible weight vector - w = sample_smaa_weights( - criteria_list=criteria_list, - - groups=groups, - weight_mode=weight_mode, - - group_lb=group_lb, - group_ub=group_ub, - group_order_constraints=group_order_constraints, - - local_lb=local_lb, - local_ub=local_ub, - local_order_constraints=local_order_constraints, + w = sample_smaa_weights(config, sampler_stats, rng) - alpha=alpha, - alpha_group=alpha_group, - alpha_local=alpha_local, - - rng=rng, - sampler_stats=sampler_stats - ) - - for c in criteria_list: + for c in config.criteria: weight_samples[c].append(w[c]) # Evaluate alternatives using PROMETHEE - nfs = promethee_nfs( - df=current_df, - criteria_list=criteria_list, - directions_dict=directions_dict, - weights_dict=w, - method=method, - thresholds_dict=thresholds_dict, - use_veto=use_veto, - veto_thresholds_dict=veto_thresholds_dict, - veto_type=veto_type, - penalty_factor=penalty_factor - ) + 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)} @@ -1062,7 +984,7 @@ def run_smaa( exp_rank = pd.Series(exp_rank, index=alts).sort_values() w_mean = pd.Series( - {c: float(np.mean(weight_samples[c])) for c in criteria_list} + {c: float(np.mean(weight_samples[c])) for c in config.criteria} ).sort_values(ascending=False) sim_rows = [] @@ -1103,25 +1025,26 @@ def run_smaa( } -def mcda(df, directions, scenario, method, weight_mode, groups, group_weights, local_weights, thresholds, veto_type, veto_thresholds, penalty_factor, sampling_mode, group_lb, group_ub, group_order_constraints, local_lb, local_ub, local_order_constraints): +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(df) - use_veto = (veto_type!="no") + performance_uncertainty = has_uncertain_performances(config.df) + config.use_veto = (config.veto_type!="no") + config.criteria = list(config.directions.keys()) # Select the actual analysis type. - if scenario == "deterministic": - weights = get_fixed_weights(weight_mode, list(directions.keys()), groups, group_weights, local_weights) + if config.scenario == "deterministic": + set_fixed_weights(config) if performance_uncertainty: analysis_type = "performance_uncertainty" else: analysis_type = "deterministic" - elif scenario == "uncertain": + elif config.scenario == "uncertain": if performance_uncertainty: analysis_type = "full_smaa" else: @@ -1137,12 +1060,12 @@ def mcda(df, directions, scenario, method, weight_mode, groups, group_weights, l # ============================================================ if analysis_type == "deterministic": - results, intermediates = deterministic_promethee(df, directions, method, thresholds, use_veto, veto_type, weights, penalty_factor) + results, intermediates = deterministic_promethee(config) pd.set_option("display.precision", 6) print("\n--- DETERMINISTIC SCENARIO ---") print("\nDecision matrix (numeric):") - print(df) + print(config.df) print("\nPairwise preference matrix S(a,b):") print(intermediates["S"]) print("\nPROMETHEE flows and NFS:") @@ -1169,22 +1092,8 @@ def mcda(df, directions, scenario, method, weight_mode, groups, group_weights, l """ elif analysis_type == "performance_uncertainty": - rng = np.random.default_rng(42) - - perf_out = run_performance_uncertainty( - df=df, - directions_dict=directions, - weights_dict=weights, - n_samples=10000, - rng=rng, - method=method, - thresholds_dict=thresholds, - use_veto=use_veto, - veto_thresholds_dict=veto_thresholds, - veto_type=veto_type, - penalty_factor=penalty_factor - ) + perf_out = run_performance_uncertainty(config, rng) print("\n--- PERFORMANCE UNCERTAINTY SCENARIO ---") print(f"Samples used: {perf_out['n_samples']}") @@ -1227,46 +1136,9 @@ def mcda(df, directions, scenario, method, weight_mode, groups, group_weights, l - pairwise outranking probabilities. """ - if scenario == "uncertain": - smaa_out = run_smaa( - df=df, - directions_dict=directions, - analysis_type=analysis_type, - n_samples=10000, - - # General SMAA settings - alpha=1.0, - rng=rng, - - # PROMETHEE settings - method=method, - thresholds_dict=thresholds, - - # Veto settings - use_veto=use_veto, - veto_thresholds_dict=veto_thresholds, - veto_type=veto_type, - penalty_factor=penalty_factor, - - # Weight structure settings - weight_mode=weight_mode, - sampling_mode=sampling_mode, - - # Group-level settings - groups=groups, - group_lb=group_lb, - group_ub=group_ub, - group_order_constraints=group_order_constraints, - - # Local criterion-level settings - local_lb=local_lb, - local_ub=local_ub, - local_order_constraints=local_order_constraints, - - # Hierarchical sampling parameters - alpha_group=1.0, - alpha_local=1.0 - ) + if config.scenario == "uncertain": + rng = np.random.default_rng(42) + smaa_out = run_smaa(config, analysis_type=analysis_type, rng=rng) print("\n--- SMAA SCENARIO ---") print(f"Samples used: {smaa_out['n_samples']}") diff --git a/mysite/dss/serializers.py b/mysite/dss/serializers.py index 630fc45..d726085 100644 --- a/mysite/dss/serializers.py +++ b/mysite/dss/serializers.py @@ -8,21 +8,26 @@ class McdaRequestSerializer(serializers.Serializer): SAMPLING_CHOICES = ["random", "bounded", "ordered", "bounded_ordered"] decision_matrix = serializers.DictField() # {"alternative": {"criterion": value},} - directions = serializers.DictField() # {"criterion": "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], required=False) # Relevant if scenario=="deterministic" + 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, defaul="no") - veto_thesholds = serializers.DictField(required=False) # {"criterion": value} - penalty_factor = serializers.FloatField(required=False, default=0.5) # used if veto_type=="soft" + 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, requred=False) + sampling_mode = serializers.ChoiceField(SAMPLING_CHOICES, required=False) + 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)] diff --git a/mysite/dss/views.py b/mysite/dss/views.py index 9ea33f0..653914a 100644 --- a/mysite/dss/views.py +++ b/mysite/dss/views.py @@ -1,12 +1,12 @@ import pandas as pd + from django.shortcuts import render from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from .serializers import McdaRequestSerializer, McdaResponseSerializer -from .mcda import mcda - +from .mcda import mcda, McdaConfig, WeightConstraints class McdaCalculationView(APIView): def post(self, request): @@ -21,38 +21,43 @@ def post(self, request): return Response({"Issue with decision matrix": str(e)}, status=status.HTTP_400_BAD_REQUEST) try: - result = mcda( + constraints = WeightConstraints( + group_lb=data.get("group_lb"), + group_ub=data.get("group_ub"), + group_order=data.get("group_order_constraints"), + local_lb=data.get("local_lb"), + local_ub=data.get("local_ub"), + local_order=data.get("local_order_constraints"), + ) + config = McdaConfig( df=decision_matrix, directions=data["directions"], scenario=data["scenario"], method=data["method"], weight_mode=data["weight_mode"], groups=data.get("groups"), - group_weight=data.get("group_weights"), + group_weights=data.get("group_weights"), + local_weights=data.get("local_weights"), thresholds=data.get("thresholds"), # Veto settings veto_type=data["veto_type"], - use_veto=(data["veto_type"]!="no"), veto_thresholds=data.get("veto_thresholds"), penalty_factor=data.get("penalty_factor"), - # Weight structure settings (group-level and local) + # Sampling sampling_mode=data.get("sampling_mode"), - group_lb=data.get("group_lb"), - group_ub=data.get("group_ub"), - group_order_constraints=data.get("group_order_constraints"), - local_lb=data.get("local_lb"), - local_ub=data.get("local_ub"), - local_order_constraints=data.get("local_order_constraints"), + n_samples=data.get("n_samples"), + alpha=data.get("alpha"), + alpha_group=data.get("alpha_group"), + alpha_local=data.get("alpha_local"), + + # Weight structure settings (group-level and local) + constraints=constraints, ) + result = mcda(config) except ValueError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - response_data = { - "shape": data["shape"], - "operation": data["operation"], - "result": result, - } - response_serializer = McdaResponseSerializer(response_data) + response_serializer = McdaResponseSerializer(result) return Response(response_serializer.data, status=status.HTTP_200_OK) From 92e6cbf1b12cb2d6f2b7fa6cbfa67171184e9d91 Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Sun, 5 Jul 2026 10:15:31 +0200 Subject: [PATCH 06/15] Rearrange MCDA functions; rename outputs to `results` --- mysite/dss/mcda.py | 362 ++++++++++++++++++++++----------------------- mysite/dss/plot.py | 9 +- 2 files changed, 183 insertions(+), 188 deletions(-) diff --git a/mysite/dss/mcda.py b/mysite/dss/mcda.py index fc9d524..19f278f 100644 --- a/mysite/dss/mcda.py +++ b/mysite/dss/mcda.py @@ -183,101 +183,6 @@ def veto_is_triggered( return False -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 - } - - -# ============================================================ -# FIXED CRITERION WEIGHTS -# ============================================================ def set_fixed_weights(config: McdaConfig): """ The dictionary `weights` contains the final weight assigned to @@ -320,75 +225,6 @@ def set_fixed_weights(config: McdaConfig): config.weights = weights -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": - 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 - - # ============================================================ # ACTIVATE CONSTRAINTS ACCORDING TO SAMPLING MODE # ============================================================ @@ -850,6 +686,166 @@ def promethee_nfs(config: McdaConfig, decision_matrix: pd.DataFrame=None): 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": + 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 # ============================================================ @@ -1060,20 +1056,20 @@ def mcda(config: McdaConfig): # ============================================================ if analysis_type == "deterministic": - results, intermediates = deterministic_promethee(config) + 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(intermediates["S"]) + print(pairwise) print("\nPROMETHEE flows and NFS:") print(results) print("\nRanking (best to worst):") print(list(results.index)) - plot.deterministic_promethee_figures(**intermediates) + plot.deterministic_promethee_figures(results, pairwise) # ============================================================ # CASE 2: PERFORMANCE UNCERTAINTY ANALYSIS @@ -1093,24 +1089,24 @@ def mcda(config: McdaConfig): elif analysis_type == "performance_uncertainty": rng = np.random.default_rng(42) - perf_out = run_performance_uncertainty(config, rng) + results = run_performance_uncertainty(config, rng) print("\n--- PERFORMANCE UNCERTAINTY SCENARIO ---") - print(f"Samples used: {perf_out['n_samples']}") + print(f"Samples used: {config.n_samples}") print("\nWinning probabilities (P[rank=1]):") - print(perf_out["win_probability"]) + print(results["win_probability"]) print("\nExpected rank (lower is better):") - print(perf_out["expected_rank"]) + print(results["expected_rank"]) print("\nRank acceptability indices b_{i,r}:") - print(perf_out["rank_acceptability"]) + print(results["rank_acceptability"]) print("\nMean NFS:") - print(perf_out["mean_nfs"]) + print(results["mean_nfs"]) - plot.performance_uncertainty_figures(perf_out) + plot.performance_uncertainty_figures(results) # ============================================================ # UNCERTAIN SCENARIO EXECUTION @@ -1138,27 +1134,27 @@ def mcda(config: McdaConfig): if config.scenario == "uncertain": rng = np.random.default_rng(42) - smaa_out = run_smaa(config, analysis_type=analysis_type, rng=rng) + results = run_smaa(config, analysis_type=analysis_type, rng=rng) print("\n--- SMAA SCENARIO ---") - print(f"Samples used: {smaa_out['n_samples']}") + print(f"Samples used: {results['n_samples']}") print("\nWinning probabilities (P[rank=1]):") - print(smaa_out["win_probability"]) + print(results["win_probability"]) print("\nExpected rank (lower is better):") - print(smaa_out["expected_rank"]) + print(results["expected_rank"]) print("\nRank acceptability indices b_{i,r}:") - print(smaa_out["rank_acceptability"]) + print(results["rank_acceptability"]) print("\nMean sampled weights (barycenter):") - print(smaa_out["mean_weights"]) + print(results["mean_weights"]) print("\nRejection sampling diagnostics:") - print(smaa_out["sampler_stats"]) + print(results["sampler_stats"]) print("\nPairwise outranking probabilities P(i outranks j) based on NFS:") - print(smaa_out["outrank_probability"]) + print(results["outrank_probability"]) - plot.smaa_figures(smaa_out) + plot.smaa_figures(results) diff --git a/mysite/dss/plot.py b/mysite/dss/plot.py index 50c196a..9994290 100644 --- a/mysite/dss/plot.py +++ b/mysite/dss/plot.py @@ -6,7 +6,7 @@ # FIGURES FOR SHOWING THE RESULTS # ============================================================ -def deterministic_promethee_figures(S: pd.DataFrame, phi_plus: pd.Series, phi_minus: pd.Series): +def deterministic_promethee_figures(results: pd.DataFrame, S: pd.DataFrame): """Plot two figures for the deterministic PROMETHEE case """ plt.rcParams.update({ @@ -22,8 +22,7 @@ def deterministic_promethee_figures(S: pd.DataFrame, phi_plus: pd.Series, phi_mi # ============================================================ # Sort NFS values according to the final PROMETHEE ranking - nfs = phi_plus - phi_minus - sorted_nfs = nfs.sort_values(ascending=False) + sorted_nfs = results["NFS (phi)"].sort_values(ascending=False) alternatives = sorted_nfs.index nfs_values = sorted_nfs.values @@ -66,10 +65,10 @@ def deterministic_promethee_figures(S: pd.DataFrame, phi_plus: pd.Series, phi_mi - AGAINST row, i.e. phi_minus """ S_aug = S.copy() - S_aug["FOR"] = phi_plus + S_aug["FOR"] = results["FOR (phi+)"] against_row = pd.DataFrame( - [list(phi_minus) + [np.nan]], + [list(results["AGAINST (phi-)"]) + [np.nan]], columns=S_aug.columns, index=["AGAINST"] ) From a57808ae7e8d712efcbaa67f577cd6945ef04ba4 Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Sun, 5 Jul 2026 14:02:27 +0200 Subject: [PATCH 07/15] Fix multiple bugs; set McdaConfig private attributes --- mysite/dss/mcda.py | 69 ++++++++++++++++++++------------------- mysite/dss/serializers.py | 12 +++---- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/mysite/dss/mcda.py b/mysite/dss/mcda.py index 19f278f..cdb64c1 100644 --- a/mysite/dss/mcda.py +++ b/mysite/dss/mcda.py @@ -20,44 +20,46 @@ class WeightConstraints: Class for storing weight costraints in terms of upper and lower bounds for (groups of) criteria. """ - group_lb: Optional[Bounds] - group_ub: Optional[Bounds] - group_order: Optional[OrderConstraint] - local_lb: Optional[dict[str, Bounds]] - local_ub: Optional[dict[str, Bounds]] - local_order: Optional[dict[str, OrderConstraint]] + group_lb: Optional[Bounds] = None + group_ub: Optional[Bounds] = None + group_order: Optional[OrderConstraint] = None + local_lb: Optional[dict[str, Bounds]] = None + local_ub: Optional[dict[str, Bounds]] = None + local_order: Optional[dict[str, OrderConstraint]] = None @dataclass(slots=True) class McdaConfig: # General - df = pd.DataFrame + df: pd.DataFrame # decision_matrix: dict[str, Criteria] directions: dict[str, Literal["min", "max"] | float] scenario: str method: str - criteria: Optional[list[str]] + criteria: Optional[list[str]] = None # automatically created # Weighing and grouping weight_mode: str = McdaRequestSerializer.WEIGHT_CHOICES[0] - groups: Optional[dict[str, list[str]]] - group_weights: Optional[dict[str, float]] - local_weights: Optional[dict[str, float]] + groups: Optional[dict[str, list[str]]] = None + group_weights: Optional[dict[str, float]] = None + local_weights: Optional[dict[str, float]] = None + _weights: Optional[dict[str, float]] = None # PROMETHEE parameters - thresholds: Optional[Criteria] + thresholds: Optional[Criteria] = None veto_type: str = "no" - veto_thresholds: Optional[Bounds] + _use_veto: bool = False + veto_thresholds: Optional[Bounds] = None penalty_factor: float = 0.5 # Sampling - sampling_mode: Optional[str] + 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] + constraints: Optional[WeightConstraints] = None # ============================================================ @@ -222,7 +224,7 @@ def set_fixed_weights(config: McdaConfig): w_local = config.local_weights[g][c] / total_local weights[c] = Wg * w_local - config.weights = weights + config._weights = weights # ============================================================ @@ -459,7 +461,6 @@ def sample_hierarchical_weights( rng = np.random.default_rng() group_weights_sampled = sample_weights_dirichlet_constrained( - items=groups.keys(), lb_dict=constraints.group_lb, ub_dict=constraints.group_ub, order_cons=constraints.group_order, @@ -474,7 +475,6 @@ def sample_hierarchical_weights( for g, crits in groups.items(): local_weights_sampled = sample_weights_dirichlet_constrained( - items=crits, lb_dict=constraints.local_lb[g], ub_dict=constraints.local_ub[g], order_cons=constraints.local_order[g], @@ -566,13 +566,11 @@ def sample_smaa_weights(config: McdaConfig, sampler_stats={}, rng=None): elif config.weight_mode == "hierarchical": return sample_hierarchical_weights( groups=config.groups, - active_constr=config.constraints, - + constraints=config.constraints, alpha_group=config.alpha_group, alpha_local=config.alpha_local, - + rng=rng, sampler_stats=sampler_stats, - rng=rng ) else: @@ -626,14 +624,14 @@ def promethee_nfs(config: McdaConfig, decision_matrix: pd.DataFrame=None): Net flow score for each alternative. """ # Fall-back to defaults for unspecified parameters - df = decision_matrix or config.df + df = decision_matrix if decision_matrix is not None else config.df method = config.method or "promethee_like" - config.use_veto = (config.veto_type != "no") + config._use_veto = (config.veto_type != "no") alts = config.df.index.tolist() S = pd.DataFrame(0.0, index=alts, columns=alts) - if config.use_veto and config.veto_thresholds is None: + 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: @@ -653,18 +651,16 @@ def promethee_nfs(config: McdaConfig, decision_matrix: pd.DataFrame=None): 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 + score += config._weights[c] * pref # Apply optional veto / penalty after aggregation - if config.use_veto and veto_is_triggered( + if config._use_veto and veto_is_triggered( a=a, b=b, df=df, @@ -717,15 +713,17 @@ def deterministic_promethee(config: McdaConfig): 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 + score += config._weights[c] * pref - if config.use_veto and veto_is_triggered( + if config._use_veto and veto_is_triggered( a, b, config.df, config.directions, config.veto_thresholds ): if config.veto_type == "hard": @@ -1029,12 +1027,17 @@ def mcda(config: McdaConfig): # 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._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": - set_fixed_weights(config) if performance_uncertainty: analysis_type = "performance_uncertainty" else: diff --git a/mysite/dss/serializers.py b/mysite/dss/serializers.py index d726085..11c8b11 100644 --- a/mysite/dss/serializers.py +++ b/mysite/dss/serializers.py @@ -2,8 +2,8 @@ class McdaRequestSerializer(serializers.Serializer): SCENARIO_CHOICES = ["uncertain", "deterministic"] - WEIGHT_CHOICES = ["flat", "group", "hierarchical"] METHOD_CHOICES = ["promethee", "promethee_like"] + WEIGHT_CHOICES = ["flat", "group", "hierarchical"] VETO_CHOICES = ["no", "soft", "hard"] SAMPLING_CHOICES = ["random", "bounded", "ordered", "bounded_ordered"] @@ -23,7 +23,7 @@ class McdaRequestSerializer(serializers.Serializer): 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) + 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) @@ -105,10 +105,10 @@ class IndicatorScoresSerializer(serializers.Serializer): score = serializers.FloatField() class ExperimentRankingSerializer(serializers.Serializer): - experiment_id = serializers.IntField() - rank = serializers.IntField() - score = serializers.IntField() + experiment_id = serializers.IntegerField() + rank = serializers.IntegerField() + score = serializers.IntegerField() indicators = IndicatorScoresSerializer class McdaResponseSerializer(serializers.Serializer): - experiment_ranking = serializers.ListField(ExperimentRankingSerializer) + experiment_ranking = serializers.ListField(child=ExperimentRankingSerializer()) From 7218f87c66adfe7d801b5dac5af669b2c0fc9ecc Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Mon, 6 Jul 2026 13:36:38 +0200 Subject: [PATCH 08/15] Create tests for the MCDA functions --- mysite/dss/tests.py | 647 ++++++++++++++++++++++++-------------------- 1 file changed, 348 insertions(+), 299 deletions(-) diff --git a/mysite/dss/tests.py b/mysite/dss/tests.py index cc68103..af220f9 100644 --- a/mysite/dss/tests.py +++ b/mysite/dss/tests.py @@ -1,5 +1,10 @@ +import logging import pandas as pd from django.test import TestCase +from .mcda import McdaConfig, WeightConstraints, mcda + +logger = logging.getLogger(__name__) +logger.setLevel('DEBUG') class McdaTest(TestCase): """ @@ -10,57 +15,353 @@ class McdaTest(TestCase): # DECISION MATRIX # ============================================================ - df = pd.DataFrame( - data=[ - [9.3, 0.0, 0.0, 0.0, 0.0, 1, 0.551, 0.19108, 4.95, 0.0282, 0.00500, 0.000000439, 26.9, 1.91, 0.298], - [9.3, 0.0, 0.0, 0.0, 0.0, 2, 0.551, 0.19108, 4.63, 0.0265, 0.00501, 0.000000412, 26.5, 1.94, 0.397], - [0.0, 0.027, 0.2, 0.3, 3.3, 2, 0.396, 0.01072, 15.0, 0.1160, 0.01020, 0.000001270, 52.7, 3.34, 0.592], - [0.0, 0.027, 0.2, 0.3, 3.3, 3, 0.396, 0.01072, 8.43, 0.0807, 0.00756, 0.000000774, 17.8, 9.38, 0.297], - ], - index=["Steel RoW", "Steel RER", "AlLi RoW", "AlLi Canada"], - columns=[ - "Ni concentration (%)", - "Li concentration (%)", - "Mg concentration (%)", - "Ti concentration (%)", - "Cu concentration (%)", - "Operator", - "Recycled input (kg/kg)", - "Waste output (kg/kg)", - "Climate change (GWP100)", - "Acidification (AE)", - "Eutrophication Freshwater (P)", - "Particulate Matter (human health)", - "LandUse (soil quality index)", - "WaterUse (m³ world eq deprived)", - "Ionising radiation (kBq U-235 eq)", - ], - ) + def test_mcda(self): + df = pd.DataFrame( + data=[ + [9.3, 0.0, 0.0, 0.0, 0.0, 1, 0.551, 0.19108, 4.95, 0.0282, 0.00500, 0.000000439, 26.9, 1.91, 0.298], + [9.3, 0.0, 0.0, 0.0, 0.0, 2, 0.551, 0.19108, 4.63, 0.0265, 0.00501, 0.000000412, 26.5, 1.94, 0.397], + [0.0, 0.027, 0.2, 0.3, 3.3, 2, 0.396, 0.01072, 15.0, 0.1160, 0.01020, 0.000001270, 52.7, 3.34, 0.592], + [0.0, 0.027, 0.2, 0.3, 3.3, 3, 0.396, 0.01072, 8.43, 0.0807, 0.00756, 0.000000774, 17.8, 9.38, 0.297], + ], + index=["Steel RoW", "Steel RER", "AlLi RoW", "AlLi Canada"], + columns=[ + "Ni concentration (%)", + "Li concentration (%)", + "Mg concentration (%)", + "Ti concentration (%)", + "Cu concentration (%)", + "Operator", + "Recycled input (kg/kg)", + "Waste output (kg/kg)", + "Climate change (GWP100)", + "Acidification (AE)", + "Eutrophication Freshwater (P)", + "Particulate Matter (human health)", + "LandUse (soil quality index)", + "WaterUse (m³ world eq deprived)", + "Ionising radiation (kBq U-235 eq)", + ], + ) + + # ============================================================ + # CRITERION ORIENTATION + # ============================================================ + + directions = { + "Ni concentration (%)": "min", + "Li concentration (%)": "min", + "Mg concentration (%)": "min", + "Ti concentration (%)": "min", + "Cu concentration (%)": "min", + "Operator": "max", + "Recycled input (kg/kg)": "max", + "Waste output (kg/kg)": "min", + "Climate change (GWP100)": "min", + "Acidification (AE)": "min", + "Eutrophication Freshwater (P)": "min", + "Particulate Matter (human health)": "min", + "LandUse (soil quality index)": "min", + "WaterUse (m³ world eq deprived)": "min", + "Ionising radiation (kBq U-235 eq)": "min", + } + # ============================================================ + # CRITERION GROUPS + # ============================================================ + + groups = { + "CRM": [ + "Ni concentration (%)", + "Li concentration (%)", + "Mg concentration (%)", + "Ti concentration (%)", + "Cu concentration (%)", + ], + "Circularity": [ + "Recycled input (kg/kg)", + "Waste output (kg/kg)", + ], + "Environmental": [ + "Climate change (GWP100)", + "Acidification (AE)", + "Eutrophication Freshwater (P)", + "Particulate Matter (human health)", + "LandUse (soil quality index)", + "WaterUse (m³ world eq deprived)", + "Ionising radiation (kBq U-235 eq)", + ], + "Manufacturer": [ + "Operator", + ] + } - # ============================================================ - # CRITERION ORIENTATION - # ============================================================ + # ============================================================ + # CRITERION WEIGHTS + # ============================================================ + + group_weights = { + "CRM": 0.25, + "Circularity": 0.25, + "Environmental": 0.40, + "Manufacturer": 0.10 + } + + local_weights = { + "CRM":{ + "Ni concentration (%)": 0.30, + "Li concentration (%)": 0.20, + "Mg concentration (%)": 0.15, + "Ti concentration (%)": 0.15, + "Cu concentration (%)": 0.20 + }, + "Circularity":{ + "Recycled input (kg/kg)": 0.70, + "Waste output (kg/kg)": 0.30 + }, + "Environmental":{ + "Climate change (GWP100)": 0.30, + "Acidification (AE)": 0.10, + "Eutrophication Freshwater (P)": 0.10, + "Particulate Matter (human health)": 0.10, + "LandUse (soil quality index)": 0.10, + "WaterUse (m³ world eq deprived)": 0.20, + "Ionising radiation (kBq U-235 eq)": 0.10 + }, + "Manufacturer":{ + "Operator": 1.0 + } + } + + # ============================================================ + # PROMETHEE SETTINGS + # ============================================================ + + thresholds = { + "Ni concentration (%)": (0.0, 2.0), + "Li concentration (%)": (0.0, 0.01), + "Mg concentration (%)": (0.0, 0.05), + "Ti concentration (%)": (0.0, 0.05), + "Cu concentration (%)": (0.0, 1.0), + "Operator": (0.0, 1.0), + "Recycled input (kg/kg)": (0.0, 0.10), + "Waste output (kg/kg)": (0.0, 0.05), + "Climate change (GWP100)": (0.0, 5.0), + "Acidification (AE)": (0.0, 0.05), + "Eutrophication Freshwater (P)": (0.0, 0.003), + "Particulate Matter (human health)": (0.0, 0.0000005), + "LandUse (soil quality index)": (0.0, 20.0), + "WaterUse (m³ world eq deprived)": (0.0, 3.0), + "Ionising radiation (kBq U-235 eq)": (0.0, 0.2), + } + + # Veto thresholds: if alternative a is worse than b by more than v, + # then S(a,b) is penalized. + veto_thresholds = { + "Climate change (GWP100)": 8.0, + "WaterUse (m³ world eq deprived)": 5.0, + "Ni concentration (%)": 5.0, + "Cu concentration (%)": 2.0, + } + + # ============================================================ + # GROUP-LEVEL WEIGHT UNCERTAINTY + # ============================================================ + """ + Group-level weights represent the relative importance of the + main dimensions of the decision problem: + - CRM + - Circularity + - Environmental + - Manufacturer + + These weights are denoted as: + W_g + and must satisfy: + W_g >= 0 + sum_g W_g = 1 + + In the "bounded" sampling mode, lower and upper bounds are + imposed on each group weight. + In the "ordered" sampling mode, ordinal constraints are imposed. + These can also include preference intensities. + """ + # ============================================================ + + group_lb = { + "CRM": 0.10, + "Circularity": 0.10, + "Environmental": 0.20, + "Manufacturer": 0.05 + } - directions = { - "Ni concentration (%)": "min", - "Li concentration (%)": "min", - "Mg concentration (%)": "min", - "Ti concentration (%)": "min", - "Cu concentration (%)": "min", - "Operator": "max", - "Recycled input (kg/kg)": "max", - "Waste output (kg/kg)": "min", - "Climate change (GWP100)": "min", - "Acidification (AE)": "min", - "Eutrophication Freshwater (P)": "min", - "Particulate Matter (human health)": "min", - "LandUse (soil quality index)": "min", - "WaterUse (m³ world eq deprived)": "min", - "Ionising radiation (kBq U-235 eq)": "min", - } - criteria = list(directions.keys()) + group_ub = { + "CRM": 0.40, + "Circularity": 0.40, + "Environmental": 0.60, + "Manufacturer": 0.25 + } + group_order_constraints = [ + ("Environmental", "Manufacturer", 1.5), + ("CRM", "Manufacturer", 1.2), + ("Circularity", "Manufacturer", 1.0), + ] + + # ============================================================ + # LOCAL CRITERION-LEVEL WEIGHT UNCERTAINTY + # ============================================================ + """ + Local weights represent the importance of criteria within + each group. + + For a criterion j belonging to group g, the local weight is: + w_{j|g} + and must satisfy, within each group: + w_{j|g} >= 0 + sum_{j in G_g} w_{j|g} = 1 + + In the hierarchical model, the final global criterion weight is: + w_j = W_g * w_{j|g} + + where: + W_g = sampled group weight + w_{j|g} = sampled local weight of criterion j within group g + + In the "bounded" sampling mode, local lower and upper bounds + are imposed. + In the "ordered" sampling mode, local ordinal constraints and + preference intensities are imposed. + + A local ordinal constraint can be specified as: + ("A", "B") + meaning: + w_A >= w_B + or as: + ("A", "B", intensity) + meaning: + w_A >= intensity * w_B + """ + + local_lb = { + "CRM": { + "Ni concentration (%)": 0.10, + "Li concentration (%)": 0.05, + "Mg concentration (%)": 0.05, + "Ti concentration (%)": 0.05, + "Cu concentration (%)": 0.10, + }, + "Circularity": { + "Recycled input (kg/kg)": 0.40, + "Waste output (kg/kg)": 0.20, + }, + "Environmental": { + "Climate change (GWP100)": 0.15, + "Acidification (AE)": 0.05, + "Eutrophication Freshwater (P)": 0.05, + "Particulate Matter (human health)": 0.05, + "LandUse (soil quality index)": 0.05, + "WaterUse (m³ world eq deprived)": 0.10, + "Ionising radiation (kBq U-235 eq)": 0.05, + }, + "Manufacturer": { + "Operator": 1.00, + } + } + + local_ub = { + "CRM": { + "Ni concentration (%)": 0.40, + "Li concentration (%)": 0.30, + "Mg concentration (%)": 0.25, + "Ti concentration (%)": 0.25, + "Cu concentration (%)": 0.40, + }, + "Circularity": { + "Recycled input (kg/kg)": 0.80, + "Waste output (kg/kg)": 0.60, + }, + "Environmental": { + "Climate change (GWP100)": 0.35, + "Acidification (AE)": 0.20, + "Eutrophication Freshwater (P)": 0.20, + "Particulate Matter (human health)": 0.20, + "LandUse (soil quality index)": 0.20, + "WaterUse (m³ world eq deprived)": 0.30, + "Ionising radiation (kBq U-235 eq)": 0.20, + }, + "Manufacturer": { + "Operator": 1.00, + } + } + + local_order_constraints = { + "CRM": [ + ("Ni concentration (%)", "Li concentration (%)"), + ("Ni concentration (%)", "Mg concentration (%)"), + ("Cu concentration (%)", "Mg concentration (%)"), + ("Cu concentration (%)", "Ti concentration (%)"), + ], + "Circularity": [ + ("Recycled input (kg/kg)", "Waste output (kg/kg)"), + ], + "Environmental": [ + ("Climate change (GWP100)", "Acidification (AE)"), + ("Climate change (GWP100)", "Eutrophication Freshwater (P)"), + ("Climate change (GWP100)", "Particulate Matter (human health)"), + ("Climate change (GWP100)", "LandUse (soil quality index)"), + ("Climate change (GWP100)", "Ionising radiation (kBq U-235 eq)"), + ("WaterUse (m³ world eq deprived)", "Ionising radiation (kBq U-235 eq)"), + ], + "Manufacturer": [] + } + + constraints = WeightConstraints( + group_lb, group_ub, group_order_constraints, local_lb, local_ub, local_order_constraints + ) + + # ============================================================ + # COMPOSING ALMOST ALL COMBINATIONS OF PARAMETERS + # ============================================================ + + # "scenario", "method", "weight", "veto", "sampling" + test_cases = [ + ["deterministic", "promethee", "flat", "no", "random"], + ["deterministic", "promethee", "flat", "soft", "random"], + ["deterministic", "promethee", "flat", "hard", "random"], + ["deterministic", "promethee", "group", "no", "random"], + ["deterministic", "promethee", "hierarchical", "no", "random"], + ["uncertain", "promethee", "flat", "no", "random"], + ["uncertain", "promethee", "group", "no", "random"], + ["uncertain", "promethee", "hierarchical", "no", "random"], + ["uncertain", "promethee", "group", "no", "bounded"], + ["uncertain", "promethee", "hierarchical", "no", "bounded"], + ["uncertain", "promethee", "group", "no", "ordered"], + ["uncertain", "promethee", "hierarchical", "no", "ordered"], + ["deterministic", "promethee_like", "group", "no", "ordered"], + ["deterministic", "promethee_like", "hierarchical", "no", "bounded_ordered"], + ] + for settings in test_cases: + logger.debug(f"Testing case: {settings}") + config = McdaConfig( + df=df, + directions=directions, + scenario=settings[0], + method=settings[1], + weight_mode=settings[2], + groups=groups, + group_weights=group_weights, + local_weights=local_weights, + thresholds=thresholds, + veto_type=settings[3], + veto_thresholds=veto_thresholds, + sampling_mode=settings[4], + n_samples=10, + constraints=constraints, + ) + result = mcda(config) + return + +class Explanations(): # ============================================================ # ANALYSIS SCENARIO SELECTION # ============================================================ @@ -130,110 +431,12 @@ class McdaTest(TestCase): method = "promethee_like" - # ============================================================ - # CRITERION GROUPS - # ============================================================ - - groups = { - "CRM": [ - "Ni concentration (%)", - "Li concentration (%)", - "Mg concentration (%)", - "Ti concentration (%)", - "Cu concentration (%)", - ], - "Circularity": [ - "Recycled input (kg/kg)", - "Waste output (kg/kg)", - ], - "Environmental": [ - "Climate change (GWP100)", - "Acidification (AE)", - "Eutrophication Freshwater (P)", - "Particulate Matter (human health)", - "LandUse (soil quality index)", - "WaterUse (m³ world eq deprived)", - "Ionising radiation (kBq U-235 eq)", - ], - "Manufacturer": [ - "Operator", - ] - } - - # ============================================================ - # CRITERION WEIGHTS - # ============================================================ - - group_weights = { - "CRM": 0.25, - "Circularity": 0.25, - "Environmental": 0.40, - "Manufacturer": 0.10 - } - - local_weights = { - "CRM":{ - "Ni concentration (%)": 0.30, - "Li concentration (%)": 0.20, - "Mg concentration (%)": 0.15, - "Ti concentration (%)": 0.15, - "Cu concentration (%)": 0.20 - }, - "Circularity":{ - "Recycled input (kg/kg)": 0.70, - "Waste output (kg/kg)": 0.30 - }, - "Environmental":{ - "Climate change (GWP100)": 0.30, - "Acidification (AE)": 0.10, - "Eutrophication Freshwater (P)": 0.10, - "Particulate Matter (human health)": 0.10, - "LandUse (soil quality index)": 0.10, - "WaterUse (m³ world eq deprived)": 0.20, - "Ionising radiation (kBq U-235 eq)": 0.10 - }, - "Manufacturer":{ - "Operator": 1.0 - } - } - - # ============================================================ - # PROMETHEE SETTINGS - # ============================================================ - - thresholds = { - "Ni concentration (%)": {"q": 0.0, "p": 2.0}, - "Li concentration (%)": {"q": 0.0, "p": 0.01}, - "Mg concentration (%)": {"q": 0.0, "p": 0.05}, - "Ti concentration (%)": {"q": 0.0, "p": 0.05}, - "Cu concentration (%)": {"q": 0.0, "p": 1.0}, - "Operator": {"q": 0.0, "p": 1.0}, - "Recycled input (kg/kg)": {"q": 0.0, "p": 0.10}, - "Waste output (kg/kg)": {"q": 0.0, "p": 0.05}, - "Climate change (GWP100)": {"q": 0.0, "p": 5.0}, - "Acidification (AE)": {"q": 0.0, "p": 0.05}, - "Eutrophication Freshwater (P)": {"q": 0.0, "p": 0.003}, - "Particulate Matter (human health)": {"q": 0.0, "p": 0.0000005}, - "LandUse (soil quality index)": {"q": 0.0, "p": 20.0}, - "WaterUse (m³ world eq deprived)": {"q": 0.0, "p": 3.0}, - "Ionising radiation (kBq U-235 eq)": {"q": 0.0, "p": 0.2}, - } - # ============================================================ # VETO SETTINGS # ============================================================ use_veto = False - # Veto thresholds: if alternative a is worse than b by more than v, - # then S(a,b) is penalized. - veto_thresholds = { - "Climate change (GWP100)": 8.0, - "WaterUse (m³ world eq deprived)": 5.0, - "Ni concentration (%)": 5.0, - "Cu concentration (%)": 2.0, - } - # Type of veto: # "hard" => set S(a,b) to 0 # "soft" => multiply S(a,b) by a penalty factor @@ -293,157 +496,3 @@ class McdaTest(TestCase): weight_mode = "flat" sampling_mode = "random" - - # ============================================================ - # GROUP-LEVEL WEIGHT UNCERTAINTY - # ============================================================ - """ - Group-level weights represent the relative importance of the - main dimensions of the decision problem: - - CRM - - Circularity - - Environmental - - Manufacturer - - These weights are denoted as: - W_g - and must satisfy: - W_g >= 0 - sum_g W_g = 1 - - In the "bounded" sampling mode, lower and upper bounds are - imposed on each group weight. - In the "ordered" sampling mode, ordinal constraints are imposed. - These can also include preference intensities. - """ - # ============================================================ - - group_names = tuple(groups) - - group_lb = { - "CRM": 0.10, - "Circularity": 0.10, - "Environmental": 0.20, - "Manufacturer": 0.05 - } - - group_ub = { - "CRM": 0.40, - "Circularity": 0.40, - "Environmental": 0.60, - "Manufacturer": 0.25 - } - - group_order_constraints = [ - ("Environmental", "Manufacturer", 1.5), - ("CRM", "Manufacturer", 1.2), - ("Circularity", "Manufacturer", 1.0), - ] - - # ============================================================ - # LOCAL CRITERION-LEVEL WEIGHT UNCERTAINTY - # ============================================================ - """ - Local weights represent the importance of criteria within - each group. - - For a criterion j belonging to group g, the local weight is: - w_{j|g} - and must satisfy, within each group: - w_{j|g} >= 0 - sum_{j in G_g} w_{j|g} = 1 - - In the hierarchical model, the final global criterion weight is: - w_j = W_g * w_{j|g} - - where: - W_g = sampled group weight - w_{j|g} = sampled local weight of criterion j within group g - - In the "bounded" sampling mode, local lower and upper bounds - are imposed. - In the "ordered" sampling mode, local ordinal constraints and - preference intensities are imposed. - - A local ordinal constraint can be specified as: - ("A", "B") - meaning: - w_A >= w_B - or as: - ("A", "B", intensity) - meaning: - w_A >= intensity * w_B - """ - - local_lb = { - "CRM": { - "Ni concentration (%)": 0.10, - "Li concentration (%)": 0.05, - "Mg concentration (%)": 0.05, - "Ti concentration (%)": 0.05, - "Cu concentration (%)": 0.10, - }, - "Circularity": { - "Recycled input (kg/kg)": 0.40, - "Waste output (kg/kg)": 0.20, - }, - "Environmental": { - "Climate change (GWP100)": 0.15, - "Acidification (AE)": 0.05, - "Eutrophication Freshwater (P)": 0.05, - "Particulate Matter (human health)": 0.05, - "LandUse (soil quality index)": 0.05, - "WaterUse (m³ world eq deprived)": 0.10, - "Ionising radiation (kBq U-235 eq)": 0.05, - }, - "Manufacturer": { - "Operator": 1.00, - } - } - - local_ub = { - "CRM": { - "Ni concentration (%)": 0.40, - "Li concentration (%)": 0.30, - "Mg concentration (%)": 0.25, - "Ti concentration (%)": 0.25, - "Cu concentration (%)": 0.40, - }, - "Circularity": { - "Recycled input (kg/kg)": 0.80, - "Waste output (kg/kg)": 0.60, - }, - "Environmental": { - "Climate change (GWP100)": 0.35, - "Acidification (AE)": 0.20, - "Eutrophication Freshwater (P)": 0.20, - "Particulate Matter (human health)": 0.20, - "LandUse (soil quality index)": 0.20, - "WaterUse (m³ world eq deprived)": 0.30, - "Ionising radiation (kBq U-235 eq)": 0.20, - }, - "Manufacturer": { - "Operator": 1.00, - } - } - - local_order_constraints = { - "CRM": [ - ("Ni concentration (%)", "Li concentration (%)"), - ("Ni concentration (%)", "Mg concentration (%)"), - ("Cu concentration (%)", "Mg concentration (%)"), - ("Cu concentration (%)", "Ti concentration (%)"), - ], - "Circularity": [ - ("Recycled input (kg/kg)", "Waste output (kg/kg)"), - ], - "Environmental": [ - ("Climate change (GWP100)", "Acidification (AE)"), - ("Climate change (GWP100)", "Eutrophication Freshwater (P)"), - ("Climate change (GWP100)", "Particulate Matter (human health)"), - ("Climate change (GWP100)", "LandUse (soil quality index)"), - ("Climate change (GWP100)", "Ionising radiation (kBq U-235 eq)"), - ("WaterUse (m³ world eq deprived)", "Ionising radiation (kBq U-235 eq)"), - ], - "Manufacturer": [] - } From eaba703cb7d1a15506a880999c0971081c3dc5e7 Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Thu, 16 Jul 2026 20:52:58 +0200 Subject: [PATCH 09/15] DSS user interface + restructure weight constraints + init_data --- mysite/dss/forms.py | 360 ++++++++++++++++++ mysite/dss/mcda.py | 50 ++- mysite/dss/migrations/0001_initial.py | 95 +++++ mysite/dss/migrations/0002_rename_group.py | 17 + .../migrations/0003_alter_criterion_fields.py | 39 ++ .../0004_alter_localorder_grouporder.py | 38 ++ mysite/dss/models.py | 223 ++++++++++- mysite/dss/serializers.py | 93 ++++- mysite/dss/templates/dss/step_1.html | 62 +++ mysite/dss/templates/dss/step_2.html | 276 ++++++++++++++ mysite/dss/templates/dss/step_3.html | 202 ++++++++++ .../dss/templates/partials/radio_group.html | 53 +++ .../templates/partials/section_header.html | 13 + .../dss/templates/partials/weight_field.html | 24 ++ mysite/dss/templatetags/__init__.py | 0 mysite/dss/templatetags/dss_tags.py | 8 + mysite/dss/urls.py | 8 + mysite/dss/views.py | 339 ++++++++++++++++- mysite/init_data/country_data.csv | 3 + mysite/init_data/material_data.csv | 5 + mysite/mysite/settings.py | 1 + mysite/mysite/urls.py | 5 +- 22 files changed, 1870 insertions(+), 44 deletions(-) create mode 100644 mysite/dss/forms.py create mode 100644 mysite/dss/migrations/0001_initial.py create mode 100644 mysite/dss/migrations/0002_rename_group.py create mode 100644 mysite/dss/migrations/0003_alter_criterion_fields.py create mode 100644 mysite/dss/migrations/0004_alter_localorder_grouporder.py create mode 100644 mysite/dss/templates/dss/step_1.html create mode 100644 mysite/dss/templates/dss/step_2.html create mode 100644 mysite/dss/templates/dss/step_3.html create mode 100644 mysite/dss/templates/partials/radio_group.html create mode 100644 mysite/dss/templates/partials/section_header.html create mode 100644 mysite/dss/templates/partials/weight_field.html create mode 100644 mysite/dss/templatetags/__init__.py create mode 100644 mysite/dss/templatetags/dss_tags.py create mode 100644 mysite/dss/urls.py create mode 100644 mysite/init_data/country_data.csv create mode 100644 mysite/init_data/material_data.csv diff --git a/mysite/dss/forms.py b/mysite/dss/forms.py new file mode 100644 index 0000000..d672328 --- /dev/null +++ b/mysite/dss/forms.py @@ -0,0 +1,360 @@ +from django import forms +from django.forms import inlineformset_factory +from .models import McdaSession, GroupOrder, LocalOrder + + +# ------------------------------------------------------------------ +# 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 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, + ) + veto_type = forms.ChoiceField( + choices=McdaSession.VetoType.choices, widget=forms.RadioSelect, + ) + + 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.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)) + + # -- 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 == hierarchical --------------- + if session.weight_mode == "hierarchical": + for criterion in criteria: + self.fields[f"local_weight_{criterion}"] = WeightField( + label=f"Local 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 + ) + + # -- sampling_mode: weight_mode == "group" -------------------- + if session.weight_mode == "group": + self.fields["sampling_mode"] = forms.ChoiceField( + choices=McdaSession.SamplingMode.choices, + widget=forms.RadioSelect, + ) + + def clean(self): + cleaned = super().clean() + session = self.session + + 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 == "hierarchical": + 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.") + + if session.weight_mode == "group" and not cleaned.get("sampling_mode"): + self.add_error("sampling_mode", "Required when weight mode is group.") + + 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 == "hierarchical": + session.local_weights = {c: d[f"local_weight_{c}"] for c in criteria} + elif session.weight_mode == "group": + session.sampling_mode = d["sampling_mode"] + + 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 index cdb64c1..8240c62 100644 --- a/mysite/dss/mcda.py +++ b/mysite/dss/mcda.py @@ -7,45 +7,41 @@ from typing import Literal, Optional from . import plot -from .serializers import McdaRequestSerializer # Aliases for reused data structures -Criteria = dict[str, float | tuple[float, float]] +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 weight costraints in terms of - upper and lower bounds for (groups of) criteria. + Class for storing weights of groups and/or criteria within groups. + Weight can be a single value or a range. + The ordering of groups or criteria can also be specified. """ - group_lb: Optional[Bounds] = None - group_ub: Optional[Bounds] = None + group: Optional[RangeDict] = None group_order: Optional[OrderConstraint] = None - local_lb: Optional[dict[str, Bounds]] = None - local_ub: Optional[dict[str, Bounds]] = None + local: Optional[RangeDict] = None local_order: Optional[dict[str, OrderConstraint]] = None @dataclass(slots=True) class McdaConfig: # General df: pd.DataFrame - # decision_matrix: dict[str, Criteria] + # 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 = McdaRequestSerializer.WEIGHT_CHOICES[0] + weight_mode: str = "flat" groups: Optional[dict[str, list[str]]] = None - group_weights: Optional[dict[str, float]] = None - local_weights: Optional[dict[str, float]] = None _weights: Optional[dict[str, float]] = None # PROMETHEE parameters - thresholds: Optional[Criteria] = None + thresholds: Optional[RangeDict] = None veto_type: str = "no" _use_veto: bool = False veto_thresholds: Optional[Bounds] = None @@ -211,14 +207,14 @@ def set_fixed_weights(config: McdaConfig): elif config.weight_mode == "group": weights = {} for g, crits in config.groups.items(): - wg = config.group_weights[g] + wg = config.constraints.group[g] for c in crits: weights[c] = wg / len(crits) elif config.weight_mode == "hierarchical": weights = {} for g, crits in config.groups.items(): - Wg = config.group_weights[g] + Wg = config.constraints.group[g] total_local = sum(config.local_weights[g].values()) for c in crits: w_local = config.local_weights[g][c] / total_local @@ -530,12 +526,10 @@ def sample_smaa_weights(config: McdaConfig, sampler_stats={}, rng=None): if rng is None: rng = np.random.default_rng() - active_constr = get_active_constraints(config) - if config.weight_mode == "flat": criterion_lb = {c: 0.0 for c in config.criteria} criterion_ub = {c: 1.0 for c in config.criteria} - final_weights = sample_weights_dirichlet_constrained( + config._weights = sample_weights_dirichlet_constrained( lb_dict=criterion_lb, ub_dict=criterion_ub, order_cons=[], @@ -544,9 +538,9 @@ def sample_smaa_weights(config: McdaConfig, sampler_stats={}, rng=None): stats=sampler_stats, stats_key="flat_weights" ) - return final_weights elif config.weight_mode == "group": + active_constr = get_active_constraints(config) group_weights_sampled = sample_weights_dirichlet_constrained( lb_dict=active_constr.group_lb, ub_dict=active_constr.group_ub, @@ -561,10 +555,10 @@ def sample_smaa_weights(config: McdaConfig, sampler_stats={}, rng=None): for g, crits in config.groups.items(): for c in crits: final_weights[c] = group_weights_sampled[g] / len(crits) - return final_weights + config._weights = final_weights elif config.weight_mode == "hierarchical": - return sample_hierarchical_weights( + config._weights = sample_hierarchical_weights( groups=config.groups, constraints=config.constraints, alpha_group=config.alpha_group, @@ -626,7 +620,6 @@ def promethee_nfs(config: McdaConfig, decision_matrix: pd.DataFrame=None): # 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" - config._use_veto = (config.veto_type != "no") alts = config.df.index.tolist() S = pd.DataFrame(0.0, index=alts, columns=alts) @@ -874,6 +867,7 @@ def run_smaa(config: McdaConfig, analysis_type="full_smaa", rng=None): random -> no specific constraints bounded -> lower/upper bounds ordered -> ordinal and intensity constraints + bounded_ordered -> bounds + constraints 3) Evaluate alternatives using PROMETHEE ------------------------------------- @@ -939,10 +933,10 @@ def run_smaa(config: McdaConfig, analysis_type="full_smaa", rng=None): ) # Sample one feasible weight vector - w = sample_smaa_weights(config, sampler_stats, rng) + sample_smaa_weights(config, sampler_stats, rng) for c in config.criteria: - weight_samples[c].append(w[c]) + weight_samples[c].append(config._weights[c]) # Evaluate alternatives using PROMETHEE nfs = promethee_nfs(config, decision_matrix=current_df) @@ -1067,12 +1061,12 @@ def mcda(config: McdaConfig): print(config.df) print("\nPairwise preference matrix S(a,b):") print(pairwise) - print("\nPROMETHEE flows and NFS:") + print("\nPROMETHEE flows and Net Flow Scores:") print(results) print("\nRanking (best to worst):") print(list(results.index)) - plot.deterministic_promethee_figures(results, pairwise) + # plot.deterministic_promethee_figures(results, pairwise) # ============================================================ # CASE 2: PERFORMANCE UNCERTAINTY ANALYSIS @@ -1109,7 +1103,7 @@ def mcda(config: McdaConfig): print("\nMean NFS:") print(results["mean_nfs"]) - plot.performance_uncertainty_figures(results) + # plot.performance_uncertainty_figures(results) # ============================================================ # UNCERTAIN SCENARIO EXECUTION @@ -1160,4 +1154,4 @@ def mcda(config: McdaConfig): print("\nPairwise outranking probabilities P(i outranks j) based on NFS:") print(results["outrank_probability"]) - plot.smaa_figures(results) + # 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/models.py b/mysite/dss/models.py index 71a8362..afc7151 100644 --- a/mysite/dss/models.py +++ b/mysite/dss/models.py @@ -1,3 +1,224 @@ from django.db import models +from .mcda import McdaConfig, WeightConstraints +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator -# Create your models here. + +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) + + # 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) + 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) + sampling_mode = models.CharField(max_length=15, choices=SamplingMode, null=True) + + # 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], ...] + + 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() + + + def build_config(self) -> McdaConfig: + import pandas as pd + criteria = list(self.criteria.all()) + 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="criteria") + 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: + verbose_name_plural = "Criteria" + unique_together = ['name', 'group'] + + 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)]) + + 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)]) + + 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/serializers.py b/mysite/dss/serializers.py index 11c8b11..4415aaf 100644 --- a/mysite/dss/serializers.py +++ b/mysite/dss/serializers.py @@ -1,4 +1,63 @@ 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 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 McdaRequestSerializer(serializers.Serializer): SCENARIO_CHOICES = ["uncertain", "deterministic"] @@ -7,7 +66,7 @@ class McdaRequestSerializer(serializers.Serializer): VETO_CHOICES = ["no", "soft", "hard"] SAMPLING_CHOICES = ["random", "bounded", "ordered", "bounded_ordered"] - decision_matrix = serializers.DictField() # {"alternative": {"criterion": value},} + decision_matrix = serializers.DictField() # {"alternative": {"criterion": value|(min, max)},} directions = serializers.DictField() # {"criterion": "min"|"max"|value} scenario = serializers.ChoiceField(choices=SCENARIO_CHOICES) @@ -49,6 +108,12 @@ def validate(self, data): "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() @@ -100,6 +165,30 @@ def validate(self, data): return data +# Serializers for output data + +class GroupOrderSerializer(serializers.ModelSerializer): + group1_name = serializers.CharField(source='group1.name') + group2_name = serializers.CharField(source='group2.name') + + class Meta: + model = GroupOrder + fields = ['group1_name', 'group2_name', 'intensity'] + + def to_representation(self, instance): + return (instance.group1.name, instance.group2.name, instance.intensity) + +class LocalOrderSerializer(serializers.ModelSerializer): + criterion1_name = serializers.CharField(source='criterion1.name') + criterion2_name = serializers.CharField(source='criterion2.name') + + class Meta: + model = GroupOrder + fields = ['criterion1_name', 'criterion2_name', 'intensity'] + + def to_representation(self, instance): + return (instance.criterion1.name, instance.criterion2.name, instance.intensity) + class IndicatorScoresSerializer(serializers.Serializer): indicator = serializers.CharField() score = serializers.FloatField() @@ -110,5 +199,5 @@ class ExperimentRankingSerializer(serializers.Serializer): score = serializers.IntegerField() indicators = IndicatorScoresSerializer -class McdaResponseSerializer(serializers.Serializer): +class McdaResultSerializer(serializers.Serializer): experiment_ranking = serializers.ListField(child=ExperimentRankingSerializer()) diff --git a/mysite/dss/templates/dss/step_1.html b/mysite/dss/templates/dss/step_1.html new file mode 100644 index 0000000..bf22cf8 --- /dev/null +++ b/mysite/dss/templates/dss/step_1.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block title %}Step 1 — Analysis Setup{% endblock %} + +{% block content %} +
+ + {# ── Progress bar ── #} +
+
+
+ +
+ + {# ── Header ── #} +
+

Step 1 of 3

+

Analysis setup

+

+ These four choices drive which options appear in the next step. +

+
+ +
+ {% csrf_token %} + + {# ── Non-field errors ── #} + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + + {# ── Scenario ── #} + {% include "partials/radio_group.html" with field=form.scenario label="Scenario" options=scenario_options %} + + {# ── Method ── #} + {% include "partials/radio_group.html" with field=form.method label="MCDA method" options=method_options %} + + {# ── Weight mode ── #} + {% include "partials/radio_group.html" with field=form.weight_mode label="Weight mode" options=weight_mode_options %} + + {# ── Veto type ── #} + {% include "partials/radio_group.html" with field=form.veto_type label="Veto type" options=veto_type_options %} + + {# ── Submit ── #} +
+ +
+ +
+
+
+{% endblock %} diff --git a/mysite/dss/templates/dss/step_2.html b/mysite/dss/templates/dss/step_2.html new file mode 100644 index 0000000..1c8e43e --- /dev/null +++ b/mysite/dss/templates/dss/step_2.html @@ -0,0 +1,276 @@ +{% extends "base.html" %} +{% load dss_tags %} +{% block title %}Step 2 — Weights & Thresholds{% endblock %} + +{% block content %} +
+ + {# ── Progress bar ── #} +
+
+
+ +
+ + {# ── Header ── #} +
+

Step 2 of 3

+

Weights & thresholds

+

+ Weights accept a single value 0.5 + or a range 0.2, 0.8. + All values must be positive. +

+
+ + {# ── Session context pills ── #} +
+ + Decision data: {{ session.get_scenario_display }} + + + Method: {{ session.get_method_display }} + + + Weights: {{ session.get_weight_mode_display }} + + + Veto: {{ session.get_veto_type_display }} + +
+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + + {% comment %} + SECTION 1 — Sampling mode + Visible when weight_mode == "group" + {% endcomment %} + {% if session.weight_mode == "group" %} + {% include "partials/section_header.html" with title="Sampling mode" hint="Decide how to draw samples from weight and/or KPI values, for Monte Carlo simulation." %} + + {% with field=form.sampling_mode %} +
+ {% for opt in sampling_mode_options %} + + {% endfor %} +
+ {% if field.errors %} +

{{ field.errors|join:", " }}

+ {% endif %} + {% endwith %} + {% endif %} + + {% comment %} + SECTION 2 — Group weights + Visible when weight_mode in {group, hierarchical} + {% endcomment %} + {% if session.weight_mode in "group,hierarchical" %} + {% include "partials/section_header.html" with title="Group weights" hint="Relative importance of each pre-defined group. Enter a value or range." %} + +
+ {% for group in groups %} + {% with fname="group_weight_"|add:group %} + {% include "partials/weight_field.html" with field=form|get_field:fname label=group|capfirst %} + {% endwith %} + {% endfor %} +
+ {% endif %} + + {% comment %} + SECTION 3 — Local weights + Visible when weight_mode == hierarchical + {% endcomment %} + {% if session.weight_mode == "hierarchical" %} + {% include "partials/section_header.html" with title="Local weights" hint="Weight of each criterion within its group." %} + +
+ {% for criterion in criteria %} + {% with fname="local_weight_"|add:criterion %} + {% include "partials/weight_field.html" with field=form|get_field:fname label=criterion %} + {% endwith %} + {% endfor %} +
+ {% endif %} + + {% comment %} + SECTION 4 — Thresholds (always) + Shape differs between promethee (range) and others (float) + {% endcomment %} + {% include "partials/section_header.html" with title="Thresholds" hint=threshold_hint %} + +
+
+ Criterion + + {% if session.method == "promethee" %}Indifference, Preference{% else %}Threshold{% endif %} + + Target +
+ + {% for criterion, target in criteria.items %} + {% with fname="threshold_"|add:criterion %} +
+ +
+ {{ criterion }} +
+ +
+ {% with field=form|get_field:fname %} + + {% if field.errors %} +

{{ field.errors|join:", " }}

+ {% endif %} + {% endwith %} +
+ +
+ {% if target == "max" %} + max + + {% elif target == "min" %} + min + + {% else %} + {{target}} + + {% endif %} +
+ +
+ {% endwith %} + {% endfor %} +
+ + {% comment %} + SECTION 5 — Veto thresholds + Visible when veto_type != "no" + {% endcomment %} + {% if session.veto_type != "no" %} + {% include "partials/section_header.html" with title="Veto thresholds" hint="Maximum tolerable disadvantage per criterion." %} + {% comment %} +
+ {% for criterion in criteria %} + {% with fname="veto_"|add:criterion %} + {% with field=form|get_field:fname %} +
+ +
+ + {% if field.errors %} +

{{ field.errors|join:", " }}

+ {% endif %} +
+
+ {% endwith %} + {% endwith %} + {% endfor %} +
+ {% endcomment %} + + {# ── Penalty factor — only for soft veto ── #} + {% if session.veto_type == "soft" %} +
+ {% with field=form.penalty_factor %} + +

+ Scales the severity of the soft veto. Must be positive. +

+ + {% if field.errors %} +

{{ field.errors|join:", " }}

+ {% endif %} + {% endwith %} +
+ {% endif %} + {% endif %} + + {# ── Navigation ── #} +
+ + + + + Back + + +
+ +
+
+
+{% endblock %} diff --git a/mysite/dss/templates/dss/step_3.html b/mysite/dss/templates/dss/step_3.html new file mode 100644 index 0000000..5d100e5 --- /dev/null +++ b/mysite/dss/templates/dss/step_3.html @@ -0,0 +1,202 @@ +{% extends "base.html" %} +{% block title %}Step 3 — Sampling settings{% endblock %} + +{% block content %} +
+ + {# ── Progress bar ── #} +
+
+
+ +
+ + {# ── Header ── #} +
+

Step 3 of 3

+

Sampling settings

+

+ To assess the effect of uncertain parameters, sampling and uncertainty parameters + are configured here. +

+
+ + {# ── Session context pills ── #} +
+ + Decision data: {{ session.get_scenario_display }} + + + Method: {{ session.get_method_display }} + + + Weights: {{ session.get_weight_mode_display }} + + + Veto: {{ session.get_veto_type_display }} + +
+ +
+ {% csrf_token %} + + {# ── Non-field errors ── #} + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + + {% include "partials/section_header.html" with title="Sampling parameters" hint="All values must be positive." %} + + {{ form.as_p }} + + {% include "partials/section_header.html" with title="Ordering constraints" hint="Ordering the importance of groups or criteria relative to each other." %} + {% if group_order_formset %} + {{ group_order_formset.management_form }} + + + + + + {% for row in group_order_formset %} + + + + + + + {% endfor %} + +
Superior GroupInferior GroupIntensityRemove
{{ row.group1 }}{{ row.group2 }}{{ row.intensity }}{{ row.DELETE }}
+ + {% endif %} + + {% if local_order_formset %} + {{ local_order_formset.management_form }} + + + + + + {% for row in local_order_formset %} + + + + + + + {% endfor %} + +
Superior CriterionInferior CriterionIntensityRemove
{{ row.criterion1 }}{{ row.criterion2 }}{{ row.intensity }}{{ row.DELETE }}
+ + {% endif %} + + {# ── Navigation ── #} +
+ + + + + Back + + +
+ +
+
+
+ + +{% endblock %} diff --git a/mysite/dss/templates/partials/radio_group.html b/mysite/dss/templates/partials/radio_group.html new file mode 100644 index 0000000..dfded66 --- /dev/null +++ b/mysite/dss/templates/partials/radio_group.html @@ -0,0 +1,53 @@ +{% comment %} + Partial: radio_group.html + Variables: + field — form field (for id, name, errors) + label — display label + options — list of dicts: [{value, label, hint}] + hint is optional; used for a short explanatory line below the label +{% endcomment %} +
+ + {{ label }} + {% if field.errors %} + {{ field.errors|join:", " }} + {% endif %} + + +
+ {% for opt in options %} + + {% endfor %} +
+
diff --git a/mysite/dss/templates/partials/section_header.html b/mysite/dss/templates/partials/section_header.html new file mode 100644 index 0000000..0513384 --- /dev/null +++ b/mysite/dss/templates/partials/section_header.html @@ -0,0 +1,13 @@ +{% comment %} + Partial: section_header.html + Variables: + title — section title + hint — one-line explanation shown below the title +{% endcomment %} +
+

{{ title }}

+ +
+{% if hint %} +

{{ 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 %} +
+ + + {% if field.errors %} +

{{ field.errors|join:", " }}

+ {% endif %} +
diff --git a/mysite/dss/templatetags/__init__.py b/mysite/dss/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mysite/dss/templatetags/dss_tags.py b/mysite/dss/templatetags/dss_tags.py new file mode 100644 index 0000000..b8d55d8 --- /dev/null +++ b/mysite/dss/templatetags/dss_tags.py @@ -0,0 +1,8 @@ +from django import template + +register = template.Library() + +@register.filter +def get_field(form, field_name: str): + """{{ form|get_field:"group_weight_economic" }} → the BoundField.""" + return form[field_name] diff --git a/mysite/dss/urls.py b/mysite/dss/urls.py new file mode 100644 index 0000000..d576892 --- /dev/null +++ b/mysite/dss/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import ExperimentComparisonInitView, McdaWizardView, ExperimentResultsView + +urlpatterns = [ + path("experiments/", ExperimentComparisonInitView.as_view(), name="experiment-init"), + path("/step//", McdaWizardView.as_view(), name="wizard"), + path("results//", ExperimentResultsView.as_view(), name="results"), +] diff --git a/mysite/dss/views.py b/mysite/dss/views.py index 653914a..790b33b 100644 --- a/mysite/dss/views.py +++ b/mysite/dss/views.py @@ -1,13 +1,334 @@ import pandas as pd -from django.shortcuts import render -from rest_framework.views import APIView +from django.shortcuts import get_object_or_404, redirect, render, reverse +from django.views.generic import DetailView +from rest_framework.views import APIView, View from rest_framework.response import Response from rest_framework import status -from .serializers import McdaRequestSerializer, McdaResponseSerializer +from .serializers import ExperimentComparisonSerializer, McdaRequestSerializer, McdaResultSerializer from .mcda import mcda, McdaConfig, WeightConstraints +from .models import McdaSession, DecisionMatrix, Criterion, CritGroup +from .forms import BaseConfigForm, WeightsThresholdsForm, SamplingConfigForm, GroupOrderFormSet, LocalOrderFormSet +# wages, electricity carbon intensity, electricity price +country_data = pd.read_csv("init_data/country_data.csv", index_col="country") +# price, carbon footprint of consumables +material_data = pd.read_csv("init_data/material_data.csv", index_col="material") + +# Helper functions + +def lookup(info: str, country: str): + """Find the info for country in country_data + """ + if country in country_data.index: + value = country_data.loc[country, info] + if value: + return value + return country_data.loc["?", info] + +def create_default_criteria(session: McdaSession): + """Create default groups and criteria""" + cost = CritGroup.objects.create(session=session, name="Economic") + sust = CritGroup.objects.create(session=session, name="Sustainability") + circ = CritGroup.objects.create(session=session, name="Circularity", weight=0) + qual = CritGroup.objects.create(session=session, name="Technical quality") + Criterion.objects.create(session=session, name="Operating costs", group=cost, direction="min") + Criterion.objects.create(session=session, name="Process energy use", group=sust, direction="min") + Criterion.objects.create(session=session, name="Process carbon footprint", group=sust, direction="min") + return qual + +def calculate_kpis(valid_data: list) -> McdaSession: + # Start a session and initiate groups and criteria + session = McdaSession.objects.create() + quality_group = create_default_criteria(session) + for kpi in valid_data[0]["qualityParameters"]: + if not Criterion.objects.filter(name=kpi["name"]).exists(): + Criterion.objects.create( + session=session, name=kpi["name"], + group=quality_group, direction=kpi["target"], + ) + + # Extract data from all experiments + for exp in valid_data: + multipliers = {"h": 1/3600, "min": 1/60, "s": 1, "kg": 1, "g": 1/1000, "mg": 10**-6, "m3": 1, "L": 1/1000} + processing_time = exp["weldLength"] * exp["weldSpeed"] #TODO: check units + consumables_use = {} + for cons in exp["consumables"]: + qnt_unit, time_unit = cons["unit"].split("/") + if qnt_unit not in multipliers: + raise RuntimeError(f"Cannot process unit '{qnt_unit}'") + elif time_unit not in multipliers: + raise RuntimeError(f"Cannot process unit '{time_unit}'") + elif cons["name"] not in material_data.index: + raise RuntimeError(f"Material '{cons["name"]}' not recognised.") + amount = multipliers[qnt_unit] * cons["flowRate"] * \ + processing_time * multipliers[time_unit] + consumables_use[cons["name"]] = amount + + consumables_costs = sum([ + amount * material_data.loc[cons, "price"] + for cons, amount in consumables_use.items() + ]) + labour_costs = processing_time/3600 * lookup("wages", exp["country"]) * 3 + process_energy_use = processing_time/3600 * ( + exp["laserPowerkW"] + exp["weldingStationPowerkW"] + ) + operating_costs = consumables_costs + labour_costs + ( + process_energy_use * lookup("electricity_price", exp["country"]) + ) + consumables_footprint = sum([ + amount * material_data.loc[cons, "CO2eq"] + for cons, amount in consumables_use.items() + ]) + carbon_footprint = consumables_footprint + ( + process_energy_use * lookup("electricity_CO2eq", exp["country"]) + ) + + # Create decision matrix + kpi_dict = { + "Process energy use": process_energy_use, + "Operating costs": operating_costs, + "Process carbon footprint": carbon_footprint, + } + for kpi in exp["qualityParameters"]: + kpi_dict[kpi["name"]] = kpi["value"] + DecisionMatrix.objects.create( + session=session, name=exp["experimentId"], values=kpi_dict + ) + return session + + +def step1_context() -> dict: + """ + Static context for step 1 form. + Each option dict: {value, label, hint}. + Hints help users who aren't MCDA experts. + """ + return { + "scenario_options": [ + {"value": "deterministic", "label": "Deterministic", + "hint": "Criteria have a fixed importance."}, + {"value": "uncertain", "label": "Uncertain", + "hint": "Criteria weights are given as ranges."}, + # {"value": "stochastic", "label": "Stochastic", + # "hint": "Weights drawn from distributions."}, + ], + "method_options": [ + {"value": "promethee", "label": "PROMETHEE", + "hint": "Define indifference and preference thresholds."}, + {"value": "promethee_like", "label": "PROMETHEE-like", + "hint": "Define the preference of criteria."}, + ], + "weight_mode_options": [ + {"value": "flat", "label": "Flat", + "hint": "One weight per criterion."}, + {"value": "group", "label": "Group", + "hint": "Criteria are grouped; weights are equal within a group."}, + {"value": "hierarchical", "label": "Hierarchical", + "hint": "Group weights × local criterion weights."}, + ], + "veto_type_options": [ + {"value": "no", "label": "None", "hint": "No veto applied"}, + {"value": "hard", "label": "Hard", "hint": "Disqualify alternatives"}, + {"value": "soft", "label": "Soft", "hint": "Penalise alternatives"}, + ], + } + + +def step2_context(session) -> dict: + """ + Dynamic context for step 2 — depends on session choices. + """ + criteria = session.criteria.all() + directions = {crit.name: crit.direction for crit in criteria} + group_names = list(session.groups.values_list("name", flat=True)) + + threshold_hint = ( + "PROMETHEE requires an indifference and preference threshold per criterion " + "(enter as a range: q, p)." + if session.method == "promethee" + else "Enter the threshold value for each criterion." + ) + + sampling_mode_options = [ + {"value": "random", "label": "Random", + "hint": "No bounds or constraints are imposed."}, + {"value": "bounded", "label": "Bounded", + "hint": "Lower and upper bounds are imposed."}, + {"value": "ordered", "label": "Ordered", + "hint": "A preference order of the importance of groups is imposed."}, + {"value": "bounded_ordered", "label": "Bounded ordered", + "hint": "Both lower and upper bounds, and a preference order imposed."}, + ] + + return { + "criteria": directions, + "groups": group_names, + "threshold_hint": threshold_hint, + "sampling_mode_options": sampling_mode_options, + } + +class ExperimentComparisonInitView(APIView): + def post(self, request): + serializer = ExperimentComparisonSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + session = calculate_kpis(serializer.validated_data) + + return redirect(reverse("wizard", kwargs={"session_id":session.id, "step":1})) + return Response({"session_id": session.id}, status=status.HTTP_201_CREATED) + +class McdaWizardView(View): + def _next_step(self, current_step: int, session: McdaSession) -> int | None: + """Decision tree: select the next form""" + if current_step == 1: + return 2 # Form 2 is always shown (thresholds are always required) + if current_step == 2: + if session.scenario != "deterministic": + return 3 + return None # deterministic: skip Form 3 + return None # step 3 is always the last + + def _ask_for_orders(self, session: McdaSession) -> bool: + return ( + session.scenario == "uncertain" and + session.sampling_mode in ["ordered", "bounded_ordered"] + ) + + def _get_order_formsets(self, session, data=None): + """Instantiate both formsets with querysets scoped to this session.""" + session_groups = CritGroup.objects.filter(session=session) + session_criteria = Criterion.objects.filter(session=session) + + group_formset = GroupOrderFormSet(data, instance=session, prefix="gfs") + for form in group_formset.forms: + form.fields["group1"].queryset = session_groups + form.fields["group2"].queryset = session_groups + + local_formset = LocalOrderFormSet(data, instance=session, prefix="lfs") + for form in local_formset.forms: + form.fields["criterion1"].queryset = session_criteria + form.fields["criterion2"].queryset = session_criteria + + return group_formset, local_formset + + def get_form(self, step: int, session: McdaSession, data=None): + """Returns the right form for the current step.""" + if step == 1: + return BaseConfigForm(data) + elif step == 2: + return WeightsThresholdsForm(session, data) + elif step == 3: + return SamplingConfigForm(session, data) + + def get_context(self, step: int, session: McdaSession, **kwargs): + if step == 1: + context = step1_context() + elif step == 2: + context = step2_context(session) + elif step == 3: + if self._ask_for_orders(session): + context = dict(zip( + ["group_order_formset", "local_order_formset"], + self._get_order_formsets(session), + )) + for formset in ["group_order_formset", "local_order_formset"]: + if formset in kwargs: + context[formset] = kwargs[formset] + else: + context = {} + context.update(kwargs) + context["session"] = session + return context + + def get(self, request, session_id: int, step: int): + session = get_object_or_404(McdaSession, pk=session_id) + form = self.get_form(step, session) + context = self.get_context(step, session=session, form=form) + return render(request, f"dss/step_{step}.html", context) + + def post(self, request, session_id: int, step: int): + session = get_object_or_404(McdaSession, pk=session_id) + form = self.get_form(step, session, data=request.POST) + + if step == 3 and self._ask_for_orders(session): + group_order, local_order = self._get_order_formsets( + session, data=request.POST + ) + else: + group_order = local_order = None + + formsets_valid = ( + (group_order is None or group_order.is_valid()) and + (local_order is None or local_order.is_valid()) + ) + if not form.is_valid() or not formsets_valid: + context = self.get_context( + step, session, form=form, group_order_formset=group_order, local_order_formset=local_order + ) + return render(request, f"dss/step_{step}.html", context) + + # Save the form and GroupOrder + LocalOrder formsets to tables + form.save(session) + if group_order: + group_order.save() + if local_order: + local_order.save() + + next_step = self._next_step(step, session) + if next_step: + return redirect("wizard", session_id=session_id, step=next_step) + + # All steps done. Run the MCDA calculations + config = session.build_config() + result = mcda(config) + response_data = McdaResultSerializer(result).data + return redirect("results", session_id=session_id) + return Response(response_data, status=status.HTTP_200_OK) + +class ExperimentResultsView(DetailView): + model = "ExperimentResults" + +def parse_request(valid_data: dict) -> McdaSession: + """ + Converts validated serializer output → ORM models. + Called once when the initial API request comes in. + """ + session = McdaSession.objects.create() + + if valid_data["groups"] is not None: + for criterion_name, direction in valid_data["directions"].items(): + Criterion(session, name=criterion_name, direction=direction).save() + for group_name, criteria in valid_data["groups"].items(): + group = CritGroup(session, name=group_name) + group.save() + Criterion.objects.filter(name__in=criteria).update(group=group) + + quality_grp = create_default_criteria(session) + + for alt_name, values in valid_data["decision_matrix"].items(): + DecisionMatrix(session=session, name=alt_name, values=values).save() + + for criterion_name, direction in valid_data["directions"].items(): + if not Criterion.objects.filter(name=criterion_name).exists(): + Criterion( + session, name=criterion_name, group=quality_grp, direction=direction + ).save() + + return session + +class McdaInitView(APIView): + def post(self, request): + serializer = McdaRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + session = parse_request(serializer.validated_data) + + return Response({"session_id": session.id}, status=status.HTTP_201_CREATED) + + +#TODO: remove this View class McdaCalculationView(APIView): def post(self, request): request_serializer = McdaRequestSerializer(data=request.data) @@ -22,11 +343,9 @@ def post(self, request): try: constraints = WeightConstraints( - group_lb=data.get("group_lb"), - group_ub=data.get("group_ub"), + group=data.get("group_weights"), group_order=data.get("group_order_constraints"), - local_lb=data.get("local_lb"), - local_ub=data.get("local_ub"), + local=data.get("local_weights"), local_order=data.get("local_order_constraints"), ) config = McdaConfig( @@ -36,8 +355,6 @@ def post(self, request): method=data["method"], weight_mode=data["weight_mode"], groups=data.get("groups"), - group_weights=data.get("group_weights"), - local_weights=data.get("local_weights"), thresholds=data.get("thresholds"), # Veto settings @@ -59,5 +376,5 @@ def post(self, request): except ValueError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) - response_serializer = McdaResponseSerializer(result) - return Response(response_serializer.data, status=status.HTTP_200_OK) + results = McdaResultSerializer(result) + return Response(results.data, status=status.HTTP_200_OK) diff --git a/mysite/init_data/country_data.csv b/mysite/init_data/country_data.csv new file mode 100644 index 0000000..1f9a6ac --- /dev/null +++ b/mysite/init_data/country_data.csv @@ -0,0 +1,3 @@ +country,wages,electricity_price,electricity_CO2eq +DE,60,0.25,21 +?,70,0.214,23 diff --git a/mysite/init_data/material_data.csv b/mysite/init_data/material_data.csv new file mode 100644 index 0000000..df775ca --- /dev/null +++ b/mysite/init_data/material_data.csv @@ -0,0 +1,5 @@ +material,price,CO2eq,unit +Argon,13,9.47,m3 +Nitrogen,7.05,7.11,m3 +Aluminium (filler wire),100,88,kg +Steel,56,42,kg diff --git a/mysite/mysite/settings.py b/mysite/mysite/settings.py index 00efae9..4d4cd75 100644 --- a/mysite/mysite/settings.py +++ b/mysite/mysite/settings.py @@ -63,6 +63,7 @@ 'api', 'accounts', 'dpp.apps.DppConfig', + 'dss', 'rest_framework', ] diff --git a/mysite/mysite/urls.py b/mysite/mysite/urls.py index 06be71e..08f0b25 100644 --- a/mysite/mysite/urls.py +++ b/mysite/mysite/urls.py @@ -19,8 +19,9 @@ urlpatterns = [ path('admin/', admin.site.urls), - path('api/', include("api.urls")), # API endpoints - path('dpp/', include("dpp.urls")), # Frontend application + path('api/', include("api.urls")), # API endpoints for DPP models + path('dpp/', include("dpp.urls")), # Frontend for DPP creation + path('dss/', include("dss.urls")), # API for decision support path("accounts/", include("accounts.urls")), # User registration path("accounts/", include("django.contrib.auth.urls")), # User login ] From cdfb15bd8d7659b7e4b877fd0584de4b05311f39 Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Fri, 17 Jul 2026 17:16:28 +0200 Subject: [PATCH 10/15] Update documentation and tests --- README.md | 86 ++++++++++++++++++++++++++++++++++++++++++++ mysite/dss/mcda.py | 16 +++++---- mysite/dss/tests.py | 88 ++++++++++++++------------------------------- 3 files changed, 123 insertions(+), 67 deletions(-) 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/dss/mcda.py b/mysite/dss/mcda.py index 8240c62..eec2494 100644 --- a/mysite/dss/mcda.py +++ b/mysite/dss/mcda.py @@ -208,6 +208,8 @@ def set_fixed_weights(config: McdaConfig): 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) @@ -215,9 +217,11 @@ def set_fixed_weights(config: McdaConfig): weights = {} for g, crits in config.groups.items(): Wg = config.constraints.group[g] - total_local = sum(config.local_weights[g].values()) + if isinstance(Wg, (list, tuple)): + Wg = sum(Wg) + total_local = sum(config.constraints.local[g].values()) for c in crits: - w_local = config.local_weights[g][c] / total_local + w_local = config.constraints.local[g][c] / total_local weights[c] = Wg * w_local config._weights = weights @@ -331,7 +335,7 @@ def sample_weights_dirichlet_constrained( w_more >= w_less 3) Optional preference intensity constraints: - w_more >= intensity * w_less + w_more >= intensity × w_less Notes ----- @@ -444,7 +448,7 @@ def sample_hierarchical_weights( Final global weights -------------------- The final criterion weight is obtained as: - w_j = W_g * w_{j|g} + w_j = W_g × w_{j|g} where criterion j belongs to group g. The optional sampler_stats object stores rejection sampling @@ -513,7 +517,7 @@ def sample_smaa_weights(config: McdaConfig, sampler_stats={}, rng=None): 3) hierarchical Group weights and local within-group weights are both sampled: - w_j = W_g * w_{j|g} + w_j = W_g × w_{j|g} This is the most flexible structured model, because it allows both group-level and criterion-level uncertainty. @@ -584,7 +588,7 @@ def promethee_nfs(config: McdaConfig, decision_matrix: pd.DataFrame=None): 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) + S(a,b) = sum_j w_j × P_j(a,b) where: w_j = criterion weight diff --git a/mysite/dss/tests.py b/mysite/dss/tests.py index af220f9..2c586da 100644 --- a/mysite/dss/tests.py +++ b/mysite/dss/tests.py @@ -187,18 +187,11 @@ def test_mcda(self): """ # ============================================================ - group_lb = { - "CRM": 0.10, - "Circularity": 0.10, - "Environmental": 0.20, - "Manufacturer": 0.05 - } - - group_ub = { - "CRM": 0.40, - "Circularity": 0.40, - "Environmental": 0.60, - "Manufacturer": 0.25 + group_bounds = { + "CRM": (0.10,0.40), + "Circularity": (0.10,0.40), + "Environmental": (0.20,0.60), + "Manufacturer": (0.05,0.25), } group_order_constraints = [ @@ -221,7 +214,7 @@ def test_mcda(self): sum_{j in G_g} w_{j|g} = 1 In the hierarchical model, the final global criterion weight is: - w_j = W_g * w_{j|g} + w_j = W_g × w_{j|g} where: W_g = sampled group weight @@ -239,58 +232,32 @@ def test_mcda(self): or as: ("A", "B", intensity) meaning: - w_A >= intensity * w_B + w_A >= intensity × w_B """ - local_lb = { - "CRM": { - "Ni concentration (%)": 0.10, - "Li concentration (%)": 0.05, - "Mg concentration (%)": 0.05, - "Ti concentration (%)": 0.05, - "Cu concentration (%)": 0.10, - }, - "Circularity": { - "Recycled input (kg/kg)": 0.40, - "Waste output (kg/kg)": 0.20, - }, - "Environmental": { - "Climate change (GWP100)": 0.15, - "Acidification (AE)": 0.05, - "Eutrophication Freshwater (P)": 0.05, - "Particulate Matter (human health)": 0.05, - "LandUse (soil quality index)": 0.05, - "WaterUse (m³ world eq deprived)": 0.10, - "Ionising radiation (kBq U-235 eq)": 0.05, - }, - "Manufacturer": { - "Operator": 1.00, - } - } - - local_ub = { + local_bounds = { "CRM": { - "Ni concentration (%)": 0.40, - "Li concentration (%)": 0.30, - "Mg concentration (%)": 0.25, - "Ti concentration (%)": 0.25, - "Cu concentration (%)": 0.40, + "Ni concentration (%)": (0.10,0.40), + "Li concentration (%)": (0.05,0.30), + "Mg concentration (%)": (0.05,0.25), + "Ti concentration (%)": (0.05,0.25), + "Cu concentration (%)": (0.10,0.40), }, "Circularity": { - "Recycled input (kg/kg)": 0.80, - "Waste output (kg/kg)": 0.60, + "Recycled input (kg/kg)": (0.40,0.80), + "Waste output (kg/kg)": (0.20,0.60), }, "Environmental": { - "Climate change (GWP100)": 0.35, - "Acidification (AE)": 0.20, - "Eutrophication Freshwater (P)": 0.20, - "Particulate Matter (human health)": 0.20, - "LandUse (soil quality index)": 0.20, - "WaterUse (m³ world eq deprived)": 0.30, - "Ionising radiation (kBq U-235 eq)": 0.20, + "Climate change (GWP100)": (0.15,0.35), + "Acidification (AE)": (0.05,0.20), + "Eutrophication Freshwater (P)": (0.05,0.20), + "Particulate Matter (human health)": (0.05,0.20), + "LandUse (soil quality index)": (0.05,0.20), + "WaterUse (m³ world eq deprived)": (0.10,0.30), + "Ionising radiation (kBq U-235 eq)": (0.05,0.20), }, "Manufacturer": { - "Operator": 1.00, + "Operator": (1.00,1.00), } } @@ -316,7 +283,8 @@ def test_mcda(self): } constraints = WeightConstraints( - group_lb, group_ub, group_order_constraints, local_lb, local_ub, local_order_constraints + group_bounds, group_order_constraints, + local_bounds, local_order_constraints, ) # ============================================================ @@ -349,8 +317,6 @@ def test_mcda(self): method=settings[1], weight_mode=settings[2], groups=groups, - group_weights=group_weights, - local_weights=local_weights, thresholds=thresholds, veto_type=settings[3], veto_thresholds=veto_thresholds, @@ -404,7 +370,7 @@ class Explanations(): "group": Group weights are fixed, and each group weight is distributed equally among the criteria belonging to that group. "hierarchical": Final criterion weights are obtained as: - final weight = group weight x local criterion weight + final weight = group weight × local criterion weight This allows criteria within a group to have different relative importance. """ @@ -473,7 +439,7 @@ class Explanations(): 3) hierarchical Both group-level weights and local within-group weights are sampled. - w_j = W_g * w_{j|g} + w_j = W_g × w_{j|g} This allows criteria in the same group to have different local importance. From 003b2a7df9b3ba862ee51dfef058e51ec9183683 Mon Sep 17 00:00:00 2001 From: Sander van Nielen Date: Wed, 5 Aug 2026 16:31:18 +0200 Subject: [PATCH 11/15] Add DSS step 0: criteria selection/exclusion --- .../migrations/0041_alter_transport_mode.py | 18 ++++++ mysite/dss/forms.py | 52 +++++++++++++++- ...05_criterion_used_mcdasession_user_type.py | 23 ++++++++ mysite/dss/models.py | 19 ++++-- mysite/dss/templates/dss/step_0.html | 59 +++++++++++++++++++ mysite/dss/templates/dss/step_1.html | 23 +++++--- mysite/dss/templates/dss/step_2.html | 6 +- mysite/dss/templates/dss/step_3.html | 6 +- mysite/dss/views.py | 18 ++++-- 9 files changed, 198 insertions(+), 26 deletions(-) create mode 100644 mysite/dpp/migrations/0041_alter_transport_mode.py create mode 100644 mysite/dss/migrations/0005_criterion_used_mcdasession_user_type.py create mode 100644 mysite/dss/templates/dss/step_0.html 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/forms.py b/mysite/dss/forms.py index d672328..b678101 100644 --- a/mysite/dss/forms.py +++ b/mysite/dss/forms.py @@ -1,6 +1,6 @@ from django import forms from django.forms import inlineformset_factory -from .models import McdaSession, GroupOrder, LocalOrder +from .models import McdaSession, Criterion, CritGroup, GroupOrder, LocalOrder # ------------------------------------------------------------------ @@ -69,6 +69,56 @@ def to_python(self, value): 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, *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] + + 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"]) # ------------------------------------------------------------------ # Form 1: basic settings (always shown) 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/models.py b/mysite/dss/models.py index afc7151..b603cb6 100644 --- a/mysite/dss/models.py +++ b/mysite/dss/models.py @@ -35,9 +35,9 @@ def validate_number_or_range(value): 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 + PENDING = "pending" # request received, forms not started IN_PROGRESS = "in_progress" # user is in the form wizard - COMPLETE = "complete" # mcda() has run + COMPLETE = "complete" # mcda() has run class Scenario(models.TextChoices): DETERMINISTIC = "deterministic" @@ -65,6 +65,7 @@ class SamplingMode(models.TextChoices): 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) @@ -88,6 +89,10 @@ class SamplingMode(models.TextChoices): 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. @@ -103,11 +108,12 @@ def init_criteria_n_groups(self): 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.all()) + criteria = list(self.criteria) criterion_names = [c.name for c in criteria] criterion_names.sort() @@ -170,8 +176,9 @@ 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="criteria") + 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") diff --git a/mysite/dss/templates/dss/step_0.html b/mysite/dss/templates/dss/step_0.html new file mode 100644 index 0000000..2dfc062 --- /dev/null +++ b/mysite/dss/templates/dss/step_0.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}Step 1 — KPI selection{% endblock %} + +{% block content %} +
+ + {# ── Progress bar ── #} +
+
+
+ +
+ + {# ── Header ── #} +
+

Step 1 of 4

+

KPI selection

+

+ Select which KPIs should be used as decision criteria. +

+
+ +
+ {% csrf_token %} + + {% for group in groups %} +
+ + {{ group.name }} + + +
    + {% for criterion in group.criteria.all %} +
  • + {{ criterion.field }} {{ criterion.name }} +
  • + {% endfor %} +
+
+ {% endfor %} + + {# ── Submit ── #} +
+ +
+ +
+
+
+{% endblock %} diff --git a/mysite/dss/templates/dss/step_1.html b/mysite/dss/templates/dss/step_1.html index bf22cf8..9321043 100644 --- a/mysite/dss/templates/dss/step_1.html +++ b/mysite/dss/templates/dss/step_1.html @@ -1,19 +1,19 @@ {% extends "base.html" %} -{% block title %}Step 1 — Analysis Setup{% endblock %} +{% block title %}Step 2 — Analysis Setup{% endblock %} {% block content %}
{# ── Progress bar ── #}
-
+
{# ── Header ── #}
-

Step 1 of 3

+

Step 2 of 4

Analysis setup

These four choices drive which options appear in the next step. @@ -42,12 +42,21 @@

Analysis setup

{# ── Veto type ── #} {% include "partials/radio_group.html" with field=form.veto_type label="Veto type" options=veto_type_options %} - {# ── Submit ── #} -
+ {# ── Navigation ── #} +
+ + + + + Back +