# Source code for skcriteria.agg.simus

```#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright (c) 2022, 2023, 2024 QuatroPe

# =============================================================================
# DOCS
# =============================================================================

"""SIMUS (Sequential Interactive Model for Urban Systems) Method."""

# =============================================================================
# IMPORTS
# =============================================================================

from ..utils import hidden

with hidden():
import warnings

import numpy as np

from ._agg_base import RankResult, SKCDecisionMakerABC
from ..core import Objective
from ..preprocessing.scalers import scale_by_sum
from ..utils import doc_inherit, lp, rank

# =============================================================================
# INTERNAL FUNCTIONS
# =============================================================================

# STAGES ======================================================================

def _make_and_run_stage(transposed_matrix, b, senses, z_index, solver):
# retrieve the problem class
problem = (
lp.Minimize if senses[z_index] == Objective.MIN.value else lp.Maximize
)

# create the variables
xs = [
lp.Float(f"x{idx}", low=0) for idx in range(transposed_matrix.shape[1])
]

# create the objective function based on the criteria of row "z_index"
stage_z_coefficients = transposed_matrix[z_index]
stage_z = sum(
coefficients * x for coefficients, x in zip(stage_z_coefficients, xs)
)

# create the stage
stage = problem(z=stage_z, solver=solver)

# the constraints are other files except the row of z_index
for idx in range(transposed_matrix.shape[0]):
if idx == z_index:
continue
coefficients = transposed_matrix[idx]

# the two parts of the comparison
left = sum(c * x for c, x in zip(coefficients, xs))
right = b[idx]

# >= if objective is to minimize <= maximize
constraint = (
(left >= right)
if senses[idx] == Objective.MIN.value
else (left <= right)
)
stage.subject_to(constraint)

stage_result = stage.solve()
return stage_result

def _solve_stages(transposed_matrix, b, objectives, solver):
# execute the function inside the joblib environment one by objective.
stages = []
for idx in range(transposed_matrix.shape[0]):
stage = _make_and_run_stage(
transposed_matrix=transposed_matrix,
b=b,
senses=objectives,
z_index=idx,
solver=solver,
)
stages.append(stage)

# create the results mtx
arr_result = np.vstack([r.lp_values for r in stages])

with np.errstate(invalid="ignore"):
stages_result = scale_by_sum(arr_result, axis=1)

# replace nan for 0
stages_result[np.isnan(stages_result)] = 0

return stages, stages_result

# FIRST METHOD  ===============================================================

def _first_method(*, stages_results):
# project sum value
sp = np.sum(stages_results, axis=0)

# times that \$v_{ij} > 0\$ (\$q\$)
q = np.sum(stages_results > 0, axis=0).astype(float)

# participation factor
fp = q / len(stages_results)

# first method points
vp = sp * fp

return vp

# SECOND METHOD  ==============================================================

def _calculate_dominance_by_criteria(crit):
shape = len(crit), 1
crit_B = np.tile(crit, shape)
crit_A = crit_B.T
dominance = crit_A - crit_B
dominance[dominance < 0] = 0
return dominance

def _second_method(*, stages_results):
# dominances by criteria
dominance_by_criteria = []
for crit in stages_results:
dominance = _calculate_dominance_by_criteria(crit)
dominance_by_criteria.append(dominance)

# dominance
dominance = np.sum(dominance_by_criteria, axis=0)

# domination
tita_j_p = np.sum(dominance, axis=1)

# subordination
tita_j_d = np.sum(dominance, axis=0)

# second method score
score = tita_j_p - tita_j_d

return score, tita_j_p, tita_j_d, dominance, tuple(dominance_by_criteria)

# SIMUS =======================================================================

[docs]
def simus(matrix, objectives, b=None, rank_by=1, solver="pulp"):
"""Execute SIMUS without any validation."""
transposed_matrix = matrix.T

# check the b array and complete the missing values
b = np.asarray(b)
if None in b:
mins = np.min(transposed_matrix, axis=1)
maxs = np.max(transposed_matrix, axis=1)

auto_b = np.where(objectives == Objective.MIN.value, mins, maxs)
b = np.where(b != None, b, auto_b)  # noqa

# create and execute the stages
stages, stages_results = _solve_stages(
transposed_matrix=transposed_matrix,
b=b,
objectives=objectives,
solver=solver,
)

# first method
method_1_score = _first_method(stages_results=stages_results)

# second method
(
method_2_score,
tita_j_p,
tita_j_d,
dominance,
dominance_by_criteria,
) = _second_method(stages_results=stages_results)

# calculate ranking
score = [method_1_score, method_2_score][rank_by - 1]
ranking = rank.rank_values(score, reverse=True)

return (
ranking,
stages,
stages_results,
method_1_score,
method_2_score,
tita_j_p,
tita_j_d,
dominance,
dominance_by_criteria,
)

[docs]
class SIMUS(SKCDecisionMakerABC):
r"""SIMUS (Sequential Interactive Model for Urban Systems).

SIMUS developed by Nolberto Munier (2011) is a tool to aid decision-making
problems with multiple objectives. The method solves successive scenarios
formulated as linear programs. For each scenario, the decision-maker must
choose the criterion to be considered objective while the remaining
restrictions constitute the constrains system that the projects are subject
to. In each case, if there is a feasible solution that is optimum, it is
recorded in a matrix of efficient results. Then, from this matrix two
rankings allow the decision maker to compare results obtained by different
procedures. The first ranking is obtained through a linear weighting of
each column by a factor - equivalent of establishing a weight - and that
measures the participation of the corresponding project. In the second
ranking, the method uses dominance and subordinate relationships between
projects, concepts from the French school of MCDM.

Parameters
----------
rank_by : 1 or 2 (default=1)
Witch of the two methods are used to calculate the ranking.
The two methods are executed always.
solver : str, (default="pulp")
Which solver to use to solve the underlying linear programs. The full
list are available in `pulp.listSolvers(True)`. "pulp" or None used
the default solver selected by "PuLP".

Warnings
--------
UserWarning:
If the method detect different weights by criteria.

Raises
------
ValueError:
If the length of b does not match the number of criteria.

See
---
`PuLP Documentation <https://coin-or.github.io/pulp/>`_

