Skip to content

Commit fe9654f

Browse files
authored
Version 1.1.1
Robustness overhaul
2 parents 963f801 + bb88098 commit fe9654f

File tree

7 files changed

+143
-17
lines changed

7 files changed

+143
-17
lines changed

.github/workflows/python-package.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
strategy:
1717
fail-fast: false
1818
matrix:
19-
python-version: ["3.9", "3.10", "3.11"]
19+
python-version: ["3.10", "3.11", "3.12", "3.13"]
2020

2121
steps:
2222
- uses: actions/checkout@v4

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ then it is possible to instantiate the DSMs with this as a keyword attribute:
112112
```
113113
dsm = DSM(matrix=data, columns=column_names, instigator="row")
114114
```
115-
The default is `instigator='column`.
115+
The default is `instigator='column'`.
116116
This can also be utilized when parsing a CSV-file, like this:
117117

118118
```

cpm/models.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import Optional
2+
import numbers
23

34

45
class DSM:
@@ -16,14 +17,41 @@ def __init__(self, matrix: list[list[Optional[float]]], columns: list[str], inst
1617
:param instigator: Can either be **column** or **row**. Determines directionality of interactions in DSM.
1718
By default, propagation travels from column to row
1819
"""
19-
self.matrix = matrix
20+
self.matrix = DSM.clean_matrix(matrix)
2021
self.columns = columns
2122
self.node_network: dict[int, 'GraphNode'] = self.build_node_network(instigator)
2223
self.instigator = instigator
2324

2425
if instigator not in ['row', 'column']:
2526
raise ValueError('instigator argument needs to be either "row" or "column".')
2627

28+
self.validate_matrix()
29+
30+
@staticmethod
31+
def clean_matrix(matrix) -> list[list[float]]:
32+
cleaned_matrix = []
33+
for i, row in enumerate(matrix):
34+
cleaned_matrix.append([])
35+
for j, val in enumerate(row):
36+
if val is None:
37+
val = 0
38+
try:
39+
cleaned_value = float(val)
40+
except ValueError:
41+
cleaned_value = 0
42+
43+
cleaned_matrix[i].append(cleaned_value)
44+
45+
return cleaned_matrix
46+
47+
def validate_matrix(self):
48+
try:
49+
assert len(self.matrix) == len(self.columns)
50+
for row in self.matrix:
51+
assert len(row) == len(self.columns)
52+
except AssertionError:
53+
raise ValueError('Matrix dimensions are inconsistent with provided columns.')
54+
2755
def __str__(self):
2856
return f'{self.columns}\n{self.matrix}'
2957

@@ -47,8 +75,8 @@ def build_node_network(self, instigator: str) -> dict[int, 'GraphNode']:
4775
# Ignore diagonal
4876
if i == j:
4977
continue
50-
# Ignore empty cells
51-
if col == "" or col is None:
78+
# Ignore empty connections
79+
if col == "" or col is None or col == 0:
5280
continue
5381

5482
numerical_value = 0.0
@@ -96,8 +124,13 @@ def set_level(self):
96124

97125
return level
98126

99-
def get_probability(self):
127+
def get_probability(self, stack=0):
100128

129+
# If this node is the single node in the chain, then the probability is none.
130+
if len(self.next) == 0 and stack == 0:
131+
return 0
132+
133+
# If final node in chain, set probability to 1 to complete the calculation
101134
if len(self.next) == 0:
102135
return 1
103136

@@ -107,7 +140,7 @@ def get_probability(self):
107140
# Likelihood of propagating to this node
108141
from_this = self.node.neighbours[next_index]
109142
# Likelihood of that node being propagated to:
110-
to_next = self.next[next_index].get_probability()
143+
to_next = self.next[next_index].get_probability(stack=stack+1)
111144
prob = prob * (1 - from_this * to_next)
112145

113146
return 1 - prob
@@ -161,6 +194,9 @@ def __init__(self, start_index: int, target_index: int, dsm_impact: DSM, dsm_lik
161194
start_index = target_index
162195
target_index = temp
163196

197+
if len(dsm_impact.matrix) != len(dsm_likelihood.matrix):
198+
raise ValueError('Impact and Likelihood matrices need to have the same dimensions.')
199+
164200
self.dsm_impact: DSM = dsm_impact
165201
self.dsm_likelihood: DSM = dsm_likelihood
166202
self.start_index: int = start_index

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from setuptools import setup, find_packages
22

33
# python setup.py bdist_wheel sdist
4-
VERSION = '1.1.0'
4+
VERSION = '1.1.1'
55
DESCRIPTION = 'Tool for calculating risk of change propagation in a system.'
66

77
with open("README.md", "r") as fh:

tests/test_input_validation.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import pytest
2+
from cpm.models import ChangePropagationTree, DSM
3+
from cpm.parse import parse_csv
4+
from cpm.utils import calculate_risk_matrix
5+
6+
7+
def test_throws_if_dsm_instigator_mismatch_1():
8+
dsm_p = parse_csv('./tests/test-assets/dsm-cpx-probs.csv', instigator="row")
9+
dsm_i = parse_csv('./tests/test-assets/dsm-cpx-imps.csv')
10+
11+
with pytest.raises(ValueError):
12+
calculate_risk_matrix(dsm_i, dsm_p, search_depth=4)
13+
14+
15+
def test_throws_if_dsm_instigator_mismatch_2():
16+
dsm_p = parse_csv('./tests/test-assets/dsm-cpx-probs.csv', instigator="row")
17+
dsm_i = parse_csv('./tests/test-assets/dsm-cpx-imps.csv')
18+
19+
with pytest.raises(ValueError):
20+
ChangePropagationTree(0, 4, dsm_i, dsm_p)
21+
22+
23+
def test_throws_if_dsm_incomplete():
24+
mtx = [
25+
[0.1, 0.2, 0.3],
26+
[0.4, 0.5, 0.6],
27+
[0.7, 0.8, 0.9],
28+
]
29+
cols = ["a", "b", "c", "d"]
30+
with pytest.raises(ValueError):
31+
dsm = DSM(mtx, cols)
32+
33+
34+
def test_throws_if_dsm_size_mismatch():
35+
cols_p = ["a", "b", "c"]
36+
mtx_p = [
37+
[0.1, 0.2, 0.3],
38+
[0.4, 0.5, 0.6],
39+
[0.7, 0.8, 0.9],
40+
]
41+
cols_i = ["a", "b", "c", "d"]
42+
mtx_i = [
43+
[0.1, 0.2, 0.3, 0.4],
44+
[0.4, 0.5, 0.6, 0.7],
45+
[0.7, 0.8, 0.9, 0.10],
46+
[0.11, 0.12, 0.13, 0.14]
47+
]
48+
49+
dsm_p = DSM(mtx_p, cols_p)
50+
dsm_i = DSM(mtx_i, cols_i)
51+
52+
with pytest.raises(ValueError):
53+
# If DSMs are of different size, then input validation should prevent execution.
54+
ChangePropagationTree(0, 2, dsm_i, dsm_p)

tests/test_parser.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ def test_parse_dsm_header():
1414
def test_parse_dsm_matrix():
1515
dsm = parse_csv('./tests/test-assets/dsm-simple-symmetrical.csv')
1616

17-
should_be = [[None, 0.1, 0.2, 0.3],
18-
[0.1, None, 0.4, 0.5],
19-
[0.2, 0.4, None, 0.6],
20-
[0.3, 0.5, 0.6, None]]
17+
should_be = [[0, 0.1, 0.2, 0.3],
18+
[0.1, 0, 0.4, 0.5],
19+
[0.2, 0.4, 0, 0.6],
20+
[0.3, 0.5, 0.6, 0]]
2121

2222
for i, row in enumerate(should_be):
2323
for j, col in enumerate(row):

tests/test_propagation.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from cpm.models import ChangePropagationTree
1+
from cpm.models import ChangePropagationTree, DSM
22
from cpm.parse import parse_csv
33
from cpm.utils import calculate_risk_matrix
44

@@ -112,8 +112,44 @@ def test_probability_calculation():
112112

113113
dsm_r = parse_csv('./tests/test-assets/dsm-cpx-answers-probs.csv')
114114

115-
for i, col in enumerate(dsm_r.columns):
116-
for j, col in enumerate(dsm_r.columns):
117-
if dsm_r.matrix[i][j] is None:
115+
for i, col_i in enumerate(dsm_r.columns):
116+
for j, col_j in enumerate(dsm_r.columns):
117+
if i == j:
118118
continue
119-
assert abs(res_mtx[i][j] - dsm_r.matrix[i][j]) < 0.001
119+
assert abs(res_mtx[i][j] - dsm_r.matrix[i][j]) < 0.001, f"Failed for index i={i} (row {col_i}), j={j} (col {col_j})"
120+
121+
122+
def test_dsm_input_robustness():
123+
instigator = 'column'
124+
cols = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
125+
# Purposefully poorly formatted input matrix
126+
mtx_i = [['-', '0', 0.3, None, None, None, 0.7, 0],
127+
[None, '-', 0, 0.4, 0.5, None, None, None],
128+
["0.1", 0, '-', '', '', '0.6', 0, 0],
129+
[0, 0, 0, 'D', 0, 0, None, 0.8],
130+
[0, 0, 0, 0, None, None, 0.7, 0],
131+
['0.1', '0.2', 0, 0, None, 'F', 0.7, 0],
132+
[0, 0, 0, 0, 0, 0.6, 99, 0],
133+
[0, 0, 0.3, 0.4, 0, 0, 0, 'H']]
134+
135+
dsm_i = DSM(mtx_i, cols, instigator)
136+
# Purposefully poorly formatted input matrix
137+
mtx_l = [[None, None, 0.1, None, None, None, 0.1, None],
138+
[None, 'B', 0, 0.2, "0.2", 0, 0, None],
139+
["0.3", None, 'C', None, None, 0.3, None, 0],
140+
[0, 0, 0, "D", 0, 0, 0, 0.4],
141+
[0, 0, 0, 0, "E", 0, 0.5, 0],
142+
[0.6, 0.6, None, None, None, "F", "0.6", None],
143+
[None, None, None, None, None, 0.7, "G", 0],
144+
[0, 0, 0.8, 0.8, 0, 0, 0, "H"]]
145+
146+
dsm_p = DSM(mtx_l, cols, instigator)
147+
148+
dsm_r = parse_csv('./tests/test-assets/dsm-cpx-answers-risks.csv')
149+
res_mtx = calculate_risk_matrix(dsm_i, dsm_p, search_depth=4)
150+
151+
for i, col_i in enumerate(dsm_r.columns):
152+
for j, col_j in enumerate(dsm_r.columns):
153+
if i == j:
154+
continue
155+
assert abs(res_mtx[i][j] - dsm_r.matrix[i][j]) < 0.001, f"Failed for index i={i} (row {col_i}), j={j} (col {col_j})"

0 commit comments

Comments
 (0)