Skip to content

Commit bbdc318

Browse files
committed
Fix #15
1 parent 22bfdb9 commit bbdc318

11 files changed

+352
-34
lines changed

multifunctional/allocation.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def generic_allocation(
5151
if not total:
5252
raise ZeroDivisionError("Sum of allocation factors is zero")
5353

54+
act["mf_allocation_run_uuid"] = uuid4().hex
5455
processes = [act]
5556

5657
for original_exc in filter(lambda x: x.get("functional"), act.get("exchanges", [])):
@@ -146,6 +147,9 @@ def generic_allocation(
146147

147148
processes.append(allocated_process)
148149

150+
# Useful for other functions like purging expired links in future
151+
act["mf_was_once_allocated"] = True
152+
149153
return processes
150154

151155

multifunctional/edge_classes.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,6 @@ def __setitem__(self, key, value):
1919

2020

2121
class ReadOnlyExchanges(Exchanges):
22-
def delete(self):
23-
raise NotImplementedError("Exchanges are read-only")
24-
2522
def __iter__(self):
2623
for obj in self._get_queryset():
2724
yield ReadOnlyExchange(obj)

multifunctional/errors.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
11
class NoAllocationNeeded:
22
pass
3+
4+
5+
class MultipleFunctionalExchangesWithSameInput(Exception):
6+
"""Multiple functional links to same input product is not allowed."""
7+
8+
pass

multifunctional/node_classes.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .errors import NoAllocationNeeded
1010
from .utils import (
1111
product_as_process_name,
12+
purge_expired_linked_readonly_processes,
1213
set_correct_process_type,
1314
update_datasets_from_allocation_results,
1415
)
@@ -32,12 +33,8 @@ class MaybeMultifunctionalProcess(BaseMultifunctionalNode):
3233
Sets flag on save if multifunctional."""
3334

3435
def save(self):
35-
if self.multifunctional:
36-
self._data["type"] = "multifunctional"
37-
elif not self._data.get("type"):
38-
# TBD: This should use bw2data.utils.set_correct_process_type
39-
# but that wants datasets as dicts with exchanges
40-
self._data["type"] = labels.process_node_default
36+
set_correct_process_type(self)
37+
purge_expired_linked_readonly_processes(self)
4138
super().save()
4239

4340
def __str__(self):
@@ -52,6 +49,8 @@ def allocate(
5249
if self.get("skip_allocation"):
5350
return NoAllocationNeeded
5451
if not self.multifunctional:
52+
# Call save because we don't know if the process type should be changed
53+
self.save()
5554
return NoAllocationNeeded
5655

5756
from . import allocation_strategies
@@ -121,11 +120,6 @@ def new_edge(self, **kwargs):
121120
"This node is read only. Update the corresponding multifunctional process."
122121
)
123122

124-
def delete(self):
125-
raise NotImplementedError(
126-
"This node is read only. Update the corresponding multifunctional process."
127-
)
128-
129123
def exchanges(self, exchanges_class=None):
130124
if exchanges_class is not None:
131125
warnings.warn("`exchanges_class` argument ignored; must be `ReadOnlyExchanges`")

multifunctional/utils.py

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1+
from collections import Counter
12
from pprint import pformat
23
from typing import Dict, List
34

4-
from bw2data import get_node
5-
from bw2data.backends import Exchange
5+
from bw2data import get_node, labels
6+
from bw2data.backends import Exchange, Node
67
from bw2data.backends.schema import ExchangeDataset
78
from bw2data.errors import UnknownObject
89
from loguru import logger
910

11+
from multifunctional.errors import MultipleFunctionalExchangesWithSameInput
1012

11-
def allocation_before_writing(
12-
data: Dict[tuple, dict], strategy_label: str
13-
) -> Dict[tuple, dict]:
13+
14+
def allocation_before_writing(data: Dict[tuple, dict], strategy_label: str) -> Dict[tuple, dict]:
1415
"""Utility to perform allocation on datasets and expand `data` with allocated processes."""
1516
from . import allocation_strategies
1617

@@ -62,7 +63,7 @@ def add_exchange_input_if_missing(data: dict) -> dict:
6263

6364

6465
def update_datasets_from_allocation_results(data: List[dict]) -> None:
65-
"""Given data from allocation, create or update datasets as needed from `data`."""
66+
"""Given data from allocation, create, update, or delete datasets as needed from `data`."""
6667
from .node_classes import ReadOnlyProcessWithReferenceProduct
6768

6869
for ds in data:
@@ -98,3 +99,121 @@ def product_as_process_name(data: List[dict]) -> None:
9899
functional_excs = [exc for exc in ds["exchanges"] if exc.get("functional")]
99100
if len(functional_excs) == 1 and functional_excs[0].get("name"):
100101
ds["name"] = functional_excs[0]["name"]
102+
103+
104+
def set_correct_process_type(dataset: Node) -> Node:
105+
"""
106+
Change the `type` for an LCI process under certain conditions.
107+
108+
Only will make changes if the following conditions are met:
109+
110+
* `type` is `multifunctional` but the dataset is no longer multifunctional ->
111+
set to either `process` or `processwithreferenceproduct`
112+
* `type` is `None` or missing -> set to either `process` or `processwithreferenceproduct`
113+
* `type` is `process` but the dataset also includes an exchange which points to the same node
114+
-> `processwithreferenceproduct`
115+
116+
"""
117+
if dataset.get("type") not in (
118+
labels.chimaera_node_default,
119+
labels.process_node_default,
120+
"multifunctional",
121+
None,
122+
):
123+
pass
124+
elif dataset.multifunctional:
125+
dataset["type"] = "multifunctional"
126+
elif any(exc.input == exc.output for exc in dataset.exchanges()):
127+
if dataset["type"] == "multifunctional":
128+
logger.debug(
129+
"Changed %s (%s) type from `multifunctional` to `%s`",
130+
dataset.get("name"),
131+
dataset.id,
132+
labels.chimaera_node_default,
133+
)
134+
dataset["type"] = labels.chimaera_node_default
135+
elif any(exc.get("functional") for exc in dataset.exchanges()):
136+
if dataset["type"] == "multifunctional":
137+
logger.debug(
138+
"Changed %s (%s) type from `multifunctional` to `%s`",
139+
dataset.get("name"),
140+
dataset.id,
141+
labels.process_node_default,
142+
)
143+
dataset["type"] = labels.process_node_default
144+
elif (
145+
# No production edges -> implicit self production -> chimaera
146+
not any(
147+
exc.get("type") in labels.technosphere_positive_edge_types
148+
for exc in dataset.exchanges()
149+
)
150+
):
151+
dataset["type"] = labels.chimaera_node_default
152+
elif not dataset.get("type"):
153+
dataset["type"] = labels.process_node_default
154+
else:
155+
# No conditions for setting or changing type occurred
156+
pass
157+
158+
return dataset
159+
160+
161+
def purge_expired_linked_readonly_processes(dataset: Node) -> None:
162+
from .database import MultifunctionalDatabase
163+
164+
if not dataset.get("mf_was_once_allocated"):
165+
return
166+
167+
if dataset["type"] == "multifunctional":
168+
# Can have some readonly allocated processes which refer to non-functional edges
169+
for ds in MultifunctionalDatabase(dataset["database"]):
170+
if (
171+
ds["type"] in ("readonly_process",)
172+
and ds.get("mf_parent_key") == dataset.key
173+
and ds["mf_allocation_run_uuid"] != dataset["mf_allocation_run_uuid"]
174+
):
175+
ds.delete()
176+
177+
for exc in dataset.exchanges():
178+
try:
179+
exc.input
180+
except UnknownObject:
181+
exc.input = dataset
182+
exc.save()
183+
logger.debug(
184+
"Edge to deleted readonly process redirected to parent process: %s",
185+
exc,
186+
)
187+
188+
else:
189+
# Process or chimaera process with one functional edge
190+
# Make sure that single functional edge is not referring to obsolete readonly process
191+
functional_edges = [exc for exc in dataset.exchanges() if exc.get("functional")]
192+
if not len(functional_edges) < 2:
193+
raise ValueError(
194+
f"Process marked monofunctional with type {dataset['type']} but has {len(functional_edges)} functional edges"
195+
)
196+
edge = functional_edges[0]
197+
if edge.input["type"] in (
198+
"readonly_process",
199+
): # TBD https://github.com/brightway-lca/multifunctional/issues/23
200+
# This node should be deleted; have to change to chimaera process with self-input
201+
logger.debug(
202+
"Edge to expired readonly process %s redirected to parent process %s",
203+
edge.input,
204+
dataset,
205+
)
206+
edge.input = dataset
207+
edge.save()
208+
if dataset["type"] != labels.chimaera_node_default:
209+
logger.debug(
210+
"Change node type to chimaera: %s (%s)",
211+
dataset,
212+
dataset.id,
213+
)
214+
dataset["type"] = labels.chimaera_node_default
215+
216+
# Obsolete readonly processes
217+
for ds in MultifunctionalDatabase(dataset["database"]):
218+
if ds["type"] in ("readonly_process",) and ds.get("mf_parent_key") == dataset.key:
219+
ds.delete()

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from fixtures.internal_linking import DATA as INTERNAL_LINKING_DATA
88
from fixtures.product_properties import DATA as PP_DATA
99
from fixtures.products import DATA as PRODUCT_DATA
10+
from fixtures.many_products import DATA as MANY_PRODUCTS_DATA
1011

1112
from multifunctional import MultifunctionalDatabase, allocation_before_writing
1213

@@ -34,6 +35,15 @@ def products():
3435
return db
3536

3637

38+
@pytest.fixture
39+
@bw2test
40+
def many_products():
41+
db = MultifunctionalDatabase("products")
42+
db.write(deepcopy(MANY_PRODUCTS_DATA), process=False)
43+
db.metadata["dirty"] = True
44+
return db
45+
46+
3747
@pytest.fixture
3848
@bw2test
3949
def errors():

tests/fixtures/many_products.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
DATA = {
2+
("products", "a"): {
3+
"name": "flow - a",
4+
"code": "a",
5+
"unit": "kg",
6+
"type": "emission",
7+
"categories": ("air",),
8+
},
9+
("products", "p1"): {
10+
"type": "product",
11+
"name": "first product",
12+
"unit": "kg",
13+
"exchanges": [],
14+
},
15+
("products", "p2"): {
16+
"type": "product",
17+
"name": "first product",
18+
"unit": "kg",
19+
"exchanges": [],
20+
},
21+
("products", "p3"): {
22+
"type": "product",
23+
"name": "first product",
24+
"unit": "kg",
25+
"exchanges": [],
26+
},
27+
("products", "1"): {
28+
"name": "process - 1",
29+
"code": "1",
30+
"location": "first",
31+
"type": "multifunctional",
32+
"exchanges": [
33+
{
34+
"functional": True,
35+
"type": "production",
36+
"input": ("products", "p1"),
37+
"amount": 4,
38+
"properties": {
39+
"price": 7,
40+
"mass": 6,
41+
},
42+
},
43+
{
44+
"functional": True,
45+
"type": "production",
46+
"input": ("products", "p2"),
47+
"amount": 4,
48+
"properties": {
49+
"price": 7,
50+
"mass": 6,
51+
},
52+
},
53+
{
54+
"functional": True,
55+
"type": "production",
56+
"input": ("products", "p3"),
57+
"amount": 4,
58+
"properties": {
59+
"price": 7,
60+
"mass": 6,
61+
},
62+
},
63+
{
64+
"type": "biosphere",
65+
"name": "flow - a",
66+
"amount": 10,
67+
"input": ("products", "a"),
68+
},
69+
],
70+
},
71+
}

tests/test_internal_linking_zero_allocation.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def test_allocation_sets_code_for_zero_allocation_products_in_multifunctional_pr
8080
},
8181
],
8282
"type": "multifunctional",
83+
"mf_was_once_allocated": True,
8384
"mf_strategy_label": "property allocation by 'manual_allocation'",
8485
"name": "(unknown)",
8586
"location": None,
@@ -153,4 +154,8 @@ def test_allocation_sets_code_for_zero_allocation_products_in_multifunctional_pr
153154
"database": "db",
154155
},
155156
]
156-
assert allocation_strategies["manual_allocation"](given) == expected
157+
result = allocation_strategies["manual_allocation"](given)
158+
for node in result:
159+
if "mf_allocation_run_uuid" in node:
160+
del node["mf_allocation_run_uuid"]
161+
assert result == expected

tests/test_node_creation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def test_node_creation_default_label():
3434
assert node["name"] == "foo"
3535
assert node["database"] == "test database"
3636
assert node["code"]
37-
assert node["type"] == bd.labels.process_node_default
37+
assert node["type"] == bd.labels.chimaera_node_default
3838

3939

4040
@bw2test

tests/test_read_only_nodes.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,6 @@ def test_read_only_node(basic):
1010
node = sorted(basic, key=lambda x: (x["name"], x.get("reference product", "")))[2]
1111
assert isinstance(node, mf.ReadOnlyProcessWithReferenceProduct)
1212

13-
with pytest.raises(NotImplementedError) as info:
14-
node.delete()
15-
assert "This node is read only" in info.value.args[0]
16-
1713
with pytest.raises(NotImplementedError) as info:
1814
node.copy()
1915
assert "This node is read only" in info.value.args[0]
@@ -42,10 +38,6 @@ def test_read_only_exchanges(basic):
4238
exc.save()
4339
assert "Read-only exchange" in info.value.args[0]
4440

45-
with pytest.raises(NotImplementedError) as info:
46-
exc.delete()
47-
assert "Read-only exchange" in info.value.args[0]
48-
4941
with pytest.raises(NotImplementedError) as info:
5042
exc["foo"] = "bar"
5143
assert "Read-only exchange" in info.value.args[0]
@@ -58,10 +50,6 @@ def test_read_only_exchanges(basic):
5850
# exc.output = node
5951
# assert 'Read-only exchange' in info.value.args[0]
6052

61-
with pytest.raises(NotImplementedError) as info:
62-
node.exchanges().delete()
63-
assert "Exchanges are read-only" in info.value.args[0]
64-
6553

6654
def test_read_only_parent(basic):
6755
basic.metadata["default_allocation"] = "mass"

0 commit comments

Comments
 (0)