"""

_skcriteria_parameters = ["rank_by", "solver"]

def __init__(self, *, rank_by=1, solver="pulp"):
if not (
isinstance(solver, lp.pulp.LpSolver)
or lp.is_solver_available(solver)
):
raise ValueError(f"solver {solver} not available")
self._solver = solver
if rank_by not in (1, 2):
raise ValueError("'rank_by' must be 1 or 2")
self._rank_by = rank_by

@property
def solver(self):
"""Solver used by PuLP."""
return self._solver

@property
def rank_by(self):
"""Which of the two ranking provided by SIMUS is used."""
return self._rank_by

@doc_inherit(SKCDecisionMakerABC._evaluate_data)
def _evaluate_data(self, matrix, objectives, b, weights, **kwargs):
if len(np.unique(weights)) > 1:
warnings.warn("SIMUS not take into account the weights")
if b is not None and len(objectives) != len(b):
raise ValueError("'b' must be the same leght as criteria or None")
(
ranking,
stages,
stages_results,
method_1_score,
method_2_score,
tita_j_p,
tita_j_d,
dominance,
dominance_by_criteria,
) = simus(
matrix,
objectives,
b=b,
rank_by=self.rank_by,
solver=self.solver,
)
return ranking, {
"rank_by": self._rank_by,
"b": np.copy(b),
"stages": stages,
"stages_results": stages_results,
"method_1_score": method_1_score,
"method_2_score": method_2_score,
"tita_j_p": tita_j_p,
"tita_j_d": tita_j_d,
"dominance": dominance,
"dominance_by_criteria": dominance_by_criteria,
}

@doc_inherit(SKCDecisionMakerABC._make_result)
def _make_result(self, alternatives, values, extra):
return RankResult(
"SIMUS", alternatives=alternatives, values=values, extra=extra
)

[docs]
def evaluate(self, dm, *, b=None):
"""Validate the decision matrix and calculate a ranking.

Parameters
----------
dm: :py:class:`skcriteria.data.DecisionMatrix`
Decision matrix on which the ranking will be calculated.
b: :py:class:`numpy.ndarray`
Right-side-value of the LP problem,

SIMUS automatically assigns the vector of the right side (b) in
the constraints of linear programs.

If the criteria are to maximize, then the constraint is <=;
and if the column minimizes the constraint is >=.
The b/right side value limits of the constraint are chosen
automatically based on the minimum or maximum value of the
criteria/column if the constraint is <= or >= respectively.

The user provides "b" in some criteria and lets SIMUS choose
automatically others.  For example, if you want to limit the two
constraints of the dm with 4 criteria by the value 100,  b must be
`[None, 100, 100, None]` where None will be chosen automatically
by SIMUS.

Returns
-------
:py:class:`skcriteria.data.RankResult`
Ranking.

"""
data = dm.to_dict()
b = b if b is None else np.asarray(b)

rank, extra = self._evaluate_data(b=b, **data)

alternatives = data["alternatives"]
result = self._make_result(
alternatives=alternatives, values=rank, extra=extra
)

return result

```